@shendeguize/dsh-agent-sidecar 0.1.0

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
@@ -0,0 +1,779 @@
1
+ /**
2
+ * Pure view-model logic for the session-detail timeline view (design §5.1
3
+ * view 2, §5.3 honest presentation). No React, no I/O, no imports from the
4
+ * data layer — everything arrives and leaves as plain values, so this whole
5
+ * module is unit-testable in a bare node environment (same posture as
6
+ * board/logic.ts and inject/logic.ts).
7
+ *
8
+ * Decoupling contract (T5.3 ↔ S7 integration): the `*Wire` types below are
9
+ * this module's OWN hand-written mirrors of the host M3 response shapes
10
+ * (src/routes.ts `timelineBody` over src/fusion.ts `TimelinePage`) — the
11
+ * client TS program cannot import host modules. The integration layer owns
12
+ * transport and feeds pages in; this module owns accumulation, dedup,
13
+ * ordering, gap detection and presentation derivation.
14
+ *
15
+ * Honesty invariants implemented here (design §4.b.3 / §5.3):
16
+ * - a seq discontinuity between consecutive seq-carrying entries inserts a
17
+ * visible gap marker row (「缺口:可能有 N 条事件未捕获」); seq-less
18
+ * entries are skipped by the detector and can never fake or mask a gap;
19
+ * - page provenance (`sources`) is surfaced as badges, never dropped;
20
+ * - listen-mode merges only ever append facts (dedup by entry identity),
21
+ * and newly appended entries are reported for highlight, not invented.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { DETAIL_STRINGS } from './strings.ts'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Wire mirrors (host source of truth: routes.ts timelineBody / fusion.ts).
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** One merged timeline entry as serialized by the host (fusion.ts `TimelineEntry`). */
33
+ export interface TimelineEntryWire {
34
+ origin: 'dsh' | 'sidecar'
35
+ /** dsh log seq (native or mirrored via sidecar `extra.seq`); null when unknown. */
36
+ seq: number | null
37
+ /** Unix epoch milliseconds. */
38
+ ts: number
39
+ /** dsh native event type for dsh entries, normalized kind for sidecar entries. */
40
+ kind: string
41
+ /** Normalized sidecar text; `''` for dsh entries without a sidecar twin. */
42
+ text: string
43
+ /** Raw dsh event data; JSON drops it entirely for sidecar-only entries. */
44
+ data?: unknown
45
+ extra: Record<string, unknown> | null
46
+ }
47
+
48
+ /** Decoded cursor object echoed next to the opaque token (routes.ts). */
49
+ export interface TimelineCursorWire {
50
+ seq: number | null
51
+ ts: number
52
+ }
53
+
54
+ /** Which sources contributed to a page (fusion.ts `TimelineSources`). */
55
+ export interface TimelineSourcesWire {
56
+ dshLive: boolean
57
+ dshCold: boolean
58
+ sidecarReplay: boolean
59
+ sidecarBuffer: boolean
60
+ }
61
+
62
+ /**
63
+ * One timeline page as answered by `GET session/<id>` (nested) and
64
+ * `GET session/<id>/timeline` (routes.ts `timelineBody`). `nextCursor` is
65
+ * the opaque pagination token and must be round-tripped verbatim.
66
+ */
67
+ export interface TimelinePageWire {
68
+ sessionId: string
69
+ entries: TimelineEntryWire[]
70
+ cursor: TimelineCursorWire | null
71
+ nextCursor: string | null
72
+ sources: TimelineSourcesWire
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Small shared helpers.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const MINUTE_MS = 60_000
80
+ const HOUR_MS = 3_600_000
81
+ const DAY_MS = 86_400_000
82
+ const KEY_SEP = '\u0000'
83
+
84
+ /** Resolve `{name}` placeholders in a message template. */
85
+ export function formatTemplate(
86
+ template: string,
87
+ params: Record<string, string | number>,
88
+ ): string {
89
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => {
90
+ const value = params[key]
91
+ return value === undefined ? match : String(value)
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Coarse relative time: <60s (including clock skew into the future) is
97
+ * 刚刚, then whole minutes/hours/days. Non-finite input renders empty.
98
+ */
99
+ export function formatRelativeTime(thenMs: number, nowMs: number): string {
100
+ if (!Number.isFinite(thenMs)) return ''
101
+ const delta = nowMs - thenMs
102
+ if (delta < MINUTE_MS) return DETAIL_STRINGS.time.justNow
103
+ if (delta < HOUR_MS) {
104
+ return formatTemplate(DETAIL_STRINGS.time.minutesAgo, { n: Math.floor(delta / MINUTE_MS) })
105
+ }
106
+ if (delta < DAY_MS) {
107
+ return formatTemplate(DETAIL_STRINGS.time.hoursAgo, { n: Math.floor(delta / HOUR_MS) })
108
+ }
109
+ return formatTemplate(DETAIL_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Event-kind classification (icon + label).
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** Normalized event-kind vocabulary; anything unrecognized maps to 'other'. */
117
+ export type TimelineKindToken =
118
+ | 'user'
119
+ | 'assistant'
120
+ | 'thinking'
121
+ | 'toolCall'
122
+ | 'toolResult'
123
+ | 'turn'
124
+ | 'step'
125
+ | 'error'
126
+ | 'other'
127
+
128
+ const KIND_GLYPHS: Record<TimelineKindToken, string> = {
129
+ user: '▷',
130
+ assistant: '◁',
131
+ thinking: '…',
132
+ toolCall: '⚙',
133
+ toolResult: '↩',
134
+ turn: '§',
135
+ step: '·',
136
+ error: '✕',
137
+ other: '•',
138
+ }
139
+
140
+ /**
141
+ * Map a raw event kind onto the glyph/label vocabulary. Covers both the
142
+ * sidecar normalized kinds — user/assistant/thinking/tool_call/tool_result
143
+ * plus the turn_ and step_ prefixes (sidecar/model.py) — and dsh native
144
+ * slash-path types (`message/user` style); everything else is honestly
145
+ * 'other'.
146
+ */
147
+ export function classifyKind(kind: string): TimelineKindToken {
148
+ const k = kind.trim().toLowerCase()
149
+ const last = k.includes('/') ? (k.split('/').pop() ?? k) : k
150
+ if (last === 'user') return 'user'
151
+ if (last === 'assistant') return 'assistant'
152
+ if (last === 'thinking' || last === 'reasoning') return 'thinking'
153
+ if (last === 'tool_call' || last === 'tool-call' || last === 'toolcall') return 'toolCall'
154
+ if (last === 'tool_result' || last === 'tool-result' || last === 'toolresult') {
155
+ return 'toolResult'
156
+ }
157
+ if (last.startsWith('turn_') || last === 'turn') return 'turn'
158
+ if (last.startsWith('step_') || last === 'step') return 'step'
159
+ if (last === 'error') return 'error'
160
+ return 'other'
161
+ }
162
+
163
+ /** Glyph for a kind token. */
164
+ export function kindGlyph(token: TimelineKindToken): string {
165
+ return KIND_GLYPHS[token]
166
+ }
167
+
168
+ /**
169
+ * Display label: the Chinese vocabulary for recognized kinds; unknown kinds
170
+ * keep their raw text (the view never invents a category, design §5.3).
171
+ */
172
+ export function kindLabel(token: TimelineKindToken, rawKind: string): string {
173
+ if (token === 'other') {
174
+ const trimmed = rawKind.trim()
175
+ return trimmed === '' ? DETAIL_STRINGS.kind.other : trimmed
176
+ }
177
+ return DETAIL_STRINGS.kind[token]
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Entry normalization.
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** One-line summary cap (characters, not bytes — display concern only). */
185
+ export const SUMMARY_MAX_CHARS = 120
186
+ /** Expanded-body cap so a single pathological entry cannot freeze the tab. */
187
+ export const BODY_MAX_CHARS = 4_000
188
+
189
+ /** One normalized, render-ready timeline entry. */
190
+ export interface TimelineEntryVM {
191
+ /** Stable identity for dedup and React keys (see {@link entryKey}). */
192
+ key: string
193
+ origin: 'dsh' | 'sidecar'
194
+ seq: number | null
195
+ /** Unix epoch ms. */
196
+ ts: number
197
+ /** Raw wire kind (hover/debug). */
198
+ kindRaw: string
199
+ kind: TimelineKindToken
200
+ glyph: string
201
+ label: string
202
+ /** First line of the body, truncated to {@link SUMMARY_MAX_CHARS}. */
203
+ summary: string
204
+ /** Expanded body (full text or pretty-printed dsh data); null when the summary says it all. */
205
+ body: string | null
206
+ expandable: boolean
207
+ }
208
+
209
+ /**
210
+ * Dedup identity of one wire entry within a session timeline. Seq-carrying
211
+ * entries collapse on seq+kind+text — NOT seq alone: one dsh record can
212
+ * normalize into several sibling events sharing one seq (reasoning+text
213
+ * blocks, multi-block messages), and a seq-only key would silently fold
214
+ * them away (F1). Same rule as fusion.ts `sidecarEventKey`, so a client
215
+ * multi-page merge matches the host's single-page merge. Seq-less entries
216
+ * fall back to ts+kind+text.
217
+ */
218
+ export function entryKey(entry: {
219
+ seq: number | null
220
+ ts: number
221
+ kind: string
222
+ text: string
223
+ }): string {
224
+ return entry.seq !== null
225
+ ? `s:${entry.seq}${KEY_SEP}${entry.kind}${KEY_SEP}${entry.text}`
226
+ : `t:${entry.ts}${KEY_SEP}${entry.kind}${KEY_SEP}${entry.text}`
227
+ }
228
+
229
+ const ELLIPSIS = '…'
230
+
231
+ function truncateChars(text: string, max: number): string {
232
+ return text.length <= max ? text : `${text.slice(0, max)}${ELLIPSIS}`
233
+ }
234
+
235
+ /** Best-effort primary text of an entry: normalized text, else common data fields. */
236
+ function extractBaseText(entry: TimelineEntryWire): string {
237
+ if (entry.text !== '') return entry.text
238
+ const data = entry.data
239
+ if (typeof data === 'string') return data
240
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
241
+ const record = data as Record<string, unknown>
242
+ for (const field of ['text', 'title', 'content', 'message']) {
243
+ const value = record[field]
244
+ if (typeof value === 'string' && value !== '') return value
245
+ }
246
+ }
247
+ return ''
248
+ }
249
+
250
+ function safePrettyJson(value: unknown): string | null {
251
+ try {
252
+ const json = JSON.stringify(value, null, 2)
253
+ return typeof json === 'string' ? json : null
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Wire entry → render-ready view model. Summary is the first line of the
261
+ * best-effort text; the expandable body is the full text when the summary
262
+ * truncated it, else the pretty-printed dsh `data` payload when one exists
263
+ * (both bounded by {@link BODY_MAX_CHARS}).
264
+ */
265
+ export function normalizeTimelineEntry(entry: TimelineEntryWire): TimelineEntryVM {
266
+ const token = classifyKind(entry.kind)
267
+ const baseText = extractBaseText(entry)
268
+ const firstLine = baseText.split('\n', 1)[0] ?? ''
269
+ const summary = truncateChars(firstLine.trim(), SUMMARY_MAX_CHARS)
270
+
271
+ let body: string | null = null
272
+ if (baseText.trim() !== '' && baseText.trim() !== summary) {
273
+ body = truncateChars(baseText, BODY_MAX_CHARS)
274
+ } else if (entry.data !== undefined) {
275
+ const json = safePrettyJson(entry.data)
276
+ if (json !== null && json !== summary) body = truncateChars(json, BODY_MAX_CHARS)
277
+ }
278
+
279
+ return {
280
+ key: entryKey(entry),
281
+ origin: entry.origin,
282
+ seq: entry.seq,
283
+ ts: entry.ts,
284
+ kindRaw: entry.kind,
285
+ kind: token,
286
+ glyph: kindGlyph(token),
287
+ label: kindLabel(token, entry.kind),
288
+ summary,
289
+ body,
290
+ expandable: body !== null,
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Ordering (mirrors fusion.ts mergeTimeline so client order == host order).
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Sort entries the way the host merges them: seq-carrying entries keep
300
+ * exact seq order among themselves, seq-less entries sort by ts and are
301
+ * interleaved by ts (tie: seq domain first). This keeps a client-side
302
+ * multi-page merge byte-identical to what one giant host page would be.
303
+ * Within one seq (sibling block events of one dsh record) the dsh entry
304
+ * sorts first — mirroring the host merge — and sidecar siblings keep
305
+ * their arrival order (the sort is stable).
306
+ */
307
+ export function sortTimelineEntries(entries: readonly TimelineEntryVM[]): TimelineEntryVM[] {
308
+ const seqDomain = entries.filter((e) => e.seq !== null)
309
+ const unseqed = entries.filter((e) => e.seq === null)
310
+ seqDomain.sort(
311
+ (a, b) =>
312
+ (a.seq ?? 0) - (b.seq ?? 0) ||
313
+ (a.origin === b.origin ? 0 : a.origin === 'dsh' ? -1 : 1),
314
+ )
315
+ unseqed.sort((a, b) => a.ts - b.ts || a.key.localeCompare(b.key))
316
+
317
+ const out: TimelineEntryVM[] = []
318
+ let i = 0
319
+ let j = 0
320
+ for (;;) {
321
+ const a = seqDomain[i]
322
+ const b = unseqed[j]
323
+ if (a === undefined && b === undefined) break
324
+ if (b === undefined || (a !== undefined && a.ts <= b.ts)) {
325
+ if (a !== undefined) {
326
+ out.push(a)
327
+ i += 1
328
+ }
329
+ } else {
330
+ out.push(b)
331
+ j += 1
332
+ }
333
+ }
334
+ return out
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Timeline accumulation state (owned by the integration layer, fed to the
339
+ // component as the `timeline` prop).
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Accumulated timeline state across pages and listen updates. Plain data:
344
+ * every mutation helper returns a fresh object (React-state friendly).
345
+ */
346
+ export interface TimelineVM {
347
+ sessionId: string
348
+ /** Ascending (host merge order), deduplicated, normalized. */
349
+ entries: TimelineEntryVM[]
350
+ /** Union of the provenance flags of every merged page. */
351
+ sources: TimelineSourcesWire
352
+ /**
353
+ * Opaque pagination token of the oldest merged history page; null when
354
+ * the log start was reached (or nothing was loaded yet). Round-trip it
355
+ * verbatim to `GET session/<id>/timeline?cursor=`.
356
+ */
357
+ nextCursor: string | null
358
+ /** True once the log start was reached (nextCursor exhausted). */
359
+ reachedStart: boolean
360
+ /** Keys appended by the most recent listen-mode merge (highlight set). */
361
+ newKeys: readonly string[]
362
+ }
363
+
364
+ /** Fresh empty state for a session. */
365
+ export function createTimelineVM(sessionId: string): TimelineVM {
366
+ return {
367
+ sessionId,
368
+ entries: [],
369
+ sources: { dshLive: false, dshCold: false, sidecarReplay: false, sidecarBuffer: false },
370
+ nextCursor: null,
371
+ reachedStart: false,
372
+ newKeys: [],
373
+ }
374
+ }
375
+
376
+ function unionSources(a: TimelineSourcesWire, b: TimelineSourcesWire): TimelineSourcesWire {
377
+ return {
378
+ dshLive: a.dshLive || b.dshLive,
379
+ dshCold: a.dshCold || b.dshCold,
380
+ sidecarReplay: a.sidecarReplay || b.sidecarReplay,
381
+ sidecarBuffer: a.sidecarBuffer || b.sidecarBuffer,
382
+ }
383
+ }
384
+
385
+ interface MergeOutcome {
386
+ entries: TimelineEntryVM[]
387
+ appendedKeys: string[]
388
+ }
389
+
390
+ /** (seq, kind) twin-slot sub-key of the convergence maps in mergeEntries. */
391
+ function seqKindKey(seq: number, kind: string): string {
392
+ return `${seq}${KEY_SEP}${kind}`
393
+ }
394
+
395
+ /** The key an un-supplemented dsh entry (empty wire text) carries. */
396
+ function emptyTextKey(seq: number, kind: string): string {
397
+ return `s:${seq}${KEY_SEP}${kind}${KEY_SEP}`
398
+ }
399
+
400
+ /**
401
+ * Dedup-by-key merge, then host-order sort.
402
+ *
403
+ * Cross-page convergence: the host folds the first sidecar twin's text
404
+ * into the matching dsh entry, so the SAME event can arrive with
405
+ * `text: ''` in one page (twin not yet observed) and with the folded
406
+ * text in a later one — two different keys for one event. A text-carrying
407
+ * arrival therefore upgrades the empty-text (seq, kind) slot in place,
408
+ * and an empty-text arrival for an already-supplemented (seq, kind) is
409
+ * dropped as stale. This keeps the accumulated client merge equal to the
410
+ * host's latest single-page merge instead of duplicating the entry.
411
+ * Genuine same-seq siblings always carry distinct kind/text and are
412
+ * untouched by the rule.
413
+ */
414
+ function mergeEntries(
415
+ existing: readonly TimelineEntryVM[],
416
+ incoming: readonly TimelineEntryWire[],
417
+ ): MergeOutcome {
418
+ const entries = [...existing]
419
+ const seen = new Set(entries.map((e) => e.key))
420
+ const emptySlot = new Map<string, number>()
421
+ const filled = new Set<string>()
422
+ for (let i = 0; i < entries.length; i += 1) {
423
+ const e = entries[i]
424
+ if (e === undefined || e.seq === null) continue
425
+ const sk = seqKindKey(e.seq, e.kindRaw)
426
+ if (e.key === emptyTextKey(e.seq, e.kindRaw)) emptySlot.set(sk, i)
427
+ else filled.add(sk)
428
+ }
429
+
430
+ const appendedKeys: string[] = []
431
+ let changed = false
432
+ for (const wire of incoming) {
433
+ const vm = normalizeTimelineEntry(wire)
434
+ if (seen.has(vm.key)) continue
435
+ if (vm.seq !== null) {
436
+ const sk = seqKindKey(vm.seq, vm.kindRaw)
437
+ if (wire.text === '') {
438
+ if (filled.has(sk)) continue
439
+ emptySlot.set(sk, entries.length)
440
+ } else {
441
+ filled.add(sk)
442
+ const slot = emptySlot.get(sk)
443
+ if (slot !== undefined) {
444
+ entries[slot] = vm
445
+ emptySlot.delete(sk)
446
+ seen.add(vm.key)
447
+ appendedKeys.push(vm.key)
448
+ changed = true
449
+ continue
450
+ }
451
+ }
452
+ }
453
+ seen.add(vm.key)
454
+ entries.push(vm)
455
+ appendedKeys.push(vm.key)
456
+ changed = true
457
+ }
458
+ if (!changed) return { entries, appendedKeys: [] }
459
+ return { entries: sortTimelineEntries(entries), appendedKeys }
460
+ }
461
+
462
+ /**
463
+ * Merge one HISTORY page (the initial newest page, or an older page fetched
464
+ * via `nextCursor`) into the state. Advances the pagination token to the
465
+ * page's own `nextCursor` and never marks entries as new (paging back is
466
+ * not fresh activity). Duplicate entries across overlapping pages dedup on
467
+ * {@link entryKey}.
468
+ */
469
+ export function applyTimelinePage(vm: TimelineVM, page: TimelinePageWire): TimelineVM {
470
+ const merged = mergeEntries(vm.entries, page.entries)
471
+ return {
472
+ ...vm,
473
+ entries: merged.entries,
474
+ sources: unionSources(vm.sources, page.sources),
475
+ nextCursor: page.nextCursor,
476
+ reachedStart: page.nextCursor === null,
477
+ newKeys: vm.newKeys,
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Merge one LISTEN update (the newest page refetched after an SSE `state`
483
+ * signal — stream snapshots carry no per-session events, so listen mode is
484
+ * snapshot-trigger + timeline-refetch, ADR-2/ADR-3). Appended entries are
485
+ * reported in `newKeys` for highlight; replays of already-known entries
486
+ * dedup silently and do NOT re-highlight. The pagination token is left
487
+ * alone: a newest-window page must never reset the older-history cursor.
488
+ */
489
+ export function applyListenPage(vm: TimelineVM, page: TimelinePageWire): TimelineVM {
490
+ const merged = mergeEntries(vm.entries, page.entries)
491
+ return {
492
+ ...vm,
493
+ entries: merged.entries,
494
+ sources: unionSources(vm.sources, page.sources),
495
+ newKeys: merged.appendedKeys,
496
+ }
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // Gap detection + row derivation.
501
+ // ---------------------------------------------------------------------------
502
+
503
+ /** One rendered timeline row: a real event or an honesty gap marker. */
504
+ export type TimelineRowVM =
505
+ | {
506
+ type: 'event'
507
+ key: string
508
+ entry: TimelineEntryVM
509
+ relativeTime: string
510
+ /** Hover title: ISO timestamp + raw kind + origin (+ seq). */
511
+ hoverTitle: string
512
+ /** True when this entry arrived in the latest listen merge. */
513
+ isNew: boolean
514
+ }
515
+ | {
516
+ type: 'gap'
517
+ key: string
518
+ /** Lower bound of dropped events implied by the seq break. */
519
+ missingCount: number
520
+ label: string
521
+ }
522
+
523
+ function isoOrEmpty(ts: number): string {
524
+ if (!Number.isFinite(ts)) return ''
525
+ try {
526
+ return new Date(ts).toISOString()
527
+ } catch {
528
+ return ''
529
+ }
530
+ }
531
+
532
+ function eventHoverTitle(entry: TimelineEntryVM): string {
533
+ const parts = [isoOrEmpty(entry.ts), entry.kindRaw, entry.origin]
534
+ if (entry.seq !== null) {
535
+ parts.push(formatTemplate(DETAIL_STRINGS.timeline.seq, { n: entry.seq }))
536
+ }
537
+ return parts.filter((p) => p !== '').join(' · ')
538
+ }
539
+
540
+ /** Gap marker text: 「缺口:可能有 N 条事件未捕获(256 队列上限或未持久化)」. */
541
+ export function gapLabel(missingCount: number): string {
542
+ return formatTemplate(DETAIL_STRINGS.gap.label, { n: missingCount })
543
+ }
544
+
545
+ /**
546
+ * Derive render rows from the accumulated state, inserting a gap marker
547
+ * wherever consecutive seq-carrying entries jump by more than 1 (honest
548
+ * presentation of the 256-slot subscribe queue drop / unpersisted events,
549
+ * design §4.b.3). Seq-less entries are transparent to the detector: they
550
+ * neither trigger a gap nor reset the last-seen seq, so mixed timelines
551
+ * cannot produce false positives. Nothing is inserted before the first
552
+ * seq entry — unloaded older history is pagination, not a gap.
553
+ */
554
+ export function buildTimelineRows(vm: TimelineVM, nowMs: number): TimelineRowVM[] {
555
+ const newKeys = new Set(vm.newKeys)
556
+ const rows: TimelineRowVM[] = []
557
+ let lastSeq: number | null = null
558
+ for (const entry of vm.entries) {
559
+ if (entry.seq !== null) {
560
+ if (lastSeq !== null && entry.seq > lastSeq + 1) {
561
+ const missing = entry.seq - lastSeq - 1
562
+ rows.push({
563
+ type: 'gap',
564
+ key: `gap:${lastSeq}-${entry.seq}`,
565
+ missingCount: missing,
566
+ label: gapLabel(missing),
567
+ })
568
+ }
569
+ lastSeq = entry.seq
570
+ }
571
+ rows.push({
572
+ type: 'event',
573
+ key: entry.key,
574
+ entry,
575
+ relativeTime: formatRelativeTime(entry.ts, nowMs),
576
+ hoverTitle: eventHoverTitle(entry),
577
+ isNew: newKeys.has(entry.key),
578
+ })
579
+ }
580
+ return rows
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Segmented rendering bound (perf stopgap; no full virtualization, see task
585
+ // report — page sizes already bound growth, this bounds pathological cases).
586
+ // ---------------------------------------------------------------------------
587
+
588
+ /** Default cap of rendered rows; older rows collapse behind a notice. */
589
+ export const DEFAULT_MAX_RENDER_ROWS = 400
590
+
591
+ export interface LimitedRows {
592
+ rows: TimelineRowVM[]
593
+ /** Rows hidden from the top (oldest side); 0 when nothing was cut. */
594
+ hiddenCount: number
595
+ /** Collapse notice text; null when nothing was cut. */
596
+ notice: string | null
597
+ }
598
+
599
+ /**
600
+ * Keep only the newest `max` rows (the tail — listen mode appends there).
601
+ * The data stays in the VM; this is purely a render bound the component
602
+ * can lift via its「全部显示」toggle.
603
+ */
604
+ export function limitTimelineRows(
605
+ rows: readonly TimelineRowVM[],
606
+ max: number = DEFAULT_MAX_RENDER_ROWS,
607
+ ): LimitedRows {
608
+ if (!Number.isFinite(max) || max <= 0 || rows.length <= max) {
609
+ return { rows: [...rows], hiddenCount: 0, notice: null }
610
+ }
611
+ const hiddenCount = rows.length - max
612
+ return {
613
+ rows: rows.slice(hiddenCount),
614
+ hiddenCount,
615
+ notice: formatTemplate(DETAIL_STRINGS.timeline.hiddenNotice, { n: hiddenCount }),
616
+ }
617
+ }
618
+
619
+ // ---------------------------------------------------------------------------
620
+ // Source badges.
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /** Color-token buckets the CSS maps to `--dsw-alias-*` variables. */
624
+ export type BadgeTone = 'success' | 'warn' | 'neutral' | 'muted' | 'danger'
625
+
626
+ export interface SourceBadgeVM {
627
+ id: keyof TimelineSourcesWire
628
+ label: string
629
+ tone: BadgeTone
630
+ }
631
+
632
+ const SOURCE_ORDER: ReadonlyArray<{ id: keyof TimelineSourcesWire; tone: BadgeTone }> = [
633
+ { id: 'dshLive', tone: 'success' },
634
+ { id: 'dshCold', tone: 'neutral' },
635
+ { id: 'sidecarReplay', tone: 'neutral' },
636
+ { id: 'sidecarBuffer', tone: 'muted' },
637
+ ]
638
+
639
+ /**
640
+ * Provenance badges for the header, stable order: dsh 实时 → dsh 冷读 →
641
+ * sidecar 重放 → sidecar 缓冲. Only contributing sources appear; an
642
+ * all-false set yields an empty list (the empty state explains itself).
643
+ */
644
+ export function deriveSourceBadges(sources: TimelineSourcesWire): SourceBadgeVM[] {
645
+ const out: SourceBadgeVM[] = []
646
+ for (const { id, tone } of SOURCE_ORDER) {
647
+ if (sources[id]) out.push({ id, label: DETAIL_STRINGS.sources[id], tone })
648
+ }
649
+ return out
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // Header status badge.
654
+ // ---------------------------------------------------------------------------
655
+
656
+ /** Normalized session status vocabulary; anything else maps to 'unknown'. */
657
+ export type SessionStatusToken = 'working' | 'waiting' | 'idle' | 'dead' | 'unknown'
658
+
659
+ const KNOWN_STATUSES: readonly SessionStatusToken[] = ['working', 'waiting', 'idle', 'dead']
660
+
661
+ const STATUS_TONE: Record<SessionStatusToken, BadgeTone> = {
662
+ working: 'success',
663
+ waiting: 'warn',
664
+ idle: 'neutral',
665
+ unknown: 'neutral',
666
+ dead: 'muted',
667
+ }
668
+
669
+ export interface DetailStatusVM {
670
+ status: SessionStatusToken
671
+ tone: BadgeTone
672
+ label: string
673
+ }
674
+
675
+ /**
676
+ * Raw observed status → badge. Unknown raw statuses keep their raw text as
677
+ * the label (the view never invents a state); empty raw text reads 未知.
678
+ */
679
+ export function deriveDetailStatus(rawStatus: string): DetailStatusVM {
680
+ const cleaned = rawStatus.trim().toLowerCase()
681
+ const status = (KNOWN_STATUSES as readonly string[]).includes(cleaned)
682
+ ? (cleaned as SessionStatusToken)
683
+ : 'unknown'
684
+ const trimmed = rawStatus.trim()
685
+ const label =
686
+ status === 'unknown'
687
+ ? trimmed === ''
688
+ ? DETAIL_STRINGS.status.unknown
689
+ : trimmed
690
+ : DETAIL_STRINGS.status[status]
691
+ return { status, tone: STATUS_TONE[status], label }
692
+ }
693
+
694
+ /** Single-character agent marker (board vocabulary; unknown → neutral dot). */
695
+ const AGENT_GLYPHS: Record<string, string> = {
696
+ dsh: '◆',
697
+ claude: '✳',
698
+ codex: '▣',
699
+ cursor: '▮',
700
+ 'cursor-cli': '▮',
701
+ 'cursor-ide': '▮',
702
+ copilot: '◉',
703
+ kimi: '◐',
704
+ }
705
+
706
+ export function agentGlyph(agent: string): string {
707
+ return AGENT_GLYPHS[agent.trim().toLowerCase()] ?? '●'
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Loading / empty / error body states.
712
+ // ---------------------------------------------------------------------------
713
+
714
+ /**
715
+ * Friendly text for a machine error reason (ApiError.reason / server
716
+ * `{reason}` codes). Unknown codes fall back to an honest 错误码 template.
717
+ */
718
+ export function detailErrorText(reason: string): string {
719
+ const table: Record<string, string> = DETAIL_STRINGS.states.errors
720
+ const mapped = table[reason]
721
+ return mapped ?? formatTemplate(DETAIL_STRINGS.states.errorFallback, { reason })
722
+ }
723
+
724
+ export type DetailBodyKind = 'loading' | 'error' | 'empty' | 'list'
725
+
726
+ export interface DetailBodyStateVM {
727
+ kind: DetailBodyKind
728
+ /** Full-body title/hint for the non-list states; null for 'list'. */
729
+ title: string | null
730
+ hint: string | null
731
+ /** Non-null when stale entries stay visible while a refresh/page failed. */
732
+ errorBanner: string | null
733
+ }
734
+
735
+ /**
736
+ * Body-state resolution, honest-by-priority:
737
+ * - entries present → always 'list' (never hide data already shown); a
738
+ * concurrent error surfaces as an inline banner instead;
739
+ * - no entries + error → 'error' (mapped text);
740
+ * - no entries + loading → 'loading';
741
+ * - otherwise → 'empty'.
742
+ */
743
+ export function deriveDetailBodyState(input: {
744
+ loading: boolean
745
+ /** Machine reason code, or null when the last load succeeded. */
746
+ error: string | null
747
+ entryCount: number
748
+ }): DetailBodyStateVM {
749
+ if (input.entryCount > 0) {
750
+ return {
751
+ kind: 'list',
752
+ title: null,
753
+ hint: null,
754
+ errorBanner: input.error === null ? null : detailErrorText(input.error),
755
+ }
756
+ }
757
+ if (input.error !== null) {
758
+ return {
759
+ kind: 'error',
760
+ title: DETAIL_STRINGS.states.errorTitle,
761
+ hint: detailErrorText(input.error),
762
+ errorBanner: null,
763
+ }
764
+ }
765
+ if (input.loading) {
766
+ return {
767
+ kind: 'loading',
768
+ title: DETAIL_STRINGS.states.loadingTitle,
769
+ hint: null,
770
+ errorBanner: null,
771
+ }
772
+ }
773
+ return {
774
+ kind: 'empty',
775
+ title: DETAIL_STRINGS.states.emptyTitle,
776
+ hint: DETAIL_STRINGS.states.emptyHint,
777
+ errorBanner: null,
778
+ }
779
+ }