@shendeguize/dsh-agent-sidecar 0.1.0 → 0.2.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 (58) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +4708 -2932
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +255 -48
  8. package/src/client/board/board.module.css +113 -92
  9. package/src/client/board/logic.ts +99 -21
  10. package/src/client/board/project-view-logic.ts +27 -34
  11. package/src/client/board/project-view.module.css +46 -83
  12. package/src/client/board/project-view.tsx +50 -8
  13. package/src/client/board/strings.ts +70 -85
  14. package/src/client/commands.ts +30 -15
  15. package/src/client/controller.ts +27 -7
  16. package/src/client/detail/SessionDetail.tsx +234 -37
  17. package/src/client/detail/detail.module.css +100 -153
  18. package/src/client/detail/logic.ts +220 -29
  19. package/src/client/detail/strings.ts +66 -77
  20. package/src/client/detail-glue.ts +33 -0
  21. package/src/client/detail-view.module.css +43 -35
  22. package/src/client/detail-view.tsx +138 -118
  23. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  24. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  25. package/src/client/dsh-tools/dsh-tools.module.css +53 -140
  26. package/src/client/dsh-tools/strings.ts +42 -77
  27. package/src/client/index.ts +285 -124
  28. package/src/client/inject/InjectPanel.tsx +81 -61
  29. package/src/client/inject/inject.module.css +97 -197
  30. package/src/client/inject/logic.ts +38 -0
  31. package/src/client/lifecycle/handoff.ts +83 -0
  32. package/src/client/locales/en.ts +91 -4
  33. package/src/client/locales/host.ts +131 -0
  34. package/src/client/locales/index.ts +196 -8
  35. package/src/client/locales/react.ts +10 -0
  36. package/src/client/locales/view.ts +38 -0
  37. package/src/client/locales/zh.ts +200 -125
  38. package/src/client/mount.tsx +75 -41
  39. package/src/client/navigation/CenterOverlay.tsx +40 -0
  40. package/src/client/navigation/center-overlay.module.css +51 -0
  41. package/src/client/navigation/center.ts +45 -0
  42. package/src/client/navigation/modal-isolation.ts +152 -0
  43. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  44. package/src/client/navigation/sidebar-entry.module.css +73 -0
  45. package/src/client/navigation/sidebar-entry.ts +309 -0
  46. package/src/client/primitives/StaticPill.tsx +21 -0
  47. package/src/client/settings-card.module.css +35 -119
  48. package/src/client/settings-card.tsx +31 -153
  49. package/src/client/settings-fields.tsx +131 -0
  50. package/src/client/sidebar/SidebarTab.tsx +156 -0
  51. package/src/client/sidebar/model.ts +65 -0
  52. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  53. package/src/client/sidebar-tab.tsx +46 -320
  54. package/src/client/theme/agsc.module.css +31 -0
  55. package/src/client/theme/parts.ts +56 -0
  56. package/src/client/ui-integration.ts +86 -0
  57. package/src/client/widget.tsx +42 -16
  58. package/src/client/inject/overlay.module.css +0 -22
