@linxin666/dsh-remote-web-ui 0.1.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 (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Message fold: collapse a session event stream into a renderable, ordered
3
+ * message list for the mobile surface.
4
+ *
5
+ * The mobile page is an independent, self-contained bundle, so this module has
6
+ * no value imports: it re-declares a local `WireEvent` (a loose envelope with a
7
+ * typed `type` and a wide `data`) and folds it into {@link RenderMessage} rows.
8
+ * It is a pure, side-effect-free fold — callers hold the rendered list and feed
9
+ * the next batch of events in to get the next list. Caller-supplied `existing`
10
+ * messages are never mutated: every change builds a fresh message object.
11
+ *
12
+ * The data shapes follow the host session protocol (see the dsh session
13
+ * `types.ts` / `surface.ts` and the llm `types.ts` sources the reference audit
14
+ * read):
15
+ *
16
+ * - `user/message` data = `{ id, role, content: ContentBlock[], source }`
17
+ * - `assistant/message` data = `{ turn, step, message: { id, content }, usage? }`
18
+ * - `assistant/chunk` data = `{ turn, step, chunk: { type: 'text-delta' | 'reasoning-delta', text } }`
19
+ * - `turn/start` data = `{ turn }`
20
+ * - `turn/end` data = `{ turn, reason: { kind: 'error' | ... } }`
21
+ * - `tool/call` data = `{ turn, step, callId, name, arguments }`
22
+ * - `session/end-seed` empty data (skipped)
23
+ *
24
+ * Assistant content blocks (`text` vs `reasoning`) fold into two separate
25
+ * fields — `text` and `reasoning` — so the surface can show reasoning behind
26
+ * a collapsed disclosure instead of dumping it into the message body. Tool
27
+ * calls accumulate ordered details (`tools`) in addition to the plain
28
+ * `toolSummary` name list.
29
+ *
30
+ * The mobile message-level aliases `message/chunk`, `message/update` and
31
+ * `message/delete` are also accepted (assumed shapes documented below).
32
+ *
33
+ * Design notes:
34
+ * - Events are applied in ascending `seq` order.
35
+ * - A `seq` watermark is derived from `existing` (the max already-rendered
36
+ * message seq). Events whose seq is already at or below the watermark are
37
+ * skipped, which makes re-applying the same batch idempotent without
38
+ * double-folding streamed chunk text.
39
+ * - Create events additionally dedupe by message id, so a repeated
40
+ * `user/message` / `assistant/message` replaces in place instead of duplicating.
41
+ * - A pending assistant message (alive while `assistant/chunk`-style deltas
42
+ * keep arriving, `pending: true`) is finalized by the matching
43
+ * `assistant/message` (same id or `(turn, step)`) or closed by `turn/end`.
44
+ */
45
+ export interface RenderMessage {
46
+ /** Stable message identity — the wire id when present, else the event seq. */
47
+ readonly id: string
48
+ readonly kind: 'user' | 'assistant'
49
+ /** The fully folded text (assistant chunks aggregate into their message). */
50
+ readonly text: string
51
+ /**
52
+ * Folded reasoning text, kept separate from `text` so the surface can
53
+ * hide it behind a collapsed Think disclosure (web-UI parity).
54
+ */
55
+ readonly reasoning?: string
56
+ /**
57
+ * Ordered tool calls of this assistant message, in first-seen order,
58
+ * driving the collapsible tool disclosure (name + raw arguments).
59
+ */
60
+ readonly tools?: ToolCallInfo[]
61
+ /** Seq of the latest event that touched this message (used for loadOlder). */
62
+ readonly seq: number
63
+ /** Epoch ms of the latest touch. */
64
+ readonly time: number
65
+ /** True while an assistant message is still receiving chunks (not yet closed). */
66
+ readonly pending?: boolean
67
+ /** Plain-text tool call summary for this assistant message, e.g. "使用 bash / read". */
68
+ readonly toolSummary?: string
69
+ /** Set when the owning turn ended in an error. */
70
+ readonly failed?: boolean
71
+ }
72
+
73
+ /** One tool call attached to an assistant message (callId dedupes repeats). */
74
+ export interface ToolCallInfo {
75
+ /** Tool-call id (synthetic `${name}#${seq}` when the wire omitted it). */
76
+ readonly callId: string
77
+ /** Tool name, e.g. "bash". */
78
+ readonly name: string
79
+ /** Raw arguments JSON, when the event carried it. */
80
+ readonly arguments?: string
81
+ }
82
+
83
+ /**
84
+ * The session event envelope as the mobile fold sees it. `data` is kept wide
85
+ * (unknown) so the fold reads fields defensively; `surfaceOp` / `sourceEventSeqs`
86
+ * are envelope metadata unrelated to message rendering and are ignored here.
87
+ */
88
+ export interface WireEvent {
89
+ readonly type: string
90
+ readonly seq: number
91
+ readonly time: number
92
+ readonly data: unknown
93
+ readonly sourceEventSeqs?: number[]
94
+ readonly surfaceOp?: unknown
95
+ readonly ignorable?: true
96
+ }
97
+
98
+ /** Runtime shape guard for the lossless-JSON `data` of a `WireEvent`. */
99
+ function isRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
101
+ }
102
+
103
+ function pickString(value: unknown): string | undefined {
104
+ return typeof value === 'string' ? value : undefined
105
+ }
106
+
107
+ function pickNumber(value: unknown): number | undefined {
108
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
109
+ }
110
+
111
+ /** Fallback message id for events without a stable wire id. */
112
+ function syntheticId(prefix: string, seq: number): string {
113
+ return `${prefix}#${String(seq)}`
114
+ }
115
+
116
+ /** Concatenate the plain text of every `text` content block. */
117
+ function textFromContent(content: unknown): string {
118
+ return blocksOfType(content, 'text')
119
+ }
120
+
121
+ /** Concatenate the plain text of every `reasoning` content block. */
122
+ function reasoningFromContent(content: unknown): string {
123
+ return blocksOfType(content, 'reasoning')
124
+ }
125
+
126
+ /** Concatenate the plain text of every content block of one type. */
127
+ function blocksOfType(content: unknown, type: string): string {
128
+ if (!Array.isArray(content)) return ''
129
+ let out = ''
130
+ for (const block of content) {
131
+ if (!isRecord(block)) continue
132
+ if (block['type'] !== type) continue
133
+ const text = pickString(block['text'])
134
+ if (text !== undefined) out += text
135
+ }
136
+ return out
137
+ }
138
+
139
+ /**
140
+ * Extract a text-chunk target from `assistant/chunk` or the mobile alias
141
+ * `message/chunk`.
142
+ *
143
+ * - DSH shape: `data.chunk = { type: 'text-delta', text }` keyed by
144
+ * `(turn, step)`; the pending message is created/aggregated on the owning
145
+ * step and later finalized by the matching `assistant/message`.
146
+ * - Mobile shape: `data.text` with an optional `messageId` binding the delta
147
+ * to a specific assistant message.
148
+ *
149
+ * Returns null for non-text chunk variants (usage / finish / block-start).
150
+ */
151
+ function chunkTarget(data: unknown): { text: string; kind: 'text' | 'reasoning'; id?: string; turn?: number; step?: number } | null {
152
+ if (!isRecord(data)) return null
153
+ let text: string | undefined
154
+ let kind: 'text' | 'reasoning' = 'text'
155
+ let idValue: string | undefined
156
+ let turn: number | undefined
157
+ let step: number | undefined
158
+ const chunk = data['chunk']
159
+ if (isRecord(chunk)) {
160
+ if (chunk['type'] !== 'text-delta' && chunk['type'] !== 'reasoning-delta') return null
161
+ text = pickString(chunk['text'])
162
+ kind = chunk['type'] === 'reasoning-delta' ? 'reasoning' : 'text'
163
+ turn = pickNumber(data['turn'])
164
+ step = pickNumber(data['step'])
165
+ } else {
166
+ text = pickString(data['text'])
167
+ kind = pickString(data['kind']) === 'reasoning' ? 'reasoning' : 'text'
168
+ idValue = pickString(data['messageId']) ?? pickString(data['id'])
169
+ turn = pickNumber(data['turn'])
170
+ step = pickNumber(data['step'])
171
+ }
172
+ if (text === undefined) return null
173
+ const result: { text: string; kind: 'text' | 'reasoning'; id?: string; turn?: number; step?: number } = { text, kind }
174
+ if (idValue !== undefined) result.id = idValue
175
+ if (turn !== undefined) result.turn = turn
176
+ if (step !== undefined) result.step = step
177
+ return result
178
+ }
179
+
180
+ /** Max message seq across an existing list, or -1 for an empty list. */
181
+ function watermarkOf(existing: readonly RenderMessage[] | undefined): number {
182
+ if (existing === undefined) return -1
183
+ let maxSeq = -1
184
+ for (const message of existing) {
185
+ if (message.seq > maxSeq) maxSeq = message.seq
186
+ }
187
+ return maxSeq
188
+ }
189
+
190
+ /** Mutable fold state; message objects are immutable and swapped on change. */
191
+ interface FoldState {
192
+ messages: RenderMessage[]
193
+ byId: Map<string, RenderMessage>
194
+ /** Pending assistant message per `${turn}.${step}`, awaiting finalization. */
195
+ pendingByTurnStep: Map<string, RenderMessage>
196
+ /** Latest assistant message per `${turn}.${step}` (pending or finalized). */
197
+ turnStepMessage: Map<string, RenderMessage>
198
+ /** Owning turn per assistant message id (for turn/end targeting). */
199
+ messageTurn: Map<string, number>
200
+ /** Deduped tool names per assistant message id. */
201
+ toolNames: Map<string, Set<string>>
202
+ }
203
+
204
+ function createState(existing: readonly RenderMessage[] | undefined): FoldState {
205
+ const messages = existing === undefined ? [] : [...existing]
206
+ const state: FoldState = {
207
+ messages,
208
+ byId: new Map(),
209
+ pendingByTurnStep: new Map(),
210
+ turnStepMessage: new Map(),
211
+ messageTurn: new Map(),
212
+ toolNames: new Map(),
213
+ }
214
+ for (const message of messages) {
215
+ state.byId.set(message.id, message)
216
+ if (message.kind !== 'assistant') continue
217
+ // Rebuild the (turn, step) and turn index maps lost when `existing` was
218
+ // handed back to us as plain rows.
219
+ const decoded = decodePendingTurnStep(message.id)
220
+ const key = decoded === undefined ? undefined : tsKey(decoded.turn, decoded.step)
221
+ if (message.pending === true && key !== undefined) {
222
+ state.pendingByTurnStep.set(key, message)
223
+ state.turnStepMessage.set(key, message)
224
+ }
225
+ if (decoded !== undefined) {
226
+ state.messageTurn.set(message.id, decoded.turn)
227
+ }
228
+ }
229
+ return state
230
+ }
231
+
232
+ function tsKey(turn: number | undefined, step: number | undefined): string | undefined {
233
+ return turn === undefined || step === undefined ? undefined : `${turn}.${step}`
234
+ }
235
+
236
+ /**
237
+ * Recover the `(turn, step)` a pending assistant message was created under from
238
+ * its synthetic id (`assistant,<turn>.<step>#<seq>`), so an incremental fold
239
+ * over an `existing` list can re-attach index maps that were lost across calls.
240
+ */
241
+ function decodePendingTurnStep(id: string): { turn: number; step: number } | undefined {
242
+ if (!id.startsWith('assistant,')) return undefined
243
+ const rest = id.slice('assistant,'.length)
244
+ const hash = rest.indexOf('#')
245
+ const tsPart = hash === -1 ? rest : rest.slice(0, hash)
246
+ const dot = tsPart.indexOf('.')
247
+ if (dot <= 0 || dot === tsPart.length - 1) return undefined
248
+ const turn = Number(tsPart.slice(0, dot))
249
+ const step = Number(tsPart.slice(dot + 1))
250
+ if (!Number.isInteger(turn) || !Number.isInteger(step)) return undefined
251
+ return { turn, step }
252
+ }
253
+
254
+ /**
255
+ * Swap in a replacement message object at the old message's position and
256
+ * re-index it. Immutable: `next` is a fresh object; the old one is untouched.
257
+ */
258
+ function replaceMessage(state: FoldState, oldMessage: RenderMessage, next: RenderMessage): void {
259
+ const index = state.messages.indexOf(oldMessage)
260
+ if (index !== -1) state.messages[index] = next
261
+ state.byId.delete(oldMessage.id)
262
+ state.byId.set(next.id, next)
263
+ }
264
+
265
+ /** Bundle the maps keyed per `(turn, step)` over to a newly swapped message. */
266
+ function retargetTurnStep(state: FoldState, key: string | undefined, oldMessage: RenderMessage, next: RenderMessage): void {
267
+ if (key === undefined) return
268
+ if (state.pendingByTurnStep.get(key) === oldMessage) state.pendingByTurnStep.set(key, next)
269
+ if (state.turnStepMessage.get(key) === oldMessage) state.turnStepMessage.set(key, next)
270
+ }
271
+
272
+ /** Fold one event into the working state. Assumes the event passes the watermark. */
273
+ function applyEvent(state: FoldState, event: WireEvent): void {
274
+ switch (event.type) {
275
+ case 'user/message':
276
+ applyUserMessage(state, event)
277
+ break
278
+ case 'assistant/message':
279
+ applyAssistantMessage(state, event)
280
+ break
281
+ case 'assistant/chunk':
282
+ case 'message/chunk':
283
+ applyChunk(state, event)
284
+ break
285
+ case 'message/update':
286
+ applyUpdate(state, event)
287
+ break
288
+ case 'message/delete':
289
+ applyDelete(state, event)
290
+ break
291
+ case 'turn/end':
292
+ applyTurnEnd(state, event)
293
+ break
294
+ case 'tool/call':
295
+ applyToolCall(state, event)
296
+ break
297
+ // turn/start, session/end-seed, and every other/unknown type render nothing.
298
+ default:
299
+ break
300
+ }
301
+ }
302
+
303
+ function applyUserMessage(state: FoldState, event: WireEvent): void {
304
+ const data = isRecord(event.data) ? event.data : {}
305
+ const id = pickString(data['id']) ?? syntheticId('user', event.seq)
306
+ const text = textFromContent(data['content'])
307
+ const existing = state.byId.get(id)
308
+ if (existing !== undefined) {
309
+ // Idempotent replace (replayed events update in place, never duplicate).
310
+ replaceMessage(state, existing, { ...existing, text, seq: event.seq, time: event.time })
311
+ return
312
+ }
313
+ const message: RenderMessage = { id, kind: 'user', text, seq: event.seq, time: event.time }
314
+ state.messages.push(message)
315
+ state.byId.set(id, message)
316
+ }
317
+
318
+ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
319
+ const data = isRecord(event.data) ? event.data : {}
320
+ const messageData = isRecord(data['message']) ? data['message'] : data
321
+ const id = pickString(messageData['id']) ?? pickString(data['id']) ?? syntheticId('assistant', event.seq)
322
+ const turn = pickNumber(data['turn'])
323
+ const step = pickNumber(data['step'])
324
+ const finalText = textFromContent(messageData['content'])
325
+ const finalReasoning = reasoningFromContent(messageData['content'])
326
+ const key = tsKey(turn, step)
327
+
328
+ // Finalize the matching assistant message (by id, or by turn/step for the
329
+ // streaming partial that chunks built before the final event arrived).
330
+ let target = state.byId.get(id)
331
+ if (target === undefined && key !== undefined) target = state.pendingByTurnStep.get(key)
332
+
333
+ if (target !== undefined) {
334
+ const next: RenderMessage = {
335
+ ...target,
336
+ id,
337
+ text: finalText,
338
+ // The final content block list is authoritative; an adapter that omits
339
+ // reasoning from the final message keeps the streamed reasoning text.
340
+ ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
341
+ seq: event.seq,
342
+ time: event.time,
343
+ pending: false,
344
+ }
345
+ replaceMessage(state, target, next)
346
+ retargetTurnStep(state, key, target, next)
347
+ if (turn !== undefined) state.messageTurn.set(next.id, turn)
348
+ return
349
+ }
350
+
351
+ const message: RenderMessage = {
352
+ id,
353
+ kind: 'assistant',
354
+ text: finalText,
355
+ ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
356
+ seq: event.seq,
357
+ time: event.time,
358
+ }
359
+ state.messages.push(message)
360
+ state.byId.set(id, message)
361
+ if (key !== undefined) {
362
+ state.pendingByTurnStep.delete(key)
363
+ state.turnStepMessage.set(key, message)
364
+ }
365
+ if (turn !== undefined) state.messageTurn.set(id, turn)
366
+ }
367
+
368
+ function applyChunk(state: FoldState, event: WireEvent): void {
369
+ const target = chunkTarget(event.data)
370
+ if (target === null) return
371
+ const key = tsKey(target.turn, target.step)
372
+ let message: RenderMessage | undefined
373
+ if (target.id !== undefined) {
374
+ message = state.byId.get(target.id)
375
+ } else if (key !== undefined) {
376
+ message = state.pendingByTurnStep.get(key) ?? state.turnStepMessage.get(key)
377
+ }
378
+
379
+ if (message !== undefined && message.kind === 'assistant') {
380
+ const next: RenderMessage = target.kind === 'reasoning'
381
+ ? { ...message, reasoning: (message.reasoning ?? '') + target.text, seq: event.seq, time: event.time }
382
+ : { ...message, text: message.text + target.text, seq: event.seq, time: event.time }
383
+ replaceMessage(state, message, next)
384
+ retargetTurnStep(state, key, message, next)
385
+ return
386
+ }
387
+
388
+ const id = target.id
389
+ ?? (key !== undefined ? syntheticId(`assistant,${key}`, event.seq) : syntheticId('assistant', event.seq))
390
+ const created: RenderMessage = target.kind === 'reasoning'
391
+ ? { id, kind: 'assistant', text: '', reasoning: target.text, seq: event.seq, time: event.time, pending: true }
392
+ : { id, kind: 'assistant', text: target.text, seq: event.seq, time: event.time, pending: true }
393
+ state.messages.push(created)
394
+ state.byId.set(id, created)
395
+ if (key !== undefined) {
396
+ state.pendingByTurnStep.set(key, created)
397
+ state.turnStepMessage.set(key, created)
398
+ }
399
+ if (target.turn !== undefined) state.messageTurn.set(id, target.turn)
400
+ }
401
+
402
+ function findByIdOrSeq(state: FoldState, event: WireEvent): RenderMessage | undefined {
403
+ const data = isRecord(event.data) ? event.data : {}
404
+ const id = pickString(data['id'])
405
+ if (id !== undefined) {
406
+ const byId = state.byId.get(id)
407
+ if (byId !== undefined) return byId
408
+ }
409
+ const seq = pickNumber(data['seq'] ?? data['messageSeq'])
410
+ if (seq !== undefined) {
411
+ return state.messages.find(message => message.seq === seq)
412
+ }
413
+ return undefined
414
+ }
415
+
416
+ function applyUpdate(state: FoldState, event: WireEvent): void {
417
+ const message = findByIdOrSeq(state, event)
418
+ if (message === undefined) return
419
+ const data = isRecord(event.data) ? event.data : {}
420
+ const text = pickString(data['text'])
421
+ const next: RenderMessage = {
422
+ ...message,
423
+ ...(text !== undefined ? { text } : {}),
424
+ seq: event.seq,
425
+ time: event.time,
426
+ }
427
+ replaceMessage(state, message, next)
428
+ }
429
+
430
+ function removeMessage(state: FoldState, message: RenderMessage): void {
431
+ const index = state.messages.indexOf(message)
432
+ if (index !== -1) state.messages.splice(index, 1)
433
+ state.byId.delete(message.id)
434
+ state.messageTurn.delete(message.id)
435
+ state.toolNames.delete(message.id)
436
+ for (const [key, candidate] of state.turnStepMessage) {
437
+ if (candidate === message) state.turnStepMessage.delete(key)
438
+ }
439
+ for (const [key, candidate] of state.pendingByTurnStep) {
440
+ if (candidate === message) state.pendingByTurnStep.delete(key)
441
+ }
442
+ }
443
+
444
+ function applyDelete(state: FoldState, event: WireEvent): void {
445
+ const message = findByIdOrSeq(state, event)
446
+ if (message === undefined) return
447
+ removeMessage(state, message)
448
+ }
449
+
450
+ function applyToolCall(state: FoldState, event: WireEvent): void {
451
+ const data = isRecord(event.data) ? event.data : {}
452
+ const name = pickString(data['name'])
453
+ if (name === undefined) return
454
+ const turn = pickNumber(data['turn'])
455
+ const step = pickNumber(data['step'])
456
+ const key = tsKey(turn, step)
457
+
458
+ let target = key === undefined ? undefined : state.turnStepMessage.get(key)
459
+ if (target === undefined && turn !== undefined) {
460
+ for (let i = state.messages.length - 1; i >= 0; i--) {
461
+ const candidate = state.messages[i]
462
+ if (candidate !== undefined && candidate.kind === 'assistant' && state.messageTurn.get(candidate.id) === turn) {
463
+ target = candidate
464
+ break
465
+ }
466
+ }
467
+ }
468
+ if (target === undefined) {
469
+ for (let i = state.messages.length - 1; i >= 0; i--) {
470
+ const candidate = state.messages[i]
471
+ if (candidate !== undefined && candidate.kind === 'assistant') {
472
+ target = candidate
473
+ break
474
+ }
475
+ }
476
+ }
477
+ if (target === undefined) return
478
+
479
+ const names = state.toolNames.get(target.id) ?? new Set<string>()
480
+ const isNewName = !names.has(name)
481
+ if (isNewName) {
482
+ names.add(name)
483
+ state.toolNames.set(target.id, names)
484
+ }
485
+ const callId = pickString(data['callId']) ?? `${name}#${String(event.seq)}`
486
+ const args = pickString(data['arguments'])
487
+ const tools = target.tools ?? []
488
+ const existingIndex = tools.findIndex(tool => tool.callId === callId)
489
+ const isNewCall = existingIndex === -1
490
+ const nextTools: ToolCallInfo[] = isNewCall
491
+ ? [...tools, { callId, name, ...(args !== undefined ? { arguments: args } : {}) }]
492
+ : tools.map((tool, index) => index === existingIndex
493
+ ? { ...tool, ...(args !== undefined ? { arguments: args } : {}) }
494
+ : tool)
495
+ const next: RenderMessage = {
496
+ ...target,
497
+ ...(isNewName ? { toolSummary: `使用 ${[...names].join(' / ')}` } : {}),
498
+ ...(isNewCall || args !== undefined ? { tools: nextTools } : {}),
499
+ seq: event.seq,
500
+ time: event.time,
501
+ }
502
+ replaceMessage(state, target, next)
503
+ retargetTurnStep(state, key, target, next)
504
+ }
505
+
506
+ function applyTurnEnd(state: FoldState, event: WireEvent): void {
507
+ const data = isRecord(event.data) ? event.data : {}
508
+ const turn = pickNumber(data['turn'])
509
+ const reason = isRecord(data['reason']) ? data['reason'] : {}
510
+ const failed = reason['kind'] === 'error'
511
+
512
+ let targets: RenderMessage[]
513
+ if (turn !== undefined) {
514
+ targets = state.messages.filter(message => message.kind === 'assistant' && state.messageTurn.get(message.id) === turn)
515
+ } else {
516
+ targets = state.messages.filter(message => message.kind === 'assistant')
517
+ }
518
+ if (targets.length === 0) {
519
+ for (let i = state.messages.length - 1; i >= 0; i--) {
520
+ const candidate = state.messages[i]
521
+ if (candidate !== undefined && candidate.kind === 'assistant') {
522
+ targets = [candidate]
523
+ break
524
+ }
525
+ }
526
+ }
527
+ for (const message of targets) {
528
+ const wasPending = message.pending === true
529
+ replaceMessage(state, message, {
530
+ ...message,
531
+ ...(wasPending ? { pending: false } : {}),
532
+ ...(failed ? { failed: true } : {}),
533
+ seq: Math.max(message.seq, event.seq),
534
+ time: event.time,
535
+ })
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Fold a batch of session events into a renderable message list.
541
+ *
542
+ * @param events - events to apply, in any order (folded by ascending seq).
543
+ * @param existing - the previously rendered list (live-stream incremental tail).
544
+ * @returns messages sorted by seq.
545
+ */
546
+ export function foldEvents(events: readonly WireEvent[], existing?: readonly RenderMessage[]): RenderMessage[] {
547
+ const sorted = [...events].sort((a, b) => a.seq - b.seq)
548
+ const watermark = watermarkOf(existing)
549
+ const state = createState(existing)
550
+ for (const event of sorted) {
551
+ if (event.seq <= watermark) continue
552
+ applyEvent(state, event)
553
+ }
554
+ return [...state.messages].sort((a, b) => a.seq - b.seq || (a.id < b.id ? -1 : 1))
555
+ }