@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.1

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
package/src/index.ts CHANGED
@@ -15,8 +15,9 @@ import type { Context } from '@deepseek-ai/cordis'
15
15
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
16
  import z from 'schemastery'
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
- import { PairingService } from './pairing.ts'
19
- import { makeGateListener } from './gate.ts'
18
+ import { PairingService, type PairingConfig } from './pairing.ts'
19
+ import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
20
+ import { RemoteWebUiPairing } from './pairing-access.ts'
20
21
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
21
22
  import { makeMobileRoutes } from './mobile-routes.ts'
22
23
  import { makeMobileApiRoutes } from './mobile-api.ts'
@@ -89,6 +90,14 @@ export interface Config {
89
90
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
90
91
  */
91
92
  publicBaseUrl?: string
93
+ /**
94
+ * Absolute path to a JSON file where paired device sessions are persisted.
95
+ * When set, a paired phone keeps its session across `dsh web` restarts
96
+ * (the phone-side cookie already lives 365 days), so re-scanning the QR
97
+ * after each restart is unnecessary. Unset keeps sessions memory-only
98
+ * (previous behavior).
99
+ */
100
+ devicesFile?: string
92
101
  /**
93
102
  * When true, the plugin runs its own Cloudflare quick tunnel (the
94
103
  * cloudflared binary ships with the package — no user-side install) and
@@ -115,6 +124,7 @@ export const Config: z<Config> = z.object({
115
124
  cookieName: z.string().min(1).default('dsh_pair'),
116
125
  requirePairingForLan: z.boolean().default(true),
117
126
  publicBaseUrl: z.string(),
127
+ devicesFile: z.string(),
118
128
  autoTunnel: z.boolean().default(false),
119
129
  mobileEnterToSend: z.boolean().default(true),
120
130
  enabled: z.boolean().default(true),
@@ -128,7 +138,29 @@ const SWEEP_INTERVAL_MS = 10_000
128
138
  * which legitimately resolves to `undefined` when unset (the schema keeps it
129
139
  * optional, so `Required` alone would over-narrow it to `string`).
130
140
  */
131
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl'>> & { publicBaseUrl: string | undefined }
141
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
142
+ publicBaseUrl: string | undefined
143
+ devicesFile: string | undefined
144
+ }
145
+
146
+ /**
147
+ * The single mapping from resolved plugin config to the pairing service
148
+ * config. Both the constructed service and every live settings sync reuse
149
+ * it, so no field can be silently dropped when the web settings surface
150
+ * pushes a new value into the running service.
151
+ */
152
+ export function pairingConfigOf(resolved: Pick<
153
+ ResolvedConfig,
154
+ 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
155
+ >): PairingConfig {
156
+ return {
157
+ tokenTtlMs: resolved.tokenTtlMs,
158
+ offlineAfterMs: resolved.offlineAfterMs,
159
+ maxDevices: resolved.maxDevices,
160
+ cookieName: resolved.cookieName,
161
+ devicesFile: resolved.devicesFile,
162
+ }
163
+ }
132
164
 