@@ -109,6 +109,30 @@ export function formatRelativeTime(thenMs: number, nowMs: number): string {
109
109
  return formatTemplate(DETAIL_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
110
110
  }
111
111
 
112
+ function pad2(n: number): string {
113
+ return n < 10 ? `0${n}` : String(n)
114
+ }
115
+
116
+ /**
117
+ * Absolute short timestamp for the time column (UX-13): the same local
118
+ * calendar day renders「HH:mm」, anything older (or a different day)
119
+ * renders「MM-DD HH:mm」— so a column of same-age rows stays tellable
120
+ * apart, unlike the coarse relative buckets. The format rule is
121
+ * copy-implemented to match the board column (no cross-surface import);
122
+ * the full ISO timestamp stays in the hover title.
123
+ */
124
+ export function formatEventTime(thenMs: number, nowMs: number): string {
125
+ if (!Number.isFinite(thenMs) || !Number.isFinite(nowMs)) return ''
126
+ const then = new Date(thenMs)
127
+ const now = new Date(nowMs)
128
+ const hhmm = `${pad2(then.getHours())}:${pad2(then.getMinutes())}`
129
+ const sameDay =
130
+ then.getFullYear() === now.getFullYear() &&
131
+ then.getMonth() === now.getMonth() &&
132
+ then.getDate() === now.getDate()
133
+ return sameDay ? hhmm : `${pad2(then.getMonth() + 1)}-${pad2(then.getDate())} ${hhmm}`
134
+ }
135
+
112
136
  // ---------------------------------------------------------------------------
113
137
  // Event-kind classification (icon + label).
114
138
  // ---------------------------------------------------------------------------
@@ -137,26 +161,43 @@ const KIND_GLYPHS: Record<TimelineKindToken, string> = {
137
161
  other: '•',
138
162
  }
139
163
 
164
+ /** One path segment → family token, or null when it names no family. */
165
+ function segmentToken(segment: string): TimelineKindToken | null {
166
+ if (segment === 'user') return 'user'
167
+ if (segment === 'assistant') return 'assistant'
168
+ if (segment === 'thinking' || segment === 'reasoning') return 'thinking'
169
+ if (segment === 'tool_call' || segment === 'tool-call' || segment === 'toolcall') {
170
+ return 'toolCall'
171
+ }
172
+ if (segment === 'tool_result' || segment === 'tool-result' || segment === 'toolresult') {
173
+ return 'toolResult'
174
+ }
175
+ if (segment.startsWith('turn_') || segment === 'turn') return 'turn'
176
+ if (segment.startsWith('step_') || segment === 'step') return 'step'
177
+ if (segment === 'error') return 'error'
178
+ return null
179
+ }
180
+
140
181
  /**
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'.
182
+ * Map a raw event kind onto the glyph/label vocabulary. Covers the sidecar
183
+ * normalized kinds — user/assistant/thinking/tool_call/tool_result plus
184
+ * the turn_ and step_ prefixes (sidecar/model.py) — and dsh native
185
+ * slash-path types in BOTH orders: `message/user` style (family last) and
186
+ * the observed `user/message` / `assistant/chunk` / `turn/start` style
187
+ * (family first; live-data fact, UX-03 — without it the conversation
188
+ * filter would misfile real dsh user messages as protocol noise). The
189
+ * last segment stays authoritative; the first is only a fallback.
190
+ * Everything else is honestly 'other'.
146
191
  */
147
192
  export function classifyKind(kind: string): TimelineKindToken {
148
193
  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'
194
+ const segments = k.split('/')
195
+ const last = segmentToken(segments[segments.length - 1] ?? k)
196
+ if (last !== null) return last
197
+ if (segments.length > 1) {
198
+ const first = segmentToken(segments[0] ?? '')
199
+ if (first !== null) return first
156
200
  }
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
201
  return 'other'
161
202
  }
162
203
 
@@ -500,18 +541,26 @@ export function applyListenPage(vm: TimelineVM, page: TimelinePageWire): Timelin
500
541
  // Gap detection + row derivation.
501
542
  // ---------------------------------------------------------------------------
502
543
 
503
- /** One rendered timeline row: a real event or an honesty gap marker. */
544
+ /** One rendered event row (extracted so aggregation can carry members). */
545
+ export interface TimelineEventRowVM {
546
+ type: 'event'
547
+ key: string
548
+ entry: TimelineEntryVM
549
+ /** Absolute short time label (see {@link formatEventTime}). */
550
+ timeLabel: string
551
+ /** Hover title: ISO timestamp + raw kind + origin (+ seq) + relative age. */
552
+ hoverTitle: string
553
+ /** True when this entry arrived in the latest listen merge. */
554
+ isNew: boolean
555
+ }
556
+
557
+ /**
558
+ * One rendered timeline row: a real event, an honesty gap marker, or an
559
+ * aggregated run of adjacent empty streaming chunks (UX-03 — the members
560
+ * are carried verbatim so the view can expand the run without data loss).
561
+ */
504
562
  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
- }
563
+ | TimelineEventRowVM
515
564
  | {
516
565
  type: 'gap'
517
566
  key: string
@@ -519,6 +568,22 @@ export type TimelineRowVM =
519
568
  missingCount: number
520
569
  label: string
521
570
  }
571
+ | {
572
+ type: 'chunks'
573
+ key: string
574
+ /** Raw wire kind shared by every member of the run. */
575
+ kindRaw: string
576
+ count: number
577
+ /** 「N 个流式分块」 */
578
+ label: string
579
+ /** Time label of the newest member. */
580
+ timeLabel: string
581
+ hoverTitle: string
582
+ /** True when any member arrived in the latest listen merge. */
583
+ isNew: boolean
584
+ /** The collapsed rows, verbatim, for lossless expansion. */
585
+ members: TimelineEventRowVM[]
586
+ }
522
587
 
