@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,596 @@
1
+ /**
2
+ * Pure view-model logic for the dsh deep-query tools (design §5.1 view 2
3
+ * dsh 会话专属区 / M3): lineage trace → tree view model, provenance-jump
4
+ * resolution, search-result normalization (snippet highlighting), and the
5
+ * honest-degradation models (§5.3: degradation is presented, never faked).
6
+ *
7
+ * No React, no I/O, no imports from the data layer — everything arrives
8
+ * and leaves as plain values, so this module is unit-testable in a bare
9
+ * node environment (same posture as board/logic.ts and inject/logic.ts).
10
+ *
11
+ * Decoupling contract: the wire-facing types below are this module's OWN
12
+ * hand-written mirrors of the host read contract (task constraint —
13
+ * components never import host code):
14
+ * - {@link LineageResponseVM} mirrors `GET <prefix>/lineage/<id>` — always
15
+ * 200 `{available, trace, reason, detail?}` (routes.ts handleLineage over
16
+ * fusion.ts LineageResult); `available:false` is DATA, not an error.
17
+ * - {@link LineageTraceVM} mirrors fusion.ts DshLineageTraceFace (the
18
+ * `SessionLineageTrace` subset from dsh-session-query traceSession).
19
+ * - {@link SearchResponseVM} mirrors `GET <prefix>/search?q=&project=&limit=`
20
+ * (routes.ts handleSearch over fusion.ts searchSessions): `mode:
21
+ * 'filter-only'` means the sessionQuery full-text engine is unavailable
22
+ * and the backend degraded to title/project filtering.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import { DSH_TOOLS_STRINGS } from './strings.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Small shared helpers.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Resolve `{name}` placeholders in a message template (board convention). */
34
+ export function formatTemplate(
35
+ template: string,
36
+ params: Record<string, string | number>,
37
+ ): string {
38
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => {
39
+ const value = params[key]
40
+ return value === undefined ? match : String(value)
41
+ })
42
+ }
43
+
44
+ /** Head…tail abbreviation for long session ids (same rule as the board). */
45
+ export function abbreviateId(id: string, max = 20): string {
46
+ if (id.length <= max) return id
47
+ return `${id.slice(0, 12)}…${id.slice(-6)}`
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Lineage wire mirrors (host source of truth noted per type).
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** `SessionRecord` subset inside a trace (host: fusion.ts DshLineageRecordFace). */
55
+ export interface LineageRecordVM {
56
+ header: {
57
+ id: string
58
+ createdAt: number
59
+ cwd?: string
60
+ parentSession?: string
61
+ }
62
+ /** True when the session is live in the dsh process. */
63
+ live: boolean
64
+ /** True when dsh has persisted the session log. */
65
+ persisted: boolean
66
+ }
67
+
68
+ /** One descendant branch (host: fusion.ts DshLineageNodeFace). */
69
+ export interface LineageBranchVM {
70
+ session: LineageRecordVM
71
+ descendants: readonly LineageBranchVM[]
72
+ }
73
+
74
+ /** The trace body (host: fusion.ts DshLineageTraceFace ← traceSession). */
75
+ export interface LineageTraceVM {
76
+ target: LineageRecordVM
77
+ ancestors: readonly LineageRecordVM[]
78
+ descendants: readonly LineageBranchVM[]
79
+ complete: boolean
80
+ root?: LineageRecordVM
81
+ unresolvedParentId?: string
82
+ }
83
+
84
+ /** Backend degradation vocabulary (host: fusion.ts LineageResult.reason). */
85
+ export type LineageBackendReason = 'session_query_unavailable' | 'trace_failed'
86
+
87
+ /**
88
+ * Client-side degradation vocabulary: the backend reasons plus
89
+ * `not_dsh_session`, minted locally by {@link externalLineageFallback} for
90
+ * sessions of non-dsh agents (lineage is a dsh-only capability, so the
91
+ * integration never even calls the backend for them).
92
+ */
93
+ export type LineageDegradeReason = LineageBackendReason | 'not_dsh_session'
94
+
95
+ /** Body of `GET lineage/<id>` — always HTTP 200 (host: routes.ts). */
96
+ export interface LineageResponseVM {
97
+ available: boolean
98
+ trace: LineageTraceVM | null
99
+ reason: LineageBackendReason | null
100
+ detail?: string
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Lineage trace → tree view model.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** Position of a node relative to the traced session. */
108
+ export type LineageRole = 'ancestor' | 'target' | 'descendant'
109
+
110
+ /** One flattened tree row, ready to render (indent by `depth`). */
111
+ export interface LineageNodeVM {
112
+ id: string
113
+ role: LineageRole
114
+ roleLabel: string
115
+ /** Indentation level; the outermost ancestor (or the target) is 0. */
116
+ depth: number
117
+ /** Structural tree parent (render edge), null for the outermost row. */
118
+ parentId: string | null
119
+ /** True when deeper rows directly under this one exist (collapse handle). */
120
+ hasChildren: boolean
121
+ /** True when this node IS the session the user is inspecting (highlight). */
122
+ isCurrent: boolean
123
+ isTarget: boolean
124
+ live: boolean
125
+ persisted: boolean
126
+ shortId: string
127
+ cwd: string | null
128
+ createdAt: number
129
+ }
130
+
131
+ /** The whole lineage tree render model. */
132
+ export interface LineageTreeVM {
133
+ /** Rows in render order: ancestors root-first, target, descendants DFS. */
134
+ nodes: LineageNodeVM[]
135
+ targetId: string
136
+ complete: boolean
137
+ unresolvedParentId: string | null
138
+ /** Honest incompleteness banner; null when the trace is complete. */
139
+ incompleteNotice: string | null
140
+ nodeCount: number
141
+ maxDepth: number
142
+ }
143
+
144
+ function toNode(
145
+ record: LineageRecordVM,
146
+ role: LineageRole,
147
+ depth: number,
148
+ parentId: string | null,
149
+ currentSessionId: string | null,
150
+ ): LineageNodeVM {
151
+ return {
152
+ id: record.header.id,
153
+ role,
154
+ roleLabel: DSH_TOOLS_STRINGS.lineage.role[role],
155
+ depth,
156
+ parentId,
157
+ hasChildren: false,
158
+ isCurrent: currentSessionId !== null && record.header.id === currentSessionId,
159
+ isTarget: role === 'target',
160
+ live: record.live,
161
+ persisted: record.persisted,
162
+ shortId: abbreviateId(record.header.id),
163
+ cwd: record.header.cwd ?? null,
164
+ createdAt: record.header.createdAt,
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Order the ancestors root-first by walking `header.parentSession` links
170
+ * up from the target. Ancestors the links cannot reach (broken chain —
171
+ * the trace then also carries `complete:false`) are still shown: they sit
172
+ * above the resolved chain in their reported order, since everything the
173
+ * backend lists in `ancestors` IS an ancestor even when the intermediate
174
+ * links are unresolved.
175
+ */
176
+ function orderAncestors(trace: LineageTraceVM): LineageRecordVM[] {
177
+ const byId = new Map<string, LineageRecordVM>()
178
+ for (const record of trace.ancestors) byId.set(record.header.id, record)
179
+ const chain: LineageRecordVM[] = [] // nearest-first
180
+ const visited = new Set<string>()
181
+ let cursor = trace.target.header.parentSession
182
+ while (cursor !== undefined && !visited.has(cursor)) {
183
+ const record = byId.get(cursor)
184
+ if (record === undefined) break
185
+ visited.add(cursor)
186
+ chain.push(record)
187
+ cursor = record.header.parentSession
188
+ }
189
+ const leftovers = trace.ancestors.filter((r) => !visited.has(r.header.id))
190
+ return [...leftovers, ...chain.reverse()]
191
+ }
192
+
193
+ /**
194
+ * Lineage trace → flattened tree view model. Ancestors form the spine
195
+ * (root-most at depth 0), the target follows, and the descendant branches
196
+ * are appended depth-first in reported order. `currentSessionId` marks the
197
+ * highlight (usually the target, but a lineage panel opened from another
198
+ * node keeps whatever the owner is inspecting).
199
+ */
200
+ export function buildLineageTree(
201
+ trace: LineageTraceVM,
202
+ currentSessionId: string | null,
203
+ ): LineageTreeVM {
204
+ const nodes: LineageNodeVM[] = []
205
+
206
+ const ancestors = orderAncestors(trace)
207
+ let parentId: string | null = null
208
+ let depth = 0
209
+ for (const record of ancestors) {
210
+ nodes.push(toNode(record, 'ancestor', depth, parentId, currentSessionId))
211
+ parentId = record.header.id
212
+ depth += 1
213
+ }
214
+
215
+ nodes.push(toNode(trace.target, 'target', depth, parentId, currentSessionId))
216
+ const targetDepth = depth
217
+
218
+ const walk = (branch: LineageBranchVM, branchDepth: number, branchParent: string): void => {
219
+ nodes.push(
220
+ toNode(branch.session, 'descendant', branchDepth, branchParent, currentSessionId),
221
+ )
222
+ for (const child of branch.descendants) {
223
+ walk(child, branchDepth + 1, branch.session.header.id)
224
+ }
225
+ }
226
+ for (const branch of trace.descendants) {
227
+ walk(branch, targetDepth + 1, trace.target.header.id)
228
+ }
229
+
230
+ // hasChildren over the flattened DFS order: a row has children exactly
231
+ // when the next row is one level deeper.
232
+ let maxDepth = 0
233
+ for (let i = 0; i < nodes.length; i += 1) {
234
+ const node = nodes[i]
235
+ if (node === undefined) continue
236
+ const next = nodes[i + 1]
237
+ node.hasChildren = next !== undefined && next.depth > node.depth
238
+ if (node.depth > maxDepth) maxDepth = node.depth
239
+ }
240
+
241
+ const unresolvedParentId = trace.unresolvedParentId ?? null
242
+ const incompleteNotice = trace.complete
243
+ ? null
244
+ : unresolvedParentId !== null
245
+ ? formatTemplate(DSH_TOOLS_STRINGS.lineage.incompleteWithId, {
246
+ id: abbreviateId(unresolvedParentId),
247
+ })
248
+ : DSH_TOOLS_STRINGS.lineage.incomplete
249
+
250
+ return {
251
+ nodes,
252
+ targetId: trace.target.header.id,
253
+ complete: trace.complete,
254
+ unresolvedParentId,
255
+ incompleteNotice,
256
+ nodeCount: nodes.length,
257
+ maxDepth,
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Apply component-owned collapse state over the flattened rows: a
263
+ * collapsed row keeps itself but hides every deeper row until the DFS
264
+ * order returns to its level.
265
+ */
266
+ export function visibleLineageNodes(
267
+ nodes: readonly LineageNodeVM[],
268
+ collapsedIds: ReadonlySet<string>,
269
+ ): LineageNodeVM[] {
270
+ const out: LineageNodeVM[] = []
271
+ let hideDeeperThan: number | null = null
272
+ for (const node of nodes) {
273
+ if (hideDeeperThan !== null) {
274
+ if (node.depth > hideDeeperThan) continue
275
+ hideDeeperThan = null
276
+ }
277
+ out.push(node)
278
+ if (node.hasChildren && collapsedIds.has(node.id)) {
279
+ hideDeeperThan = node.depth
280
+ }
281
+ }
282
+ return out
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Provenance jump (点击谱系节点 → onSelectSession(id)).
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** What a click on a lineage node should do. */
290
+ export type LineageJump =
291
+ | { kind: 'select'; sessionId: string }
292
+ /** The node is the session already on screen — highlight, no navigation. */
293
+ | { kind: 'current' }
294
+
295
+ /** Resolve the jump target of a lineage-node click. */
296
+ export function resolveJumpTarget(
297
+ nodeId: string,
298
+ currentSessionId: string | null,
299
+ ): LineageJump {
300
+ if (currentSessionId !== null && nodeId === currentSessionId) {
301
+ return { kind: 'current' }
302
+ }
303
+ return { kind: 'select', sessionId: nodeId }
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // External-agent degradation (lineage is dsh-exclusive).
308
+ // ---------------------------------------------------------------------------
309
+
310
+ const DSH_AGENT = 'dsh'
311
+
312
+ /**
313
+ * Client-side pre-check for the lineage panel: sessions of non-dsh agents
314
+ * get a synthetic degraded response (reason `not_dsh_session`) and the
315
+ * integration never calls the backend for them; dsh sessions return null
316
+ * (proceed to fetch). Honest presentation, not an error (§5.3).
317
+ */
318
+ export function externalLineageFallback(
319
+ agent: string,
320
+ ): { available: false; trace: null; reason: 'not_dsh_session' } | null {
321
+ if (agent.trim().toLowerCase() === DSH_AGENT) return null
322
+ return { available: false, trace: null, reason: 'not_dsh_session' }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Degradation cards and the lineage view resolver.
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /** Render model of one degradation card (available:false presentations). */
330
+ export interface LineageDegradeCardVM {
331
+ reason: LineageDegradeReason | 'unknown'
332
+ title: string
333
+ body: string
334
+ /** Backend `detail` passthrough (trace_failed carries the engine error). */
335
+ detail: string | null
336
+ }
337
+
338
+ /**
339
+ * Degradation reason → card copy. Reasons outside the known vocabulary
340
+ * (a newer backend) fall back to the generic card carrying the raw reason
341
+ * — the panel never invents a state.
342
+ */
343
+ export function lineageDegradeCard(
344
+ reason: string | null | undefined,
345
+ detail?: string | null,
346
+ ): LineageDegradeCardVM {
347
+ const strings = DSH_TOOLS_STRINGS.lineage.degrade
348
+ const detailOut = detail ?? null
349
+ switch (reason) {
350
+ case 'not_dsh_session':
351
+ return { reason, title: strings.notDshTitle, body: strings.notDshBody, detail: detailOut }
352
+ case 'session_query_unavailable':
353
+ return {
354
+ reason,
355
+ title: strings.queryUnavailableTitle,
356
+ body: strings.queryUnavailableBody,
357
+ detail: detailOut,
358
+ }
359
+ case 'trace_failed':
360
+ return {
361
+ reason,
362
+ title: strings.traceFailedTitle,
363
+ body: strings.traceFailedBody,
364
+ detail: detailOut,
365
+ }
366
+ default:
367
+ return {
368
+ reason: 'unknown',
369
+ title: strings.unknownTitle,
370
+ body: formatTemplate(strings.unknownBody, { reason: reason ?? '?' }),
371
+ detail: detailOut,
372
+ }
373
+ }
374
+ }
375
+
376
+ /** Discriminated render state of the lineage panel. */
377
+ export type LineageViewState =
378
+ | { kind: 'loading'; text: string }
379
+ /** Transport/HTTP failure (unlike available:false, this IS an error). */
380
+ | { kind: 'error'; text: string; detail: string | null }
381
+ | { kind: 'degraded'; card: LineageDegradeCardVM }
382
+ /** available:true but no trace body — honest empty state. */
383
+ | { kind: 'empty'; text: string }
384
+ | { kind: 'tree'; tree: LineageTreeVM }
385
+
386
+ /** Everything the lineage panel receives (mirrors LineageTree props). */
387
+ export interface LineageViewInput {
388
+ loading: boolean
389
+ /** Transport-level failure text from the integration, or null. */
390
+ error: string | null
391
+ available: boolean
392
+ /** Open vocabulary on purpose: newer backend reasons degrade gracefully. */
393
+ reason?: string | null
394
+ detail?: string | null
395
+ trace: LineageTraceVM | null
396
+ currentSessionId: string | null
397
+ }
398
+
399
+ /**
400
+ * Resolve what the lineage panel shows, in priority order:
401
+ * loading > transport error > degraded (available:false, per-reason card)
402
+ * > empty (no trace body) > the tree.
403
+ */
404
+ export function deriveLineageView(input: LineageViewInput): LineageViewState {
405
+ if (input.loading) {
406
+ return { kind: 'loading', text: DSH_TOOLS_STRINGS.lineage.loading }
407
+ }
408
+ if (input.error !== null) {
409
+ return { kind: 'error', text: DSH_TOOLS_STRINGS.lineage.error, detail: input.error }
410
+ }
411
+ if (!input.available) {
412
+ return { kind: 'degraded', card: lineageDegradeCard(input.reason, input.detail) }
413
+ }
414
+ if (input.trace === null) {
415
+ return { kind: 'empty', text: DSH_TOOLS_STRINGS.lineage.empty }
416
+ }
417
+ return { kind: 'tree', tree: buildLineageTree(input.trace, input.currentSessionId) }
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Search wire mirrors.
422
+ // ---------------------------------------------------------------------------
423
+
424
+ /** Search mode: 'filter-only' is the sessionQuery-absent degradation. */
425
+ export type SearchMode = 'full-text' | 'filter-only'
426
+
427
+ /** UnifiedSession subset the search rows render (host: fusion.ts). */
428
+ export interface SearchSessionVM {
429
+ agent: string
430
+ sessionId: string
431
+ title: string
432
+ project: string
433
+ status: string
434
+ }
435
+
436
+ /** One raw search hit (host: routes.ts handleSearch item). */
437
+ export interface SearchHitVM {
438
+ session: SearchSessionVM
439
+ /** 'full-text' | 'title' | 'project' today; open for newer backends. */
440
+ matchedBy: string
441
+ /** Best-match excerpt; full-text hits only, null otherwise. */
442
+ snippet: string | null
443
+ }
444
+
445
+ /** Body of `GET search?q=&project=&limit=` (host: routes.ts). */
446
+ export interface SearchResponseVM {
447
+ mode: SearchMode
448
+ query: string
449
+ project: string | null
450
+ items: readonly SearchHitVM[]
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Snippet highlighting (React-safe segments, no HTML injection).
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /** One run of snippet text; `highlight` runs matched the query. */
458
+ export interface SnippetSegment {
459
+ text: string
460
+ highlight: boolean
461
+ }
462
+
463
+ /**
464
+ * Split a snippet into plain/highlight segments around case-insensitive
465
+ * occurrences of the (trimmed) query. Best-effort: the engine does not
466
+ * mark its match, so a query the snippet spells differently simply yields
467
+ * one plain segment. Empty snippet → no segments.
468
+ */
469
+ export function highlightSnippet(snippet: string, query: string): SnippetSegment[] {
470
+ if (snippet === '') return []
471
+ const needle = query.trim().toLowerCase()
472
+ if (needle === '') return [{ text: snippet, highlight: false }]
473
+ const haystack = snippet.toLowerCase()
474
+ const segments: SnippetSegment[] = []
475
+ let pos = 0
476
+ for (;;) {
477
+ const hit = haystack.indexOf(needle, pos)
478
+ if (hit === -1) break
479
+ if (hit > pos) segments.push({ text: snippet.slice(pos, hit), highlight: false })
480
+ segments.push({ text: snippet.slice(hit, hit + needle.length), highlight: true })
481
+ pos = hit + needle.length
482
+ }
483
+ if (pos < snippet.length) segments.push({ text: snippet.slice(pos), highlight: false })
484
+ return segments
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // Search-result normalization.
489
+ // ---------------------------------------------------------------------------
490
+
491
+ /** Known matchedBy vocabulary plus the honest catch-all. */
492
+ export type MatchedByToken = 'full-text' | 'title' | 'project' | 'other'
493
+
494
+ const MATCHED_BY_TOKENS: readonly MatchedByToken[] = ['full-text', 'title', 'project']
495
+
496
+ /** One render-ready search result row. */
497
+ export interface SearchItemVM {
498
+ sessionId: string
499
+ agent: string
500
+ /** Raw title (may be empty). */
501
+ title: string
502
+ /** Title with the untitled fallback applied. */
503
+ titleLabel: string
504
+ project: string
505
+ status: string
506
+ matchedBy: MatchedByToken
507
+ matchedByLabel: string
508
+ /** Pre-split highlight segments; null for non-full-text matches. */
509
+ snippet: SnippetSegment[] | null
510
+ shortId: string
511
+ }
512
+
513
+ /**
514
+ * Wire hits → render rows. Snippets are split against the response's own
515
+ * echoed query; matchedBy values outside the known vocabulary map to the
516
+ * 'other' tag (label keeps a stable word, never invents a match kind).
517
+ */
518
+ export function normalizeSearchItems(response: SearchResponseVM): SearchItemVM[] {
519
+ return response.items.map((hit) => {
520
+ const matchedBy = (MATCHED_BY_TOKENS as readonly string[]).includes(hit.matchedBy)
521
+ ? (hit.matchedBy as MatchedByToken)
522
+ : 'other'
523
+ const title = hit.session.title
524
+ return {
525
+ sessionId: hit.session.sessionId,
526
+ agent: hit.session.agent,
527
+ title,
528
+ titleLabel: title.trim() === '' ? DSH_TOOLS_STRINGS.search.untitled : title,
529
+ project: hit.session.project,
530
+ status: hit.session.status,
531
+ matchedBy,
532
+ matchedByLabel: DSH_TOOLS_STRINGS.search.matchedBy[matchedBy],
533
+ snippet:
534
+ hit.snippet === null || hit.snippet === ''
535
+ ? null
536
+ : highlightSnippet(hit.snippet, response.query),
537
+ shortId: abbreviateId(hit.session.sessionId),
538
+ }
539
+ })
540
+ }
541
+
542
+ /**
543
+ * The filter-only degradation bar text; null when full-text search is up.
544
+ * Shown whenever the mode says so — the degradation is a capability fact,
545
+ * independent of whether the current query has results.
546
+ */
547
+ export function searchDegradeNotice(mode: SearchMode): string | null {
548
+ return mode === 'filter-only' ? DSH_TOOLS_STRINGS.search.filterOnlyNotice : null
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // Search panel view resolver.
553
+ // ---------------------------------------------------------------------------
554
+
555
+ /** Discriminated body state of the search panel. */
556
+ export type SearchBodyKind = 'loading' | 'error' | 'results' | 'empty' | 'idle'
557
+
558
+ export interface SearchViewState {
559
+ body: SearchBodyKind
560
+ /** Degradation bar text (independent of the body state), or null. */
561
+ notice: string | null
562
+ /** Localized text for loading/error/empty bodies, null otherwise. */
563
+ text: string | null
564
+ /** Transport error detail for the error body, null otherwise. */
565
+ detail: string | null
566
+ }
567
+
568
+ /**
569
+ * Resolve the search panel body: loading > error > results > empty (a
570
+ * submitted non-blank query with zero hits) > idle (nothing asked yet).
571
+ */
572
+ export function deriveSearchView(input: {
573
+ loading: boolean
574
+ error: string | null
575
+ mode: SearchMode
576
+ itemCount: number
577
+ query: string
578
+ }): SearchViewState {
579
+ const notice = searchDegradeNotice(input.mode)
580
+ if (input.loading) {
581
+ return { body: 'loading', notice, text: DSH_TOOLS_STRINGS.search.loading, detail: null }
582
+ }
583
+ if (input.error !== null) {
584
+ return {
585
+ body: 'error',
586
+ notice,
587
+ text: DSH_TOOLS_STRINGS.search.error,
588
+ detail: input.error,
589
+ }
590
+ }
591
+ if (input.itemCount > 0) return { body: 'results', notice, text: null, detail: null }
592
+ if (input.query.trim() !== '') {
593
+ return { body: 'empty', notice, text: DSH_TOOLS_STRINGS.search.empty, detail: null }
594
+ }
595
+ return { body: 'idle', notice, text: null, detail: null }
596
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Centralized user-facing strings for the dsh deep-query tools (design
3
+ * §5.1 view 2 dsh 会话专属区: lineage tree / provenance jump / full-text
4
+ * search; §5.3 honest degradation wording).
5
+ *
6
+ * Deliberately a MODULE-LOCAL table (task T5.4 constraint): the main
7
+ * locale table (client/locales) and test/locales.test.ts are untouched —
8
+ * S7 merges this table into the locale registry in one move. Chinese is
9
+ * the primary UI language, same posture as board/strings.ts. Templates
10
+ * use `{name}` placeholders resolved by `formatTemplate` in logic.ts.
11
+ *
12
+ * Pure data module: no imports, no logic.
13
+ *
14
+ * @module
15
+ */
16
+
17
+ export const DSH_TOOLS_STRINGS = {
18
+ /** Lineage tree panel (dsh 谱系/溯源). */
19
+ lineage: {
20
+ title: '会话谱系',
21
+ loading: '谱系加载中…',
22
+ /** Transport/HTTP failure headline (the detail carries the cause). */
23
+ error: '谱系加载失败',
24
+ /** available:true but the trace body is missing — honest empty state. */
25
+ empty: '暂无谱系数据',
26
+ /** Badge on the session the user is currently inspecting. */
27
+ currentBadge: '当前会话',
28
+ /** Badge on records live in this dsh process. */
29
+ liveBadge: '运行中',
30
+ /** Badge on records the trace knows but dsh has not persisted. */
31
+ notPersistedBadge: '未持久化',
32
+ /** Role labels for tree rows. */
33
+ role: {
34
+ ancestor: '祖先',
35
+ target: '目标',
36
+ descendant: '子会话',
37
+ },
38
+ /** Hover title for a clickable node (provenance jump). */
39
+ jumpTitle: '跳转到该会话',
40
+ /** Hover title for the highlighted current node (no jump). */
41
+ currentTitle: '正在查看该会话',
42
+ expand: '展开',
43
+ collapse: '折叠',
44
+ nodeCount: '{n} 个会话',
45
+ /** trace.complete === false with a known unresolved parent id. */
46
+ incompleteWithId: '谱系不完整:父会话 {id} 无法解析',
47
+ /** trace.complete === false without an unresolved parent id. */
48
+ incomplete: '谱系不完整:部分父链无法解析',
49
+ /** Degradation cards — available:false is data, not an error (§4.e.4). */
50
+ degrade: {
51
+ /** Client-side reason for sessions of non-dsh agents (task spec copy). */
52
+ notDshTitle: '谱系/溯源为 dsh 会话专属',
53
+ notDshBody: '当前会话来自外部 agent,dsh 的谱系与溯源能力不适用。',
54
+ /** Backend reason `session_query_unavailable`. */
55
+ queryUnavailableTitle: 'dsh 谱系服务不可用',
56
+ queryUnavailableBody:
57
+ '当前 dsh 组合未挂载 sessionQuery,谱系与溯源暂不可用。',
58
+ /** Backend reason `trace_failed`. */
59
+ traceFailedTitle: '谱系追溯失败',
60
+ traceFailedBody: 'dsh 无法解析该会话的谱系。',
61
+ /** Any reason outside the known vocabulary (never invent a state). */
62
+ unknownTitle: '谱系不可用',
63
+ unknownBody: '后端报告谱系不可用(原因: {reason})。',
64
+ },
65
+ },
66
+ /** Cross-agent search panel (dsh 全文检索 + 降级过滤). */
67
+ search: {
68
+ title: '会话检索',
69
+ placeholder: '检索会话(标题 / 项目 / 全文)',
70
+ submit: '检索',
71
+ loading: '检索中…',
72
+ error: '检索失败',
73
+ /** Query submitted, zero hits. */
74
+ empty: '没有匹配的会话',
75
+ /** mode: 'filter-only' degradation bar (task spec copy, verbatim). */
76
+ filterOnlyNotice: 'dsh 全文检索不可用,已降级为标题/项目过滤',
77
+ /** Active project-filter chip. */
78
+ projectFilter: '项目过滤: {project}',
79
+ /** matchedBy tags on result rows. */
80
+ matchedBy: {
81
+ 'full-text': '全文',
82
+ title: '标题',
83
+ project: '项目',
84
+ other: '其他',
85
+ },
86
+ untitled: '(无标题)',
87
+ },
88
+ } as const
89
+
90
+ export type DshToolsStrings = typeof DSH_TOOLS_STRINGS