@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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- 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
|
+
}
|