@shendeguize/dsh-agent-sidecar 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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).
@@ -20,6 +20,11 @@ export const DETAIL_STRINGS = {
20
20
  listenOn: '监听中',
21
21
  listenOff: '监听',
22
22
  listenHint: '开启后新事件将实时追加并高亮',
23
+ refresh: '刷新',
24
+ refreshing: '刷新中…',
25
+ refreshHint: '手动拉取最新时间线窗口',
26
+ copyIdTitle: '点击复制会话 ID',
27
+ copied: '已复制',
23
28
  untitled: '(无标题)',
24
29
  unknownProject: '未知项目',
25
30
  /** Design §5.3 / SKILL.md wording: statuses are inferred observations. */
@@ -58,6 +63,12 @@ export const DETAIL_STRINGS = {
58
63
  gap: {
59
64
  label: '缺口:可能有 {n} 条事件未捕获(256 队列上限或未持久化)',
60
65
  },
66
+ /** Kind filter chips (UX-03): protocol noise hidden by default, honestly counted. */
67
+ filter: {
68
+ conversation: '只看对话',
69
+ all: '全部事件',
70
+ hiddenNotice: '已隐藏 {n} 条协议事件',
71
+ },
61
72
  /** Timeline list chrome. */
62
73
  timeline: {
63
74
  loadMore: '加载更多历史',
@@ -69,6 +80,8 @@ export const DETAIL_STRINGS = {
69
80
  seq: 'seq {n}',
70
81
  hiddenNotice: '为保持流畅,较早的 {n} 条已折叠',
71
82
  showAll: '全部显示',
83
+ /** Aggregated row for an adjacent run of empty streaming chunks (UX-03). */
84
+ chunkRun: '{n} 个流式分块',
72
85
  },
73
86
  /** Loading / empty / error body states. */
74
87
  states: {
@@ -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
@@ -41,6 +41,12 @@
41
41
  flex-direction: column;
42
42
  gap: 12px;
43
43
  min-width: 0;
44
+ /* Own the scroll like the board/project roots do (UX-12): without a
45
+ * bounded height the inner timeline's overflow-y can never engage, and
46
+ * the UX-04 landing / listen-mode tail pinning would silently no-op. */
47
+ height: 100%;
48
+ overflow-y: auto;
49
+ box-sizing: border-box;
44
50
  padding: 12px;
45
51
  }
46
52
 
@@ -69,11 +75,38 @@
69
75
  cursor: not-allowed;
70
76
  }
71
77
 
72
- /* dsh deep-query tools section under the timeline. */
78
+ /* dsh deep-query tools: collapsible segment between the actions row and
79
+ * the timeline (UX-09 — previously buried below a possibly very long
80
+ * list, outside the viewport on long sessions). */
73
81
  .toolsSection {
74
82
  display: flex;
75
83
  flex-direction: column;
76
84
  gap: 16px;
77
- border-top: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.08));
78
- padding-top: 12px;
85
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
86
+ border-radius: 8px;
87
+ padding: 8px 12px;
88
+ }
89
+
90
+ .toolsToggle {
91
+ align-self: flex-start;
92
+ display: inline-flex;
93
+ align-items: center;
94
+ gap: 6px;
95
+ font-size: 12px;
96
+ font-family: inherit;
97
+ color: var(--dsw-alias-label-secondary, #57606a);
98
+ background: transparent;
99
+ border: none;
100
+ padding: 0;
101
+ cursor: pointer;
102
+ }
103
+
104
+ .toolsToggle:hover {
105
+ color: var(--dsw-alias-label-primary, #1f2328);
106
+ }
107
+
108
+ .toolsToggleGlyph {
109
+ flex: none;
110
+ font-size: 10px;
111
+ color: var(--dsw-alias-label-tertiary, #6e7781);
79
112
  }
@@ -34,7 +34,7 @@ import { SearchStore } from './search-glue.ts'
34
34
  import { AnalysisStore } from './analysis-glue.ts'
35
35
  import { ProjectsStore } from './project-glue.ts'
36
36
  import type { SidecarController } from './controller.ts'
37
- import type { InjectMode } from './inject/logic.ts'
37
+ import { isDeliveredResult, type InjectMode } from './inject/logic.ts'
38
38
  import type { InjectActions } from './inject-glue.ts'
39
39
  import { t } from './locales/index.ts'
40
40
  import css from './detail-view.module.css'
@@ -97,6 +97,7 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
97
97
  const [analysisStore] = useState(() => integration.createAnalysisStore())
98
98
  const [injectOpen, setInjectOpen] = useState(false)
99
99
  const [analysisOpen, setAnalysisOpen] = useState(false)
100
+ const [toolsOpen, setToolsOpen] = useState(false)
100
101
 
101
102
  useEffect(() => {
102
103
  void detailStore.open()
@@ -130,6 +131,30 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
130
131
  const closeInject = (): void => { setInjectOpen(false) }
131
132
  const title = detail.header.title.trim()
132
133
 
134
+ // UX-05 observation loop, part 1: a delivered execute refetches the
135
+ // newest timeline window at once, so closing the panel never lands on a
136
+ // stale pre-injection timeline. Wraps (not replaces) the integration
137
+ // callback — the two-phase flow and the owner's onDelivered hook (board
138
+ // snapshot refresh) stay untouched.
139
+ const injectActions: InjectActions | undefined =
140
+ injectIntegration === undefined
141
+ ? undefined
142
+ : {
143
+ onPrepare: injectIntegration.actions.onPrepare,
144
+ onExecute: async (req) => {
145
+ const result = await injectIntegration.actions.onExecute(req)
146
+ if (isDeliveredResult(result)) void detailStore.refreshNewest()
147
+ return result
148
+ },
149
+ }
150
+
151
+ // UX-05 part 2: the delivered result page offers「开启监听观察反应」—
152
+ // flip listen mode on (if off) and hand the view back to the timeline.
153
+ const observeReaction = (): void => {
154
+ if (!detailStore.getState().listening) detailStore.toggleListen()
155
+ closeInject()
156
+ }
157
+
133
158
  return (
134
159
  <div className={css['detailRoot']} data-testid="agent-sidecar-detail-view">
135
160
  <div className={css['actionsRow']}>
@@ -155,6 +180,50 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
155
180
  </button>
156
181
  </div>
157
182
 
183
+ {/* Collapsible dsh deep-query tools ABOVE the timeline (UX-09):
184
+ discoverable without scrolling past a long event list. */}
185
+ <div className={css['toolsSection']} data-testid="agent-sidecar-detail-tools">
186
+ <button
187
+ type="button"
188
+ className={css['toolsToggle']}
189
+ aria-expanded={toolsOpen}
190
+ onClick={() => { setToolsOpen((open) => !open) }}
191
+ >
192
+ <span className={css['toolsToggleGlyph']} aria-hidden>
193
+ {toolsOpen ? '▾' : '▸'}
194
+ </span>
195
+ {t('detail.tools.title')}
196
+ <span className={css['toolsToggleGlyph']}>
197
+ {toolsOpen ? t('detail.tools.hide') : t('detail.tools.show')}
198
+ </span>
199
+ </button>
200
+ {toolsOpen && (
201
+ <>
202
+ <LineageTree
203
+ trace={detail.lineage.trace}
204
+ available={detail.lineage.available}
205
+ reason={detail.lineage.reason}
206
+ detail={detail.lineage.detail}
207
+ currentSessionId={sessionId}
208
+ onSelectSession={props.onSelectSession}
209
+ loading={detail.lineage.loading}
210
+ error={detail.lineage.error}
211
+ />
212
+ <SearchPanel
213
+ query={search.query}
214
+ project={search.project}
215
+ mode={search.mode}
216
+ items={search.items}
217
+ loading={search.loading}
218
+ error={search.error}
219
+ onQueryChange={(query) => { searchStore.setQuery(query) }}
220
+ onSubmit={() => { void searchStore.submit() }}
221
+ onSelectSession={props.onSelectSession}
222
+ />
223
+ </>
224
+ )}
225
+ </div>
226
+
158
227
  <SessionDetail
159
228
  sessionId={sessionId}
160
229
  header={detail.header}
@@ -163,8 +232,10 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
163
232
  error={detail.error}
164
233
  hasMore={detail.hasMore}
165
234
  listening={detail.listening}
235
+ refreshing={detail.refreshing}
166
236
  onLoadMore={() => { void detailStore.loadMore() }}
167
237
  onToggleListen={() => { detailStore.toggleListen() }}
238
+ onRefresh={() => { void detailStore.refreshNewest() }}
168
239
  onClose={props.onClose}
169
240
  />
170
241
 
@@ -181,31 +252,7 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
181
252
  />
182
253
  )}
183
254
 
184
- <div className={css['toolsSection']} data-testid="agent-sidecar-detail-tools">
185
- <LineageTree
186
- trace={detail.lineage.trace}
187
- available={detail.lineage.available}
188
- reason={detail.lineage.reason}
189
- detail={detail.lineage.detail}
190
- currentSessionId={sessionId}
191
- onSelectSession={props.onSelectSession}
192
- loading={detail.lineage.loading}
193
- error={detail.lineage.error}
194
- />
195
- <SearchPanel
196
- query={search.query}
197
- project={search.project}
198
- mode={search.mode}
199
- items={search.items}
200
- loading={search.loading}
201
- error={search.error}
202
- onQueryChange={(query) => { searchStore.setQuery(query) }}
203
- onSubmit={() => { void searchStore.submit() }}
204
- onSelectSession={props.onSelectSession}
205
- />
206
- </div>
207
-
208
- {injectIntegration !== undefined && injectOpen && (
255
+ {injectIntegration !== undefined && injectActions !== undefined && injectOpen && (
209
256
  <div className={overlay['backdrop']} role="presentation" onClick={closeInject}>
210
257
  <div
211
258
  className={overlay['dialog']}
@@ -221,9 +268,10 @@ export function SidecarDetailView(props: SidecarDetailViewProps): ReactElement {
221
268
  ...(title !== '' ? { title } : {}),
222
269
  }}
223
270
  defaultMode={injectIntegration.getDefaultMode()}
224
- onPrepare={injectIntegration.actions.onPrepare}
225
- onExecute={injectIntegration.actions.onExecute}
271
+ onPrepare={injectActions.onPrepare}
272
+ onExecute={injectActions.onExecute}
226
273
  onClose={closeInject}
274
+ onObserve={observeReaction}
227
275
  />
228
276
  </div>
229
277
  </div>
@@ -169,10 +169,12 @@
169
169
  outline-offset: 1px;
170
170
  }
171
171
 
172
- /* Current-session highlight (the row the user is inspecting). */
172
+ /* Current-session highlight (the row the user is inspecting). Brand tint
173
+ * rides the real brand-primary token — brand-secondary/tertiary do not
174
+ * exist in the dsh token table (UX-15). */
173
175
  .node[data-current='true'] {
174
176
  border-color: var(--dsw-alias-brand-primary, #4d6bfe);
175
- background: var(--dsw-alias-brand-tertiary, rgba(77, 107, 254, 0.08));
177
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
176
178
  cursor: default;
177
179
  }
178
180
 
@@ -333,7 +335,7 @@
333
335
 
334
336
  .matchTag[data-kind='full-text'] {
335
337
  color: var(--dsw-alias-brand-primary, #4d6bfe);
336
- border-color: var(--dsw-alias-brand-secondary, rgba(77, 107, 254, 0.32));
338
+ border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 32%, transparent);
337
339
  background: transparent;
338
340
  }
339
341
 
@@ -368,7 +370,7 @@
368
370
 
369
371
  .snippetMark {
370
372
  color: var(--dsw-alias-brand-primary, #4d6bfe);
371
- background: var(--dsw-alias-brand-tertiary, rgba(77, 107, 254, 0.12));
373
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 12%, transparent);
372
374
  border-radius: 2px;
373
375
  font-weight: 600;
374
376
  }