523
588
  function isoOrEmpty(ts: number): string {
524
589
  if (!Number.isFinite(ts)) return ''
@@ -529,11 +594,12 @@ function isoOrEmpty(ts: number): string {
529
594
  }
530
595
  }
531
596
 
532
- function eventHoverTitle(entry: TimelineEntryVM): string {
597
+ function eventHoverTitle(entry: TimelineEntryVM, nowMs: number): string {
533
598
  const parts = [isoOrEmpty(entry.ts), entry.kindRaw, entry.origin]
534
599
  if (entry.seq !== null) {
535
600
  parts.push(formatTemplate(DETAIL_STRINGS.timeline.seq, { n: entry.seq }))
536
601
  }
602
+ parts.push(formatRelativeTime(entry.ts, nowMs))
537
603
  return parts.filter((p) => p !== '').join(' · ')
538
604
  }
539
605
 
@@ -572,14 +638,139 @@ export function buildTimelineRows(vm: TimelineVM, nowMs: number): TimelineRowVM[
572
638
  type: 'event',
573
639
  key: entry.key,
574
640
  entry,
575
- relativeTime: formatRelativeTime(entry.ts, nowMs),
576
- hoverTitle: eventHoverTitle(entry),
641
+ timeLabel: formatEventTime(entry.ts, nowMs),
642
+ hoverTitle: eventHoverTitle(entry, nowMs),
577
643
  isNew: newKeys.has(entry.key),
578
644
  })
579
645
  }
580
646
  return rows
581
647
  }
582
648
 
