@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.
Files changed (77) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -8
  3. package/README.zh.md +5 -5
  4. package/lib/client.js +259 -43
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +26 -7
  7. package/lib/mobile.js +209 -39
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +22 -0
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +142 -3
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +11 -0
  14. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  15. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  16. package/lib/types/client/UpdateEntry.js +8 -5
  17. package/lib/types/client/UpdatePanel.d.ts +3 -1
  18. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  19. package/lib/types/client/UpdatePanel.js +3 -3
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +8 -4
  23. package/lib/types/client/pair-api.d.ts +1 -6
  24. package/lib/types/client/pair-api.d.ts.map +1 -1
  25. package/lib/types/client/pair-api.js +1 -10
  26. package/lib/types/gate.d.ts.map +1 -1
  27. package/lib/types/gate.js +2 -0
  28. package/lib/types/mobile/api.d.ts +10 -0
  29. package/lib/types/mobile/api.d.ts.map +1 -1
  30. package/lib/types/mobile/api.js +4 -0
  31. package/lib/types/mobile/messages.d.ts +24 -0
  32. package/lib/types/mobile/messages.d.ts.map +1 -1
  33. package/lib/types/mobile/messages.js +60 -23
  34. package/lib/types/mobile/messages.test.js +49 -1
  35. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  36. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  37. package/lib/types/mobile/mobile-styles.js +38 -0
  38. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  39. package/lib/types/mobile/views/ChatView.js +34 -11
  40. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  41. package/lib/types/mobile/views/SessionListView.js +30 -4
  42. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  43. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  44. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  45. package/lib/types/mobile-api.d.ts.map +1 -1
  46. package/lib/types/mobile-api.js +10 -1
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +22 -2
  49. package/lib/types/tunnel.d.ts +1 -0
  50. package/lib/types/tunnel.d.ts.map +1 -1
  51. package/lib/types/tunnel.js +7 -2
  52. package/lib/types/update.d.ts +1 -1
  53. package/lib/types/update.d.ts.map +1 -1
  54. package/lib/types/update.js +1 -1
  55. package/package.json +1 -2
  56. package/src/client/PluginSettingsCard.tsx +219 -18
  57. package/src/client/RemoteEntry.tsx +10 -0
  58. package/src/client/RemoteSettingsCard.tsx +2 -2
  59. package/src/client/UpdateEntry.tsx +9 -5
  60. package/src/client/UpdatePanel.tsx +12 -1
  61. package/src/client/locales.ts +8 -4
  62. package/src/client/pair-api.ts +1 -10
  63. package/src/client/settings-card.module.css +90 -1
  64. package/src/gate.ts +2 -0
  65. package/src/mobile/api.ts +14 -1
  66. package/src/mobile/messages.test.ts +52 -1
  67. package/src/mobile/messages.ts +65 -22
  68. package/src/mobile/mobile-styles.ts +38 -0
  69. package/src/mobile/mobile-theme.test.ts +1 -1
  70. package/src/mobile/views/ChatView.tsx +33 -10
  71. package/src/mobile/views/SessionListView.test.tsx +69 -1
  72. package/src/mobile/views/SessionListView.tsx +58 -5
  73. package/src/mobile/views/WorkspaceView.tsx +4 -2
  74. package/src/mobile-api.ts +9 -1
  75. package/src/routes.ts +22 -2
  76. package/src/tunnel.ts +7 -2
  77. 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, auto-run the update when a newer release exists, report the
5
- * outcome (restart hint on success, translated failure on error).
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
- // Auto-update: an npm install with a newer release proceeds without a
77
- // second confirmation — clicking the update trigger is the intent.
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
  }
@@ -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.updatingTitle': '正在自动更新…',
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}; updating automatically…',
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.updatingTitle': 'Updating automatically…',
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.',
@@ -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
+ })
@@ -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
- const sorted = [...events].sort((a, b) => a.seq - b.seq)
612
- const watermark = watermarkOf(existing)
613
- const state = createState(existing)
614
- for (const event of sorted) {
615
- if (event.seq <= watermark) continue
616
- applyEvent(state, event)
617
- }
618
- // Incremental tails (the live-stream hot path) are appended in seq order
619
- // or updated in place, so the list is usually ordered already — verify in
620
- // O(n) and skip the O(n log n) re-sort when it is.
621
- const out = state.messages
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 : [...out].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
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, initMobileTheme, setMobileTheme, subscribeMobileTheme, toggleMobileTheme,
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
- setMessages(foldEvents(buffered, foldEvents(page.events.map(eventOf))))
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
- setMessages(previous => foldEvents(fresh.events.map(eventOf), previous))
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) setMessages(foldEvents(buffered))
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 => foldEvents([event], 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
- setMessages(previous => [...older, ...previous])
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
- /** One rendered message row (user bubble or assistant bubble with folds). */
485
- function MessageRow({ message, showToolCalls, showSystemMessages }: {
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 }) {