133
165
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
134
166
  const DEFAULTS: ResolvedConfig = {
@@ -138,6 +170,7 @@ const DEFAULTS: ResolvedConfig = {
138
170
  cookieName: 'dsh_pair',
139
171
  requirePairingForLan: true,
140
172
  publicBaseUrl: undefined,
173
+ devicesFile: undefined,
141
174
  autoTunnel: false,
142
175
  mobileEnterToSend: true,
143
176
  enabled: true,
@@ -158,6 +191,7 @@ function applyImpl(ctx: Context, config?: Config): void {
158
191
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
159
192
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
160
193
  publicBaseUrl: config?.publicBaseUrl,
194
+ devicesFile: config?.devicesFile,
161
195
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
162
196
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
163
197
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -175,17 +209,13 @@ function applyImpl(ctx: Context, config?: Config): void {
175
209
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
176
210
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
177
211
  publicBaseUrl: value.publicBaseUrl,
212
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
178
213
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
179
214
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
180
215
  enabled: value.enabled ?? DEFAULTS.enabled,
181
216
  }
182
217
  }
183
- const service = new PairingService({
184
- tokenTtlMs: resolved.tokenTtlMs,
185
- offlineAfterMs: resolved.offlineAfterMs,
186
- maxDevices: resolved.maxDevices,
187
- cookieName: resolved.cookieName,
188
- })
218
+ const service = new PairingService(pairingConfigOf(resolved))
189
219
 
190
220
  // ── auto tunnel ─────────────────────────────────────────────────────────
191
221
  // The minted public URL becomes the QR base (and the pairing fence's
@@ -312,14 +342,15 @@ function applyImpl(ctx: Context, config?: Config): void {
312
342
  ]
313
343
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
314
344
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
345
+ // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
346
+ // plugin is not installed; stop() / enabled=false still refuse cookies.
347
+ new RemoteWebUiPairing(ctx, (request) => {
348
+ if (!resolve().enabled) return false
349
+ return isPairedDeviceRequest(service, request)
350
+ })
315
351
  const sync = (): void => {
316
352
  const value = resolve()
317
- service.config = {
318
- tokenTtlMs: value.tokenTtlMs,
319
- offlineAfterMs: value.offlineAfterMs,
320
- maxDevices: value.maxDevices,
321
- cookieName: value.cookieName,
322
- }
353
+ service.config = pairingConfigOf(value)
323
354
  // The auto tunnel owns the public base while enabled: the minted URL
324
355
  // lands in the service through the tunnel's phase listener. The manual
325
356
  // publicBaseUrl applies only when the auto tunnel is off.
@@ -0,0 +1,28 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { PairRequiredView } from './PairRequiredView.tsx'
5
+
6
+ afterEach(() => {
7
+ cleanup()
8
+ vi.unstubAllGlobals()
9
+ })
10
+
11
+ describe('PairRequiredView', () => {
12
+ it('accepts a pasted link in the installed app context', async () => {
13
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
14
+ vi.stubGlobal('fetch', fetch)
15
+ const onPaired = vi.fn()
16
+ render(<PairRequiredView onPaired={onPaired} />)
17
+
18
+ fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } })
19
+ fireEvent.click(screen.getByRole('button', { name: '配对' }))
20
+
21
+ await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'))
22
+ })
23
+
24
+ it('shows an initial QR failure without starting the mobile data channel', () => {
25
+ render(<PairRequiredView initialError="配对链接已被使用。" onPaired={vi.fn()} />)
26
+ expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。')
27
+ })
28
+ })
@@ -0,0 +1,58 @@
1
+ /** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
2
+ import { type FormEvent, useState } from 'react'
3
+ import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.ts'
4
+
5
+ export interface PairRequiredViewProps {
6
+ initialError?: string
7
+ onPaired(path: string): void
8
+ }
9
+
10
+ /** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
11
+ export function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps) {
12
+ const [value, setValue] = useState('')
13
+ const [error, setError] = useState<string | undefined>(initialError)
14
+ const [submitting, setSubmitting] = useState(false)
15
+
16
+ const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
17
+ event.preventDefault()
18
+ const input = parseMobilePairInput(value)
19
+ if (input === undefined) {
20
+ setError('请输入有效的配对链接。')
21
+ return
22
+ }
23
+
24
+ setSubmitting(true)
25
+ setError(undefined)
26
+ const result = await acceptMobilePair(input.token)
27
+ setSubmitting(false)
28
+ if (!result.ok) {
29
+ setError(result.message)
30
+ return
31
+ }
32
+
33
+ onPaired(mobilePairPath(input.workspaceId))
34
+ }
35
+
36
+ return (
37
+ <main className="mobile mobile-pair" aria-labelledby="mobile-pair-title">
38
+ <form className="mobile-pairCard" onSubmit={(event) => { void submit(event) }}>
39
+ <h1 id="mobile-pair-title" className="mobile-title">设备配对</h1>
40
+ <p className="mobile-muted">粘贴桌面端复制的配对链接以连接此设备。</p>
41
+ <label className="mobile-pairLabel" htmlFor="mobile-pair-link">配对链接</label>
42
+ <input
43
+ id="mobile-pair-link"
44
+ className="mobile-pairInput"
45
+ value={value}
46
+ onChange={(event) => { setValue(event.target.value) }}
47
+ autoComplete="off"
48
+ autoCapitalize="off"
49
+ spellCheck={false}
50
+ />
51
+ {error === undefined ? null : <p className="mobile-error" role="alert">{error}</p>}
52
+ <button className="mobile-new mobile-pairSubmit" type="submit" disabled={submitting}>
53
+ {submitting ? '正在配对' : '配对'}
54
+ </button>
55
+ </form>
56
+ </main>
57
+ )
58
+ }
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
  }
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client'
11
11
  import { App } from './views/App.tsx'
12
12
  import { mobileCss } from './mobile-styles.ts'
13
13
  import { initMobileTheme } from './mobile-theme.ts'
14
+ import { registerMobilePwa } from '../mobile-pwa.ts'
15
+ import { consumeMobilePairUrl } from './pairing.ts'
14
16
 
15
17
  // Apply the persisted (or default light) theme before first paint, so the
16
18
  // page never flashes the wrong palette.
@@ -24,4 +26,16 @@ document.head.appendChild(style)
24
26
 
25
27
  const root = document.getElementById('root')
26
28
  if (root === null) throw new Error('mobile: #root missing')
27
- createRoot(root).render(<App />)
29
+ void bootMobile(root)
30
+
31
+ async function bootMobile(mount: HTMLElement): Promise<void> {
32
+ const pair = await consumeMobilePairUrl(window.location.href)
33
+ if (pair.kind === 'accepted') {
34
+ window.location.replace(pair.path)
35
+ return
36
+ }
37
+ if (pair.kind === 'failed') window.history.replaceState(null, '', pair.path)
38
+
39
+ void registerMobilePwa()
40
+ createRoot(mount).render(<App initialPairError={pair.kind === 'failed' ? pair.message : undefined} />)
41
+ }
@@ -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,
@@ -1020,4 +1058,52 @@ body {
1020
1058
  transition: none;
1021
1059
  }
1022
1060
  }
1061
+
1062
+ /* ── installed-app pairing ───────────────────────────────────────────── */
1063
+
1064
+ .mobile-pair {
1065
+ min-height: 100dvh;
1066
+ display: grid;
1067
+ place-items: center;
1068
+ padding: 24px;
1069
+ }
1070
+
1071
+ .mobile-pairCard {
1072
+ display: grid;
1073
+ width: min(100%, 420px);
1074
+ gap: 12px;
1075
+ padding: 24px;
1076
+ border: 1px solid var(--m-border);
1077
+ border-radius: 12px;
1078
+ background: var(--m-bg-raised);
1079
+ box-shadow: var(--m-shadow-raise);
1080
+ }
1081
+
1082
+ .mobile-pairLabel {
1083
+ color: var(--m-text-secondary);
1084
+ font-size: 13px;
1085
+ font-weight: 600;
1086
+ }
1087
+
1088
+ .mobile-pairInput {
1089
+ width: 100%;
1090
+ min-height: 42px;
1091
+ box-sizing: border-box;
1092
+ padding: 9px 12px;
1093
+ border: 1px solid var(--m-border);
1094
+ border-radius: 8px;
1095
+ background: var(--m-bg-input);
1096
+ color: var(--m-text);
1097
+ font: inherit;
1098
+ outline: none;
1099
+ }
1100
+
1101
+ .mobile-pairInput:focus-visible {
1102
+ border-color: var(--m-accent);
1103
+ box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
1104
+ }
1105
+
1106
+ .mobile-pairSubmit {
1107
+ width: 100%;
1108
+ }
1023
1109
  `
@@ -0,0 +1,77 @@
1
+ /** Pairing helpers owned by the standalone /m mobile surface. */
2
+ export type PairFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
3
+
4
+ export interface MobilePairInput {
5
+ token: string
6
+ workspaceId?: string
7
+ }
8
+
9
+ export type MobilePairAccept =
10
+ | { ok: true }
11
+ | { ok: false; message: string }
12
+
13
+ export type MobilePairBootstrap =
14
+ | { kind: 'none' }
15
+ | { kind: 'accepted'; path: string }
16
+ | { kind: 'failed'; path: string; message: string }
17
+
18
+ const MOBILE_ROOT = '/m/'
19
+
20
+ /** Parse a pairing token or a copied pairing link without following its origin. */
21
+ export function parseMobilePairInput(value: string): MobilePairInput | undefined {
22
+ const trimmed = value.trim()
23
+ if (trimmed === '') return undefined
24
+
25
+ try {
26
+ const url = new URL(trimmed)
27
+ const token = url.searchParams.get('pair')
28
+ if (token === null || token === '') return undefined
29
+ const workspaceId = url.searchParams.get('workspace')
30
+ return {
31
+ token,
32
+ ...(workspaceId !== null && workspaceId !== '' ? { workspaceId } : {}),
33
+ }
34
+ } catch {
35
+ return { token: trimmed }
36
+ }
37
+ }
38
+
39
+ /** Build the safe, token-free mobile destination after successful pairing. */
40
+ export function mobilePairPath(workspaceId?: string): string {
41
+ return workspaceId === undefined ? MOBILE_ROOT : MOBILE_ROOT + '?workspace=' + encodeURIComponent(workspaceId)
42
+ }
43
+
44
+ /** Accept one pairing token on this exact browser or installed-web-app context. */
45
+ export async function acceptMobilePair(token: string, fetcher: PairFetch = fetch): Promise<MobilePairAccept> {
46
+ try {
47
+ const response = await fetcher('/api/pair/accept', {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify({ token }),
51
+ })
52
+ if (response.ok) return { ok: true }
53
+ if (response.status === 404) return { ok: false, message: '配对链接无效或已过期。' }
54
+ if (response.status === 409) return { ok: false, message: '配对链接已被使用。' }
55
+ return { ok: false, message: '此设备无法使用该配对链接。' }
56
+ } catch {
57
+ return { ok: false, message: '无法连接到配对服务。' }
58
+ }
59
+ }
60
+
61
+ /** Consume a QR pairing token before the mobile application starts making RPC calls. */
62
+ export async function consumeMobilePairUrl(href: string, fetcher: PairFetch = fetch): Promise<MobilePairBootstrap> {
63
+ let url: URL
64
+ try {
65
+ url = new URL(href)
66
+ } catch {
67
+ return { kind: 'none' }
68
+ }
69
+
70
+ const token = url.searchParams.get('pair')
71
+ if (token === null || token === '') return { kind: 'none' }
72
+
73
+ const workspaceId = url.searchParams.get('workspace')
74
+ const path = mobilePairPath(workspaceId === null || workspaceId === '' ? undefined : workspaceId)
75
+ const result = await acceptMobilePair(token, fetcher)
76
+ return result.ok ? { kind: 'accepted', path } : { kind: 'failed', path, message: result.message }
77
+ }