649
+ // ---------------------------------------------------------------------------
650
+ // Kind filter + chunk-run aggregation (UX-03: signal over protocol noise).
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /** Kind-filter modes; pure UI state owned by the component. */
654
+ export type TimelineFilterMode = 'conversation' | 'all'
655
+
656
+ /** The kinds that count as conversation (review UX-03 vocabulary). */
657
+ export const CONVERSATION_KINDS: ReadonlySet<TimelineKindToken> = new Set([
658
+ 'user',
659
+ 'assistant',
660
+ 'error',
661
+ ])
662
+
663
+ export interface FilteredRows {
664
+ rows: TimelineRowVM[]
665
+ /** Exactly how many event rows the filter removed (honest count). */
666
+ hiddenCount: number
667
+ }
668
+
669
+ /**
670
+ * Kind filter over derived rows: 'conversation' keeps user/assistant/error
671
+ * events only; 'all' passes everything through. Gap markers ALWAYS stay —
672
+ * honesty rows are not noise and hiding them could fake a clean timeline.
673
+ * Runs BEFORE {@link aggregateChunkRows} (gaps are detected on the full
674
+ * entry list upstream, so filtering can never fabricate a gap).
675
+ */
676
+ export function filterTimelineRows(
677
+ rows: readonly TimelineRowVM[],
678
+ mode: TimelineFilterMode,
679
+ ): FilteredRows {
680
+ if (mode === 'all') return { rows: [...rows], hiddenCount: 0 }
681
+ const out: TimelineRowVM[] = []
682
+ let hiddenCount = 0
683
+ for (const row of rows) {
684
+ if (row.type === 'event' && !CONVERSATION_KINDS.has(row.entry.kind)) {
685
+ hiddenCount += 1
686
+ continue
687
+ }
688
+ out.push(row)
689
+ }
690
+ return { rows: out, hiddenCount }
691
+ }
692
+
693
+ /**
694
+ * True for a protocol streaming-chunk entry: an empty one-line summary and
695
+ * a chunk-flavored kind (`assistant/chunk` style, matched on the last
696
+ * slash segment). These are the rows that drowned real conversation in
697
+ * the walkthrough (18 of 38 rows, review UX-03).
698
+ */
699
+ export function isStreamChunkEntry(entry: TimelineEntryVM): boolean {
700
+ if (entry.summary !== '') return false
701
+ const k = entry.kindRaw.trim().toLowerCase()
702
+ const last = k.includes('/') ? (k.split('/').pop() ?? k) : k
703
+ return last === 'chunk' || last.endsWith('_chunk') || last.endsWith('-chunk')
704
+ }
705
+
706
+ /**
707
+ * Collapse each maximal run of ≥2 adjacent same-kind streaming-chunk rows
708
+ * into one 'chunks' row carrying the members verbatim (lossless — the view
709
+ * offers 展开). Gap markers and any non-chunk row break a run, so the
710
+ * aggregation can never paper over a seq discontinuity. Single chunks stay
711
+ * as plain rows (a 1-run header would add noise, not remove it).
712
+ */
713
+ export function aggregateChunkRows(rows: readonly TimelineRowVM[]): TimelineRowVM[] {
714
+ const out: TimelineRowVM[] = []
715
+ let run: TimelineEventRowVM[] = []
716
+
717
+ const flush = (): void => {
718
+ if (run.length >= 2) {
719
+ const first = run[0]!
720
+ const last = run[run.length - 1]!
721
+ out.push({
722
+ type: 'chunks',
723
+ key: `chunks:${first.key}`,
724
+ kindRaw: first.entry.kindRaw,
725
+ count: run.length,
726
+ label: formatTemplate(DETAIL_STRINGS.timeline.chunkRun, { n: run.length }),
727
+ timeLabel: last.timeLabel,
728
+ hoverTitle: `${first.entry.kindRaw} ×${run.length}`,
729
+ isNew: run.some((r) => r.isNew),
730
+ members: run,
731
+ })
732
+ } else {
733
+ out.push(...run)
734
+ }
735
+ run = []
736
+ }
737
+
738
+ for (const row of rows) {
739
+ const chunk = row.type === 'event' && isStreamChunkEntry(row.entry)
740
+ if (chunk) {
741
+ const sameKind = run.length === 0 || run[run.length - 1]!.entry.kindRaw === row.entry.kindRaw
742
+ if (!sameKind) flush()
743
+ run.push(row as TimelineEventRowVM)
744
+ continue
745
+ }
746
+ flush()
747
+ out.push(row)
748
+ }
749
+ flush()
750
+ return out
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // Viewport landing rule (UX-04: open on the newest events).
755
+ // ---------------------------------------------------------------------------
756
+
757
+ /**
758
+ * Whether the view should pin its scroll position to the newest rows:
759
+ * on the first non-empty render (initial landing — understanding the
760
+ * current context needs the latest events, review UX-04), and on every
761
+ * append while listen mode is on. Loading older history must never yank
762
+ * the viewport (`positioned` stays true after the first landing).
763
+ */
764
+ export function shouldStickToLatest(input: {
765
+ entryCount: number
766
+ /** True once the initial landing already happened. */
767
+ positioned: boolean
768
+ listening: boolean
769
+ }): boolean {
770
+ if (input.entryCount === 0) return false
771
+ return !input.positioned || input.listening
772
+ }
773
+
583
774
  // ---------------------------------------------------------------------------
584
775
  // Segmented rendering bound (perf stopgap; no full virtualization, see task
585
776
  // report — page sizes already bound growth, this bounds pathological cases).
@@ -1,98 +1,87 @@
1
- /**
2
- * Centralized user-facing strings for the session-detail view (design §5.1
3
- * view 2, §5.3 honesty wording). Deliberately a module-local table — NOT
4
- * part of the main locale registry (locales/zh.ts / locales/en.ts): the
5
- * locale tables are owned by parallel tasks and the S7 integration wave
6
- * folds this table in centrally. Same posture as board/strings.ts.
7
- *
8
- * Chinese is the primary UI language. Templates use `{name}` placeholders
9
- * resolved by `formatTemplate` in `logic.ts`.
10
- *
11
- * Pure data module: no imports, no logic.
12
- *
13
- * @module
14
- */
1
+ import { createLocaleView } from '../locales/view.ts'
15
2
 
16
- export const DETAIL_STRINGS = {
17
- /** Header block (session meta + controls). */
3
+ /** Dynamic session-detail vocabulary; templates are interpolated by `formatTemplate`. */
4
+ export const DETAIL_STRINGS = createLocaleView({
18
5
  header: {
19
- close: '返回看板',
20
- listenOn: '监听中',
21
- listenOff: '监听',
22
- listenHint: '开启后新事件将实时追加并高亮',
23
- untitled: '(无标题)',
24
- unknownProject: '未知项目',
25
- /** Design §5.3 / SKILL.md wording: statuses are inferred observations. */
26
- observedDisclaimer: '状态为从持久化数据推断的观察值,可能滞后',
6
+ close: 'detail.header.close',
7
+ listenOn: 'detail.header.listenOn',
8
+ listenOff: 'detail.header.listenOff',
9
+ listenHint: 'detail.header.listenHint',
10
+ refresh: 'detail.header.refresh',
11
+ refreshing: 'detail.header.refreshing',
12
+ refreshHint: 'detail.header.refreshHint',
13
+ copyIdTitle: 'detail.header.copyIdTitle',
14
+ copied: 'detail.header.copied',
15
+ untitled: 'detail.header.untitled',
16
+ unknownProject: 'detail.header.unknownProject',
17
+ observedDisclaimer: 'detail.header.observedDisclaimer',
27
18
  },
28
- /** Session status badge labels (observed values, see disclaimer). */
29
19
  status: {
30
- working: '工作中',
31
- waiting: '等待中',
32
- idle: '空闲',
33
- dead: '已结束',
34
- unknown: '未知',
20
+ working: 'detail.status.working',
21
+ waiting: 'detail.status.waiting',
22
+ idle: 'detail.status.idle',
23
+ dead: 'detail.status.dead',
24
+ unknown: 'detail.status.unknown',
35
25
  },
36
- /** Timeline source badges (provenance of the merged page, honest labels). */
37
26
  sources: {
38
- title: '数据来源',
39
- dshLive: 'dsh 实时',
40
- dshCold: 'dsh 冷读',
41
- sidecarReplay: 'sidecar 重放',
42
- sidecarBuffer: 'sidecar 缓冲',
43
- none: '来源未知',
27
+ title: 'detail.sources.title',
28
+ dshLive: 'detail.sources.dshLive',
29
+ dshCold: 'detail.sources.dshCold',
30
+ sidecarReplay: 'detail.sources.sidecarReplay',
31
+ sidecarBuffer: 'detail.sources.sidecarBuffer',
32
+ none: 'detail.sources.none',
44
33
  },
45
- /** Normalized event-kind labels; unknown kinds keep their raw text. */
46
34
  kind: {
47
- user: '用户消息',
48
- assistant: '助手回复',
49
- thinking: '思考',
50
- toolCall: '工具调用',
51
- toolResult: '工具结果',
52
- turn: '回合',
53
- step: '步骤',
54
- error: '错误',
55
- other: '事件',
35
+ user: 'detail.kind.user',
36
+ assistant: 'detail.kind.assistant',
37
+ thinking: 'detail.kind.thinking',
38
+ toolCall: 'detail.kind.toolCall',
39
+ toolResult: 'detail.kind.toolResult',
40
+ turn: 'detail.kind.turn',
41
+ step: 'detail.kind.step',
42
+ error: 'detail.kind.error',
43
+ other: 'detail.kind.other',
56
44
  },
57
- /** Seq-discontinuity marker row (design §4.b.3 honest presentation). */
58
45
  gap: {
59
- label: '缺口:可能有 {n} 条事件未捕获(256 队列上限或未持久化)',
46
+ label: 'detail.gap.label',
47
+ },
48
+ filter: {
49
+ conversation: 'detail.filter.conversation',
50
+ all: 'detail.filter.all',
51
+ hiddenNotice: 'detail.filter.hiddenNotice',
60
52
  },
61
- /** Timeline list chrome. */
62
53
  timeline: {
63
- loadMore: '加载更多历史',
64
- loadingMore: '加载中…',
65
- noMore: '已到时间线起点',
66
- expand: '展开',
67
- collapse: '收起',
68
- newBadge: '',
69
- seq: 'seq {n}',
70
- hiddenNotice: '为保持流畅,较早的 {n} 条已折叠',
71
- showAll: '全部显示',
54
+ loadMore: 'detail.timeline.loadMore',
55
+ loadingMore: 'detail.timeline.loadingMore',
56
+ noMore: 'detail.timeline.noMore',
57
+ expand: 'detail.timeline.expand',
58
+ collapse: 'detail.timeline.collapse',
59
+ newBadge: 'detail.timeline.newBadge',
60
+ seq: 'detail.timeline.seq',
61
+ hiddenNotice: 'detail.timeline.hiddenNotice',
62
+ showAll: 'detail.timeline.showAll',
63
+ chunkRun: 'detail.timeline.chunkRun',
72
64
  },
73
- /** Loading / empty / error body states. */
74
65
  states: {
75
- loadingTitle: '正在加载时间线…',
76
- emptyTitle: '暂无事件',
77
- emptyHint: '该会话还没有可展示的规范化事件。',
78
- errorTitle: '时间线加载失败',
79
- /** Fallback template when the reason code has no friendly mapping. */
80
- errorFallback: '错误码:{reason}',
66
+ loadingTitle: 'detail.states.loadingTitle',
67
+ emptyTitle: 'detail.states.emptyTitle',
68
+ emptyHint: 'detail.states.emptyHint',
69
+ errorTitle: 'detail.states.errorTitle',
70
+ errorFallback: 'detail.states.errorFallback',
81
71
  errors: {
82
- session_not_found: '会话不存在或已不可见',
83
- invalid_cursor: '分页游标无效,请重新打开详情',
84
- fusion_not_wired: '当前 host 未启用时间线能力',
85
- network_error: '网络错误,无法联系 dsh host',
86
- request_timeout: '请求超时',
72
+ session_not_found: 'detail.states.errors.session_not_found',
73
+ invalid_cursor: 'detail.states.errors.invalid_cursor',
74
+ fusion_not_wired: 'detail.states.errors.fusion_not_wired',
75
+ network_error: 'detail.states.errors.network_error',
76
+ request_timeout: 'detail.states.errors.request_timeout',
87
77
  },
88
78
  },
89
- /** Relative time templates (coarse buckets, matches the board wording). */
90
79
  time: {
91
- justNow: '刚刚',
92
- minutesAgo: '{n} 分钟前',
93
- hoursAgo: '{n} 小时前',
94
- daysAgo: '{n} 天前',
80
+ justNow: 'detail.time.justNow',
81
+ minutesAgo: 'detail.time.minutesAgo',
82
+ hoursAgo: 'detail.time.hoursAgo',
83
+ daysAgo: 'detail.time.daysAgo',
95
84
  },
96
- } as const
85
+ } as const)
97
86
 
98
87
  export type DetailStrings = typeof DETAIL_STRINGS
@@ -83,6 +83,8 @@ export interface DetailGlueState {
83
83
  error: string | null
84
84
  hasMore: boolean
85
85
  listening: boolean
86
+ /** True while a manual newest-window refresh is in flight (UX-07). */
87
+ refreshing: boolean
86
88
  /** True once the initial load succeeded (timeline usable). */
87
89
  ready: boolean
88
90
  lineage: LineageSliceState
@@ -137,6 +139,7 @@ export class DetailStore {
137
139
  private paging = false
138
140
  private listenInFlight = false
139
141
  private listenQueued = false
142
+ private refreshInFlight = false
140
143
  private lineageStarted = false
141
144
 
142
145
  constructor(sessionId: string, options: DetailStoreOptions = {}) {
@@ -152,6 +155,7 @@ export class DetailStore {
152
155
  error: null,
153
156
  hasMore: false,
154
157
  listening: false,
158
+ refreshing: false,
155
159
  ready: false,
156
160
  lineage: INITIAL_LINEAGE,
157
161
  }
@@ -241,6 +245,35 @@ export class DetailStore {
241
245
  if (listening) this.scheduleListenRefetch()
242
246
  }
243
247
 
248
+ /**
249
+ * Manual newest-window refetch with visible feedback (UX-07), also fired
250
+ * once after a delivered injection (UX-05 observation loop). Unlike the
251
+ * silent best-effort listen refetch, it reports in-flight state and
252
+ * surfaces a failure reason (rendered as the inline banner). Appended
253
+ * entries get the listen-merge highlight. Coalesced: at most one manual
254
+ * refresh in flight, extra calls are dropped.
255
+ */
256
+ async refreshNewest(): Promise<void> {
257
+ if (this.disposed || !this.state.ready || this.refreshInFlight) return
258
+ this.refreshInFlight = true
259
+ this.setState({ refreshing: true, error: null })
260
+ try {
261
+ const page = await this.fetchPageFn(this.state.sessionId, {
262
+ ...(this.listenLimit !== undefined ? { limit: this.listenLimit } : {}),
263
+ })
264
+ if (this.disposed) return
265
+ this.setState({
266
+ timeline: applyListenPage(this.state.timeline, page),
267
+ refreshing: false,
268
+ })
269
+ } catch (err) {
270
+ if (this.disposed) return
271
+ this.setState({ refreshing: false, error: reasonOf(err) })
272
+ } finally {
273
+ this.refreshInFlight = false
274
+ }
275
+ }
276
+
244
277
  /**
245
278
  * SSE `state` frame hook (one call per controller notification). Refreshes
246
279
  * the header from the live board card when given, and in listen mode
@@ -1,11 +1,3 @@
1
- /*
2
- * Detail-view container + board-tab view-switcher chrome (T5.10b, design
3
- * §5.1): every color rides a --dsw-alias-* design token with a neutral
4
- * fallback. Same posture as board.module.css / dsh-tools.module.css.
5
- */
6
-
7
- /* ------------------------------------------------ board-tab view switcher */
8
-
9
1
  .switcherBar {
10
2
  display: flex;
11
3
  align-items: center;
@@ -19,28 +11,29 @@
19
11
  border-radius: 999px;
20
12
  border: 1px solid transparent;
21
13
  background: transparent;
22
- color: var(--dsw-alias-label-secondary, #57606a);
14
+ color: var(--dsw-alias-label-secondary);
23
15
  cursor: pointer;
24
16
  }
25
17
 
26
18
  .switcherButton:hover {
27
- background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
19
+ background: var(--dsw-alias-bg-layer-1);
28
20
  }
29
21
 
30
22
  .switcherButton[data-active='true'] {
31
23
  font-weight: 600;
32
- color: var(--dsw-alias-label-primary, #1f2328);
33
- background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
34
- border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
24
+ color: var(--dsw-alias-label-primary);
25
+ background: var(--dsw-alias-bg-layer-2);
26
+ border-color: var(--dsw-alias-border-l2);
35
27
  }
36
28
 
37
- /* --------------------------------------------------- detail-view chrome */
38
-
39
29
  .detailRoot {
40
30
  display: flex;
41
31
  flex-direction: column;
42
32
  gap: 12px;
43
33
  min-width: 0;
34
+ height: 100%;
35
+ overflow-y: auto;
36
+ box-sizing: border-box;
44
37
  padding: 12px;
45
38
  }
46
39
 
@@ -48,32 +41,47 @@
48
41
  display: flex;
49
42
  align-items: center;
50
43
  gap: 8px;
44
+ flex-wrap: wrap;
51
45
  }
52
46
 
53
- .actionButton {
54
- font-size: 12px;
55
- padding: 3px 12px;
56
- border-radius: 6px;
57
- border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
58
- background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
59
- color: var(--dsw-alias-label-primary, #1f2328);
60
- cursor: pointer;
61
- }
62
-
63
- .actionButton:hover {
64
- background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
65
- }
66
-
67
- .actionButton:disabled {
68
- opacity: 0.5;
69
- cursor: not-allowed;
47
+ .analysisDisabledReason {
48
+ max-width: 360px;
49
+ font-size: 11px;
50
+ line-height: 16px;
51
+ color: var(--agsc-fg-secondary);
70
52
  }
71
53
 
72
- /* dsh deep-query tools section under the timeline. */
73
54
  .toolsSection {
74
55
  display: flex;
75
56
  flex-direction: column;
76
57
  gap: 16px;
77
- border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
78
- padding-top: 12px;
58
+ border: 1px solid var(--agsc-border);
59
+ border-radius: var(--agsc-radius-control);
60
+ padding: 8px 12px;
61
+ }
62
+
63
+ .toolsToggle {
64
+ align-self: flex-start;
65
+ }
66
+
67
+ .toolsToggleGlyph {
68
+ flex: none;
69
+ font-size: 10px;
70
+ color: var(--agsc-fg-dimmed);
71
+ }
72
+
73
+ .injectDialog {
74
+ gap: 0;
75
+ width: min(560px, 100%);
76
+ max-height: min(85vh, 720px);
77
+ padding: 0;
78
+ overflow: auto;
79
+ }
80
+
81
+ .injectDialog > [data-dsh-part='inject-panel'] {
82
+ width: 100%;
83
+ max-width: none;
84
+ border: 0;
85
+ border-radius: inherit;
86
+ background: transparent;
79
87
  }