@linxin666/dsh-remote-web-ui 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +17 -8
- package/README.zh.md +5 -5
- package/lib/client.js +259 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +26 -7
- package/lib/mobile.js +209 -39
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +22 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +142 -3
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +11 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +8 -5
- package/lib/types/client/UpdatePanel.d.ts +3 -1
- package/lib/types/client/UpdatePanel.d.ts.map +1 -1
- package/lib/types/client/UpdatePanel.js +3 -3
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +8 -4
- package/lib/types/client/pair-api.d.ts +1 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +1 -10
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +2 -0
- package/lib/types/mobile/api.d.ts +10 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +4 -0
- package/lib/types/mobile/messages.d.ts +24 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +60 -23
- package/lib/types/mobile/messages.test.js +49 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +38 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +34 -11
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +30 -4
- package/lib/types/mobile/views/SessionListView.test.js +58 -1
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +4 -2
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +10 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -2
- package/lib/types/tunnel.d.ts +1 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -2
- package/lib/types/update.d.ts +1 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +1 -1
- package/package.json +1 -2
- package/src/client/PluginSettingsCard.tsx +219 -18
- package/src/client/RemoteEntry.tsx +10 -0
- package/src/client/RemoteSettingsCard.tsx +2 -2
- package/src/client/UpdateEntry.tsx +9 -5
- package/src/client/UpdatePanel.tsx +12 -1
- package/src/client/locales.ts +8 -4
- package/src/client/pair-api.ts +1 -10
- package/src/client/settings-card.module.css +90 -1
- package/src/gate.ts +2 -0
- package/src/mobile/api.ts +14 -1
- package/src/mobile/messages.test.ts +52 -1
- package/src/mobile/messages.ts +65 -22
- package/src/mobile/mobile-styles.ts +38 -0
- package/src/mobile/mobile-theme.test.ts +1 -1
- package/src/mobile/views/ChatView.tsx +33 -10
- package/src/mobile/views/SessionListView.test.tsx +69 -1
- package/src/mobile/views/SessionListView.tsx +58 -5
- package/src/mobile/views/WorkspaceView.tsx +4 -2
- package/src/mobile-api.ts +9 -1
- package/src/routes.ts +22 -2
- package/src/tunnel.ts +7 -2
- package/src/update.ts +2 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The sidebar update seat: the download trigger beside the remote-control
|
|
3
3
|
* trigger plus the update panel modal. Owns the flow — probe the registry
|
|
4
|
-
* on open,
|
|
5
|
-
* outcome (restart hint on success,
|
|
4
|
+
* on open, show the result, and let the user start the update from the
|
|
5
|
+
* result view (#507), then report the outcome (restart hint on success,
|
|
6
|
+
* translated failure on error).
|
|
6
7
|
* Component-local state per the client stack rules.
|
|
7
8
|
*/
|
|
8
9
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
@@ -72,9 +73,12 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
72
73
|
setView({ kind: "result", status })
|
|
73
74
|
return
|
|
74
75
|
}
|
|
76
|
+
// Checking never starts an update by itself (#507): the result view
|
|
77
|
+
// carries the confirmation button that calls startUpdate.
|
|
75
78
|
setView({ kind: "result", status })
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
}, [t])
|
|
80
|
+
|
|
81
|
+
const startUpdate = useCallback(async (status: UpdateStatus): Promise<void> => {
|
|
78
82
|
if (status.mode !== "npm" || !status.outdated) return
|
|
79
83
|
setView({ kind: "updating", status })
|
|
80
84
|
const token = ++runToken.current
|
|
@@ -128,7 +132,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
|
|
|
128
132
|
{open && createPortal((
|
|
129
133
|
<div className={css.overlay} role="presentation">
|
|
130
134
|
<div className={css.mask} aria-hidden="true" onClick={closePanel} />
|
|
131
|
-
<UpdatePanel t={t} view={view} onClose={closePanel} onRecheck={() => { void check() }} />
|
|
135
|
+
<UpdatePanel t={t} view={view} onClose={closePanel} onRecheck={() => { void check() }} onStartUpdate={(status) => { void startUpdate(status) }} />
|
|
132
136
|
</div>
|
|
133
137
|
), document.body)}
|
|
134
138
|
</>
|
|
@@ -24,6 +24,8 @@ export interface UpdatePanelProps {
|
|
|
24
24
|
onClose(): void
|
|
25
25
|
/** Re-run the check from a terminal state. */
|
|
26
26
|
onRecheck(): void
|
|
27
|
+
/** Start the update from the result view (the confirmation step, #507). */
|
|
28
|
+
onStartUpdate(status: UpdateStatus): void
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** The anchor package name (aggregate first) for copy purposes. */
|
|
@@ -41,7 +43,7 @@ function anchorLatest(status: UpdateStatus | undefined): string | undefined {
|
|
|
41
43
|
* @param props - copy, view state, and actions.
|
|
42
44
|
* @returns the panel element tree.
|
|
43
45
|
*/
|
|
44
|
-
export function UpdatePanel({ t, view, onClose, onRecheck }: UpdatePanelProps) {
|
|
46
|
+
export function UpdatePanel({ t, view, onClose, onRecheck, onStartUpdate }: UpdatePanelProps) {
|
|
45
47
|
const status = view.kind === "result" || view.kind === "updating" ? view.status : undefined
|
|
46
48
|
const title = view.kind === "done" && view.result.ok ? t("update.done") : t("update.title")
|
|
47
49
|
const subtitle = subtitleOf(t, view)
|
|
@@ -58,11 +60,19 @@ export function UpdatePanel({ t, view, onClose, onRecheck }: UpdatePanelProps) {
|
|
|
58
60
|
</div>
|
|
59
61
|
{view.kind === "checking" && <p className={css.updateStatus}>{t("update.checking")}</p>}
|
|
60
62
|
{view.kind === "result" && status !== undefined && <ResultBody t={t} status={status} />}
|
|
63
|
+
{view.kind === "result" && status !== undefined && status.mode === "npm" && status.outdated && status.error === undefined && (
|
|
64
|
+
<div className={css.updateActions}>
|
|
65
|
+
<button type="button" className={css.updateRetry} onClick={() => onStartUpdate(status)}>
|
|
66
|
+
{t("update.start")}
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
61
70
|
{view.kind === "updating" && status !== undefined && (
|
|
62
71
|
<div>
|
|
63
72
|
<p className={css.updateStatus}>
|
|
64
73
|
{t("update.updating", { name: anchorName(status) ?? "", version: anchorLatest(status) ?? "" })}
|
|
65
74
|
</p>
|
|
75
|
+
<p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
|
|
66
76
|
<PackageList status={status} />
|
|
67
77
|
</div>
|
|
68
78
|
)}
|
|
@@ -136,6 +146,7 @@ function ResultBody({ t, status }: { t: TranslateNS<"remote">; status: UpdateSta
|
|
|
136
146
|
<p className={css.updateDetail}>
|
|
137
147
|
{anchor !== undefined ? t("update.foundDetail", { name: anchor, version: latest ?? "" }) : ""}
|
|
138
148
|
</p>
|
|
149
|
+
<p className={css.updateDetail}>{t("update.cooldownNotice")}</p>
|
|
139
150
|
</div>
|
|
140
151
|
)
|
|
141
152
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -80,9 +80,11 @@ export const zh = {
|
|
|
80
80
|
'update.upToDate': '当前已是最新版本',
|
|
81
81
|
'update.upToDateDetail': '{name} 已是最新版本 v{version}。',
|
|
82
82
|
'update.found': '发现新版本',
|
|
83
|
-
'update.foundDetail': '{name} 有新版本 v{version}
|
|
83
|
+
'update.foundDetail': '{name} 有新版本 v{version}。确认后开始更新。',
|
|
84
|
+
'update.start': '开始更新',
|
|
84
85
|
'update.updating': '正在更新 {name} 至 v{version},可能需要 1-2 分钟…',
|
|
85
|
-
'update.
|
|
86
|
+
'update.cooldownNotice': '本次更新由你主动触发,将跳过 pnpm 的 24 小时发布冷却期(供应链保护)直接安装所选版本。',
|
|
87
|
+
'update.updatingTitle': '正在更新…',
|
|
86
88
|
'update.done': '更新完成',
|
|
87
89
|
'update.doneDetail': '所有组件已更新到最新版本。',
|
|
88
90
|
'update.restartHint': '请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。',
|
|
@@ -194,9 +196,11 @@ export const en = {
|
|
|
194
196
|
'update.upToDate': 'Everything is up to date',
|
|
195
197
|
'update.upToDateDetail': '{name} is at the latest version v{version}.',
|
|
196
198
|
'update.found': 'A new version is available',
|
|
197
|
-
'update.foundDetail': '{name} has a newer release v{version}
|
|
199
|
+
'update.foundDetail': '{name} has a newer release v{version}. Confirm to start the update.',
|
|
200
|
+
'update.start': 'Update now',
|
|
198
201
|
'update.updating': 'Updating {name} to v{version}; this may take 1-2 minutes…',
|
|
199
|
-
'update.
|
|
202
|
+
'update.cooldownNotice': 'This user-initiated update bypasses pnpm\'s 24-hour release cooldown (supply-chain protection) and installs the selected version immediately.',
|
|
203
|
+
'update.updatingTitle': 'Updating…',
|
|
200
204
|
'update.done': 'Update complete',
|
|
201
205
|
'update.doneDetail': 'All components are up to date.',
|
|
202
206
|
'update.restartHint': 'Restart dsh web for the new version to take effect: stop it with Ctrl+C in the terminal, then run dsh web again.',
|
package/src/client/pair-api.ts
CHANGED
|
@@ -128,16 +128,7 @@ export function readPairParams(search: string): { pair?: string; workspace?: str
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
132
|
-
* Strip one query parameter from the current URL without reloading.
|
|
133
|
-
* @param name - the parameter to remove.
|
|
134
|
-
* @returns the new search string ('' when empty).
|
|
135
|
-
*/
|
|
136
|
-
export function stripParam(name: string): string {
|
|
137
|
-
const url = new URL(window.location.href)
|
|
138
|
-
url.searchParams.delete(name)
|
|
139
|
-
return url.search
|
|
140
|
-
}/** Human-readable expiry clock, e.g. "10:35". */
|
|
131
|
+
/** Human-readable expiry clock, e.g. "10:35". */
|
|
141
132
|
export function formatClock(epochMs: number): string {
|
|
142
133
|
const date = new Date(epochMs)
|
|
143
134
|
const hours = String(date.getHours()).padStart(2, '0')
|
|
@@ -290,6 +290,92 @@
|
|
|
290
290
|
border-color: var(--dsw-alias-label-error);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
.selectWrap {
|
|
294
|
+
position: relative;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.selectButton {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 100%;
|
|
300
|
+
text-align: left;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: space-between;
|
|
304
|
+
gap: 8px;
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.selectLabel {
|
|
309
|
+
min-width: 0;
|
|
310
|
+
overflow: hidden;
|
|
311
|
+
text-overflow: ellipsis;
|
|
312
|
+
white-space: nowrap;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.selectChevron {
|
|
316
|
+
color: var(--dsw-alias-label-tertiary);
|
|
317
|
+
flex: none;
|
|
318
|
+
transition: transform 0.16s;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.selectChevronOpen {
|
|
322
|
+
transform: rotate(180deg);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.selectPopup {
|
|
326
|
+
position: absolute;
|
|
327
|
+
top: calc(100% + 4px);
|
|
328
|
+
left: 0;
|
|
329
|
+
right: 0;
|
|
330
|
+
z-index: 40;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
max-height: 240px;
|
|
333
|
+
overflow-y: auto;
|
|
334
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
335
|
+
border-radius: 8px;
|
|
336
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
337
|
+
box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-2);
|
|
338
|
+
padding: 4px;
|
|
339
|
+
opacity: 0;
|
|
340
|
+
transform: translateY(-4px);
|
|
341
|
+
transition: opacity 0.1s ease, transform 0.1s ease;
|
|
342
|
+
display: flex;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.selectPopupOpen {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
transform: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.selectPopupClose {
|
|
351
|
+
opacity: 0;
|
|
352
|
+
transform: translateY(-4px);
|
|
353
|
+
pointer-events: none;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.selectOption {
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
padding: 6px 10px;
|
|
359
|
+
font-size: 13px;
|
|
360
|
+
line-height: 1.5;
|
|
361
|
+
color: var(--dsw-alias-label-primary);
|
|
362
|
+
cursor: pointer;
|
|
363
|
+
white-space: nowrap;
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.selectOption:hover,
|
|
369
|
+
.selectOptionActive {
|
|
370
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.selectOptionSelected {
|
|
374
|
+
color: var(--dsw-alias-brand-primary);
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 10%, transparent);
|
|
377
|
+
}
|
|
378
|
+
|
|
293
379
|
.invalid {
|
|
294
380
|
color: var(--dsw-alias-label-error);
|
|
295
381
|
margin: 0;
|
|
@@ -310,7 +396,10 @@
|
|
|
310
396
|
.chevron,
|
|
311
397
|
.chevronOpen,
|
|
312
398
|
.discard,
|
|
313
|
-
.save
|
|
399
|
+
.save,
|
|
400
|
+
.selectChevron,
|
|
401
|
+
.selectChevronOpen,
|
|
402
|
+
.selectPopup {
|
|
314
403
|
transition: none;
|
|
315
404
|
}
|
|
316
405
|
}
|
package/src/gate.ts
CHANGED
|
@@ -89,6 +89,8 @@ export function makeGateListener(
|
|
|
89
89
|
if (!active) return false
|
|
90
90
|
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
|
|
91
91
|
if (!require) return next()
|
|
92
|
+
// The paired-device cookie is a full host-API credential: it passes this
|
|
93
|
+
// gate for the whole ApiProxy surface, not just the /m/api allowlist.
|
|
92
94
|
return isPairedDeviceRequest(service, request) ? next() : false
|
|
93
95
|
}
|
|
94
96
|
}
|
package/src/mobile/api.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { WorkspaceView } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
|
|
9
|
+
import type { AgentPresetEntry } from '@deepseek-ai/dsh-host-apiproxy/api/agent-presets'
|
|
9
10
|
import type { SessionSummary, SessionModels, SessionProjectionsBlock } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
10
11
|
import { callUnary } from './rpc.ts'
|
|
11
12
|
|
|
@@ -24,6 +25,13 @@ export interface CreatedSession {
|
|
|
24
25
|
agentPreset?: string
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Agent presets available when composing a new session. */
|
|
29
|
+
export interface AgentPresetRoster {
|
|
30
|
+
presets: readonly AgentPresetEntry[]
|
|
31
|
+
authorable: boolean
|
|
32
|
+
hasDocument: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
/** One history page (already bounded to whole messages by the host). */
|
|
28
36
|
export interface HistoryPage {
|
|
29
37
|
events: import('@deepseek-ai/dsh-host-apiproxy/api/sessions').HistoryEntry[]
|
|
@@ -57,12 +65,17 @@ export async function listSessions(cursor?: string): Promise<SessionPage> {
|
|
|
57
65
|
return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** Read the available agent compositions for a new session. */
|
|
69
|
+
export async function listAgentPresets(): Promise<AgentPresetRoster> {
|
|
70
|
+
return await callUnary<AgentPresetRoster>('agentPreset.list', {})
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
/**
|
|
61
74
|
* Create a blank session (entity birth precedes the first message). Name a
|
|
62
75
|
* workspace to attach it there, or a cwd; omitting both uses the host cwd.
|
|
63
76
|
*/
|
|
64
77
|
export async function createSession(
|
|
65
|
-
options: { workspaceId?: string; cwd?: string } = {},
|
|
78
|
+
options: { workspaceId?: string; cwd?: string; agentPreset?: string } = {},
|
|
66
79
|
): Promise<CreatedSession> {
|
|
67
80
|
return await callUnary<CreatedSession>('session.create', options)
|
|
68
81
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** foldEvents: message-list folding from a session event stream. */
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { foldEvents, type WireEvent } from './messages.ts'
|
|
3
|
+
import { EventFolder, foldEvents, type WireEvent } from './messages.ts'
|
|
4
4
|
|
|
5
5
|
/** Assemble one event with an auto-incrementing seq / time. */
|
|
6
6
|
function makeEvent(
|
|
@@ -326,3 +326,54 @@ describe('foldEvents', () => {
|
|
|
326
326
|
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
|
|
327
327
|
})
|
|
328
328
|
})
|
|
329
|
+
|
|
330
|
+
describe('EventFolder incremental folding', () => {
|
|
331
|
+
it('matches a one-shot fold when events arrive one at a time', () => {
|
|
332
|
+
const stream: WireEvent[] = [
|
|
333
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
334
|
+
textChunk(0, 0, '你', 1),
|
|
335
|
+
textChunk(0, 0, '好', 2),
|
|
336
|
+
makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, '你好'), 3),
|
|
337
|
+
makeEvent('message/update', { id: 'u-1', text: '更新' }, 4),
|
|
338
|
+
]
|
|
339
|
+
const oneShot = foldEvents(stream)
|
|
340
|
+
const folder = new EventFolder()
|
|
341
|
+
let incremental: ReturnType<typeof foldEvents> = []
|
|
342
|
+
for (const event of stream) incremental = folder.fold([event])
|
|
343
|
+
expect(incremental).toEqual(oneShot)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('replays and re-folds are no-ops and reuse the previous snapshot identity', () => {
|
|
347
|
+
const folder = new EventFolder(foldEvents([
|
|
348
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
349
|
+
textChunk(0, 0, '你', 1),
|
|
350
|
+
]))
|
|
351
|
+
const first = folder.fold([textChunk(0, 0, '好', 2)])
|
|
352
|
+
expect(first[first.length - 1]).toMatchObject({ text: '你好', pending: true })
|
|
353
|
+
// Same event again (wire replay or a double-invoked state updater): no change, same identity.
|
|
354
|
+
const replay = folder.fold([textChunk(0, 0, '好', 2)])
|
|
355
|
+
expect(replay).toBe(first)
|
|
356
|
+
// A no-op batch over an already folded stream also keeps the identity.
|
|
357
|
+
expect(folder.fold([])).toBe(first)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('prepends older pages and keeps folding live events on top', () => {
|
|
361
|
+
const folder = new EventFolder(foldEvents([
|
|
362
|
+
makeEvent('user/message', userMessageData('u-2', '第二页'), 10),
|
|
363
|
+
]))
|
|
364
|
+
folder.prepend(foldEvents([makeEvent('user/message', userMessageData('u-1', '第一页'), 5)]))
|
|
365
|
+
const withLive = folder.fold([textChunk(0, 0, '新', 11)])
|
|
366
|
+
expect(withLive.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
367
|
+
// The live fold must not lose the prepended rows on later events.
|
|
368
|
+
const later = folder.fold([textChunk(0, 0, '续', 12)])
|
|
369
|
+
expect(later.map(message => message.id)).toEqual(['u-1', 'u-2', 'assistant,0.0#11'])
|
|
370
|
+
expect(later[2]).toMatchObject({ text: '新续', pending: true })
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('seed replaces the whole stream', () => {
|
|
374
|
+
const folder = new EventFolder(foldEvents([makeEvent('user/message', userMessageData('u-1', '旧'), 0)]))
|
|
375
|
+
folder.seed(foldEvents([makeEvent('user/message', userMessageData('u-2', '新'), 5)]))
|
|
376
|
+
expect(folder.snapshot().map(message => message.id)).toEqual(['u-2'])
|
|
377
|
+
expect(folder.fold([textChunk(0, 0, '追加', 6)]).map(message => message.id)).toEqual(['u-2', 'assistant,0.0#6'])
|
|
378
|
+
})
|
|
379
|
+
})
|
package/src/mobile/messages.ts
CHANGED
|
@@ -191,16 +191,6 @@ function chunkTarget(data: unknown): { text: string; kind: 'text' | 'reasoning';
|
|
|
191
191
|
return result
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/** Max message seq across an existing list, or -1 for an empty list. */
|
|
195
|
-
function watermarkOf(existing: readonly RenderMessage[] | undefined): number {
|
|
196
|
-
if (existing === undefined) return -1
|
|
197
|
-
let maxSeq = -1
|
|
198
|
-
for (const message of existing) {
|
|
199
|
-
if (message.seq > maxSeq) maxSeq = message.seq
|
|
200
|
-
}
|
|
201
|
-
return maxSeq
|
|
202
|
-
}
|
|
203
|
-
|
|
204
194
|
/** Mutable fold state; message objects are immutable and swapped on change. */
|
|
205
195
|
interface FoldState {
|
|
206
196
|
messages: RenderMessage[]
|
|
@@ -215,6 +205,8 @@ interface FoldState {
|
|
|
215
205
|
toolNames: Map<string, Set<string>>
|
|
216
206
|
/** Context window for the current model (from request/context). */
|
|
217
207
|
contextWindow?: number
|
|
208
|
+
/** Highest seq folded so far; the replay/watermark gate. */
|
|
209
|
+
maxSeq: number
|
|
218
210
|
}
|
|
219
211
|
|
|
220
212
|
function createState(existing: readonly RenderMessage[] | undefined): FoldState {
|
|
@@ -226,8 +218,10 @@ function createState(existing: readonly RenderMessage[] | undefined): FoldState
|
|
|
226
218
|
turnStepMessage: new Map(),
|
|
227
219
|
messageTurn: new Map(),
|
|
228
220
|
toolNames: new Map(),
|
|
221
|
+
maxSeq: -1,
|
|
229
222
|
}
|
|
230
223
|
for (const message of messages) {
|
|
224
|
+
if (message.seq > state.maxSeq) state.maxSeq = message.seq
|
|
231
225
|
state.byId.set(message.id, message)
|
|
232
226
|
if (message.kind !== 'assistant') continue
|
|
233
227
|
// Rebuild the (turn, step) and turn index maps lost when `existing` was
|
|
@@ -287,6 +281,7 @@ function retargetTurnStep(state: FoldState, key: string | undefined, oldMessage:
|
|
|
287
281
|
|
|
288
282
|
/** Fold one event into the working state. Assumes the event passes the watermark. */
|
|
289
283
|
function applyEvent(state: FoldState, event: WireEvent): void {
|
|
284
|
+
if (event.seq > state.maxSeq) state.maxSeq = event.seq
|
|
290
285
|
switch (event.type) {
|
|
291
286
|
case 'user/message':
|
|
292
287
|
applyUserMessage(state, event)
|
|
@@ -608,17 +603,65 @@ function applyTurnEnd(state: FoldState, event: WireEvent): void {
|
|
|
608
603
|
* @returns messages sorted by seq.
|
|
609
604
|
*/
|
|
610
605
|
export function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[] {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
606
|
+
return new EventFolder(existing).fold(events)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Incremental folder for one message stream. Live chat folds one event at a
|
|
611
|
+
* time; rebuilding the five index maps by scanning every message per event
|
|
612
|
+
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
613
|
+
* indexes alive across folds, applies each event in O(1) map operations, and
|
|
614
|
+
* returns the previous snapshot identity unchanged when nothing applied, so
|
|
615
|
+
* React skips the re-render entirely. Replayed events are no-ops: the maxSeq
|
|
616
|
+
* watermark advanced by the first application skips them, which also makes a
|
|
617
|
+
* double-invoked React state updater harmless.
|
|
618
|
+
*/
|
|
619
|
+
export class EventFolder {
|
|
620
|
+
private state: FoldState
|
|
621
|
+
private snapshotList: RenderMessage[] | undefined
|
|
622
|
+
|
|
623
|
+
/** @param initial - seed rows (history tail load); omit for an empty stream. */
|
|
624
|
+
constructor(initial?: readonly RenderMessage[]) {
|
|
625
|
+
this.state = createState(initial)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Fold one batch incrementally; returns the current snapshot list. */
|
|
629
|
+
fold(events: readonly WireEvent[]): RenderMessage[] {
|
|
630
|
+
const sorted = [...events].sort((a, b) => a.seq - b.seq)
|
|
631
|
+
let applied = false
|
|
632
|
+
for (const event of sorted) {
|
|
633
|
+
if (event.seq <= this.state.maxSeq) continue
|
|
634
|
+
applyEvent(this.state, event)
|
|
635
|
+
applied = true
|
|
636
|
+
}
|
|
637
|
+
if (!applied && this.snapshotList !== undefined) return this.snapshotList
|
|
638
|
+
this.snapshotList = snapshotOf(this.state)
|
|
639
|
+
return this.snapshotList
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Replace the whole stream (history reload / session switch). */
|
|
643
|
+
seed(messages: readonly RenderMessage[]): void {
|
|
644
|
+
this.state = createState(messages)
|
|
645
|
+
this.snapshotList = undefined
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Prepend an older history page (exact seam; no overlapping seqs). */
|
|
649
|
+
prepend(older: readonly RenderMessage[]): void {
|
|
650
|
+
this.state = createState([...older, ...this.state.messages])
|
|
651
|
+
this.snapshotList = undefined
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Current snapshot list; a fresh copy whenever the folder changed. */
|
|
655
|
+
snapshot(): RenderMessage[] {
|
|
656
|
+
if (this.snapshotList !== undefined) return this.snapshotList
|
|
657
|
+
this.snapshotList = snapshotOf(this.state)
|
|
658
|
+
return this.snapshotList
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Copy the folder's rows and keep them seq-ordered (skips re-sorting the common ordered case). */
|
|
663
|
+
function snapshotOf(state: FoldState): RenderMessage[] {
|
|
664
|
+
const out = [...state.messages]
|
|
622
665
|
let ordered = true
|
|
623
666
|
for (let index = 1; index < out.length; index += 1) {
|
|
624
667
|
const prev = out[index - 1]!
|
|
@@ -628,5 +671,5 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
|
|
|
628
671
|
break
|
|
629
672
|
}
|
|
630
673
|
}
|
|
631
|
-
return ordered ? out :
|
|
674
|
+
return ordered ? out : out.sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
|
|
632
675
|
}
|
|
@@ -252,6 +252,43 @@ body {
|
|
|
252
252
|
padding: 8px 16px;
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
.mobile-create {
|
|
256
|
+
display: grid;
|
|
257
|
+
gap: 8px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.mobile-preset {
|
|
261
|
+
display: grid;
|
|
262
|
+
gap: 4px;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.mobile-presetLabel {
|
|
266
|
+
color: var(--m-text-secondary);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
font-weight: 600;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.mobile-presetSelect {
|
|
272
|
+
width: 100%;
|
|
273
|
+
height: 40px;
|
|
274
|
+
padding: 0 36px 0 12px;
|
|
275
|
+
border: 1px solid var(--m-border);
|
|
276
|
+
border-radius: 10px;
|
|
277
|
+
background: var(--m-bg-input);
|
|
278
|
+
color: var(--m-text);
|
|
279
|
+
font: inherit;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.mobile-presetSelect:disabled {
|
|
283
|
+
opacity: 0.6;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.mobile-presetDescription {
|
|
287
|
+
margin: 0;
|
|
288
|
+
color: var(--m-text-tertiary);
|
|
289
|
+
font-size: 12px;
|
|
290
|
+
}
|
|
291
|
+
|
|
255
292
|
/* ── buttons ─────────────────────────────────────────────────────────── */
|
|
256
293
|
|
|
257
294
|
.mobile-button {
|
|
@@ -896,6 +933,7 @@ body {
|
|
|
896
933
|
.mobile-row:focus-visible,
|
|
897
934
|
.mobile-button:focus-visible,
|
|
898
935
|
.mobile-new:focus-visible,
|
|
936
|
+
.mobile-presetSelect:focus-visible,
|
|
899
937
|
.chat-send:focus-visible,
|
|
900
938
|
.chat-chip:focus-visible,
|
|
901
939
|
.chat-msg-toggle:focus-visible,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** mobile-theme: light default, persisted dark toggle, document wiring. */
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import {
|
|
5
|
-
getMobileTheme,
|
|
5
|
+
getMobileTheme, setMobileTheme, subscribeMobileTheme, toggleMobileTheme,
|
|
6
6
|
} from './mobile-theme.ts'
|
|
7
7
|
|
|
8
8
|
const STORAGE_KEY = 'dsh.remote.theme'
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
* permission pickers, both as bottom sheets.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
14
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
15
15
|
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
16
16
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
17
17
|
import { loadHistory, prompt, type SessionView } from './App.tsx'
|
|
18
18
|
import { errorText, formatTime, staleHostHint } from './App.tsx'
|
|
19
19
|
import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
|
|
20
|
-
import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
20
|
+
import { EventFolder, foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
|
|
21
21
|
import { renderMarkdown } from '../markdown.ts'
|
|
22
22
|
import { MuxClient } from '../mux.ts'
|
|
23
23
|
import { ThemeToggle } from '../theme-toggle.tsx'
|
|
@@ -144,6 +144,8 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
144
144
|
const tailLoadingRef = useRef(true)
|
|
145
145
|
/** Live session events buffered while the initial tail page loads. */
|
|
146
146
|
const liveBufferRef = useRef<WireEvent[]>([])
|
|
147
|
+
/** Incremental folder for this session's stream (indexes stay hot across events). */
|
|
148
|
+
const folderRef = useRef<EventFolder | undefined>(undefined)
|
|
147
149
|
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
148
150
|
const liveBufferOverflowRef = useRef(false)
|
|
149
151
|
|
|
@@ -188,6 +190,7 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
188
190
|
tailLoadingRef.current = true
|
|
189
191
|
liveBufferRef.current = []
|
|
190
192
|
liveBufferOverflowRef.current = false
|
|
193
|
+
folderRef.current = undefined
|
|
191
194
|
setLoading(true)
|
|
192
195
|
setError(undefined)
|
|
193
196
|
setMessages([])
|
|
@@ -199,7 +202,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
199
202
|
const buffered = liveBufferRef.current
|
|
200
203
|
liveBufferRef.current = []
|
|
201
204
|
tailLoadingRef.current = false
|
|
202
|
-
|
|
205
|
+
const folder = new EventFolder(foldEvents(page.events.map(eventOf)))
|
|
206
|
+
folderRef.current = folder
|
|
207
|
+
setMessages(folder.fold(buffered))
|
|
203
208
|
setHasOlder(page.hasMore)
|
|
204
209
|
setLoading(false)
|
|
205
210
|
// The history-tail projection baseline seeds the permission picker.
|
|
@@ -215,7 +220,9 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
215
220
|
void loadHistory(session.sessionId, undefined, controller.signal).then(
|
|
216
221
|
(fresh) => {
|
|
217
222
|
if (cancelled) return
|
|
218
|
-
|
|
223
|
+
const folder = folderRef.current
|
|
224
|
+
const freshEvents = fresh.events.map(eventOf)
|
|
225
|
+
setMessages(previous => folder === undefined ? foldEvents(freshEvents, previous) : folder.fold(freshEvents))
|
|
219
226
|
liveBufferOverflowRef.current = false
|
|
220
227
|
},
|
|
221
228
|
(reason: unknown) => {
|
|
@@ -231,7 +238,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
231
238
|
const buffered = liveBufferRef.current
|
|
232
239
|
liveBufferRef.current = []
|
|
233
240
|
tailLoadingRef.current = false
|
|
234
|
-
if (buffered.length > 0)
|
|
241
|
+
if (buffered.length > 0) {
|
|
242
|
+
const folder = folderRef.current
|
|
243
|
+
setMessages(folder === undefined ? foldEvents(buffered) : folder.fold(buffered))
|
|
244
|
+
}
|
|
235
245
|
setError(errorText(reason))
|
|
236
246
|
setLoading(false)
|
|
237
247
|
},
|
|
@@ -274,7 +284,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
274
284
|
liveBufferRef.current.push(event)
|
|
275
285
|
return
|
|
276
286
|
}
|
|
277
|
-
setMessages(previous =>
|
|
287
|
+
setMessages(previous => {
|
|
288
|
+
const folder = folderRef.current
|
|
289
|
+
return folder === undefined ? foldEvents([event], previous) : folder.fold([event])
|
|
290
|
+
})
|
|
278
291
|
return
|
|
279
292
|
}
|
|
280
293
|
// Live projection pushes keep the permission picker current.
|
|
@@ -330,7 +343,13 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
330
343
|
pendingRef.current = false
|
|
331
344
|
setLoading(false)
|
|
332
345
|
const older = foldEvents(page.events.map(eventOf))
|
|
333
|
-
|
|
346
|
+
const folder = folderRef.current
|
|
347
|
+
if (folder === undefined) {
|
|
348
|
+
setMessages(previous => [...older, ...previous])
|
|
349
|
+
} else {
|
|
350
|
+
folder.prepend(older)
|
|
351
|
+
setMessages(folder.snapshot())
|
|
352
|
+
}
|
|
334
353
|
setHasOlder(page.hasMore)
|
|
335
354
|
},
|
|
336
355
|
(reason: unknown) => {
|
|
@@ -481,8 +500,12 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
481
500
|
|
|
482
501
|
/* ── message rows ─────────────────────────────────────────────────────── */
|
|
483
502
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
503
|
+
/**
|
|
504
|
+
* One rendered message row (user bubble or assistant bubble with folds).
|
|
505
|
+
* Memoized: live streaming updates exactly one message object per frame, so
|
|
506
|
+
* unchanged rows skip re-rendering their markdown/sub-components.
|
|
507
|
+
*/
|
|
508
|
+
const MessageRow = memo(function MessageRow({ message, showToolCalls, showSystemMessages }: {
|
|
486
509
|
message: RenderMessage
|
|
487
510
|
showToolCalls: boolean
|
|
488
511
|
showSystemMessages: boolean
|
|
@@ -510,7 +533,7 @@ function MessageRow({ message, showToolCalls, showSystemMessages }: {
|
|
|
510
533
|
<span className="chat-msg-time">{formatTime(message.time)}</span>
|
|
511
534
|
</div>
|
|
512
535
|
)
|
|
513
|
-
}
|
|
536
|
+
})
|
|
514
537
|
|
|
515
538
|
/** Collapsed-by-default reasoning disclosure (web-UI Think-row parity). */
|
|
516
539
|
function ReasoningDisclosure({ text, pending }: { text: string; pending: boolean }) {
|