@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
@@ -18,8 +18,8 @@
18
18
  * `dsh-agent-teams/src/command.ts`).
19
19
  * - The only `ui.kind` this dsh version supports is `popupSelect`:
20
20
  * an async `options(session, signal)` provider plus `onSelect`. The
21
- * `/sidecar` overview therefore presents as a popup card of rows — a
22
- * read-only glance; every row's onSelect is a no-op.
21
+ * `/sidecar` overview therefore presents as a popup card of rows; its
22
+ * board row opens Agent Center while informational rows remain inert.
23
23
  *
24
24
  * The `commandUi` service type is NOT part of the published plugin SDK
25
25
  * (same situation as `settingsScope` in ./index.ts), so this module keeps
@@ -55,7 +55,12 @@ import type {
55
55
  SessionStatusToken,
56
56
  WidgetConnection,
57
57
  } from './board/logic.ts'
58
+ import {
59
+ acquireWithHandoff,
60
+ isRegistrationCollision,
61
+ } from './lifecycle/handoff.ts'
58
62
  import { tCommand } from './locales/command.ts'
63
+ import type { CenterNavigation } from './navigation/center.ts'
59
64
 
60
65
  /** The slash command name (without the leading slash). */
61
66
  export const SIDECAR_COMMAND_NAME = 'sidecar'
@@ -399,6 +404,8 @@ export interface SidecarCommandDeps {
399
404
  now?: () => number
400
405
  /** Session-row cap; defaults to {@link DEFAULT_OVERVIEW_TOP_N}. */
401
406
  topN?: number
407
+ /** Opens Agent Center when the board option is selected. */
408
+ openCenter?: CenterNavigation
402
409
  }
403
410
 
404
411
  /**
@@ -435,8 +442,8 @@ export function createSidecarCommandContribution(
435
442
  if (deps.topN !== undefined) overviewOpts.topN = deps.topN
436
443
  return overviewToOptions(buildOverview(snapshot, overviewOpts))
437
444
  },
438
- onSelect: () => {
439
- // Read-only glance: rows are informational, selection is a no-op.
445
+ onSelect: (option) => {
446
+ if (option.id === 'board') deps.openCenter?.()
440
447
  },
441
448
  },
442
449
  }
@@ -451,7 +458,10 @@ export function createSidecarCommandContribution(
451
458
  * `ClientContext` satisfies it structurally (cordis `ctx.inject`).
452
459
  */
453
460
  export interface CommandMountContext {
454
- inject(deps: readonly string[], callback: (ctx: unknown) => void): unknown
461
+ inject(
462
+ deps: readonly string[],
463
+ callback: (ctx: unknown) => (() => void) | void,
464
+ ): unknown
455
465
  }
456
466
 
457
467
  /**
@@ -459,11 +469,9 @@ export interface CommandMountContext {
459
469
  * the design's `ctx.commands` consumption row — a composition without the
460
470
  * slash-menu runtime simply never gains the command).
461
471
  *
462
- * Idempotency: the ui-commands registry throws on a duplicate contribution
463
- * name; that throw is caught and logged, so a double apply (HMR re-apply
464
- * before the old fiber unloads) degrades to a no-op instead of taking the
465
- * client half down. Disposal is owned by the registry's effect on the
466
- * injected fiber — unloading the plugin unregisters the command.
472
+ * HMR handoff: a duplicate contribution means the old fiber still owns the
473
+ * name. The new injected fiber retries briefly, then registers its own fresh
474
+ * contribution after the old disposer runs. Foreign squatters time out.
467
475
  */
468
476
  export function registerSidecarCommand(
469
477
  ctx: CommandMountContext,
@@ -472,11 +480,18 @@ export function registerSidecarCommand(
472
480
  try {
473
481
  ctx.inject(['commandUi'], (injected) => {
474
482
  const { commandUi } = injected as { commandUi: CommandRegistryFace }
475
- try {
476
- commandUi.register(createSidecarCommandContribution(deps))
477
- } catch (err) {
478
- console.error('agent-sidecar: /sidecar command registration skipped', err)
479
- }
483
+ return acquireWithHandoff(
484
+ () => commandUi.register(createSidecarCommandContribution(deps)),
485
+ {
486
+ isCollision: isRegistrationCollision,
487
+ onError: (error) => {
488
+ console.error('agent-sidecar: /sidecar command registration failed', error)
489
+ },
490
+ onTimeout: () => {
491
+ console.error('agent-sidecar: /sidecar command handoff timed out')
492
+ },
493
+ },
494
+ )
480
495
  })
481
496
  } catch (err) {
482
497
  console.error('agent-sidecar: commandUi injection failed', err)
@@ -169,7 +169,11 @@ function defaultStorage(): StorageLike | null {
169
169
  }
170
170
  }
171
171
 
172
- /** Parse + validate persisted filters; anything malformed reads as absent. */
172
+ /**
173
+ * Parse + validate persisted filters; anything malformed reads as absent.
174
+ * The optional statusFilter (UX-01) survives only as one of its two legal
175
+ * values — an unrecognized token is dropped, not the whole record.
176
+ */
173
177
  export function readStoredFilters(storage: StorageLike | null): BoardFilterState | null {
174
178
  if (storage === null) return null
175
179
  try {
@@ -177,7 +181,11 @@ export function readStoredFilters(storage: StorageLike | null): BoardFilterState
177
181
  if (raw === null) return null
178
182
  const parsed: unknown = JSON.parse(raw)
179
183
  if (typeof parsed !== 'object' || parsed === null) return null
180
- const candidate = parsed as { timeWindowHours?: unknown; showDead?: unknown }
184
+ const candidate = parsed as {
185
+ timeWindowHours?: unknown
186
+ showDead?: unknown
187
+ statusFilter?: unknown
188
+ }
181
189
  if (
182
190
  typeof candidate.timeWindowHours !== 'number'
183
191
  || !Number.isFinite(candidate.timeWindowHours)
@@ -186,7 +194,14 @@ export function readStoredFilters(storage: StorageLike | null): BoardFilterState
186
194
  ) {
187
195
  return null
188
196
  }
189
- return { timeWindowHours: candidate.timeWindowHours, showDead: candidate.showDead }
197
+ const filters: BoardFilterState = {
198
+ timeWindowHours: candidate.timeWindowHours,
199
+ showDead: candidate.showDead,
200
+ }
201
+ if (candidate.statusFilter === 'working' || candidate.statusFilter === 'waiting') {
202
+ filters.statusFilter = candidate.statusFilter
203
+ }
204
+ return filters
190
205
  } catch {
191
206
  return null
192
207
  }
@@ -326,15 +341,20 @@ export class SidecarController {
326
341
  this.notify()
327
342
  }
328
343
 
329
- /** Manual refresh (board's refresh button): one out-of-band snapshot pull. */
330
- async refresh(): Promise<void> {
344
+ /**
345
+ * Manual refresh (board's refresh button): one out-of-band snapshot
346
+ * pull. Resolves true when the snapshot applied, false on failure so
347
+ * the button can surface honest feedback (UX-07) — never rejects; the
348
+ * stream (and its status surface) remains the health authority.
349
+ */
350
+ async refresh(): Promise<boolean> {
331
351
  try {
332
352
  const snapshot = await this.fetchFn({})
333
353
  this.applySnapshot(snapshot)
354
+ return true
334
355
  } catch (err) {
335
- // The stream (and its status surface) remains the health authority;
336
- // a failed manual pull only logs.
337
356
  console.error('agent-sidecar: manual refresh failed', err)
357
+ return false
338
358
  }
339
359
  }
340
360
 
@@ -5,29 +5,50 @@
5
5
  * imports. The integration layer (S7) owns transport and accumulation —
6
6
  * it feeds the {@link TimelineVM} built via logic.ts (`applyTimelinePage`
7
7
  * for history pages, `applyListenPage` for listen-mode refetches) and
8
- * handles `onLoadMore` / `onToggleListen`.
8
+ * handles `onLoadMore` / `onToggleListen` / `onRefresh`.
9
+ *
10
+ * Row pipeline (all pure, logic.ts): buildTimelineRows (gaps on the FULL
11
+ * entry list) → filterTimelineRows (UX-03 kind filter, conversation-first
12
+ * by default with an honest hidden count) → aggregateChunkRows (UX-03
13
+ * adjacent empty streaming chunks collapse into one expandable run) →
14
+ * limitTimelineRows (render cap with a 全部显示 escape hatch).
9
15
  *
10
16
  * Long-list posture (task report): no full virtualization — history only
11
17
  * grows page-by-page on explicit 加载更多, and rendering is additionally
12
- * capped at {@link DEFAULT_MAX_RENDER_ROWS} newest rows behind a collapse
13
- * notice with a 全部显示 escape hatch. View-local concerns (expanded
14
- * bodies, the lift-cap flag, auto-scroll) are component state; everything
18
+ * capped at {@link DEFAULT_MAX_RENDER_ROWS} newest rows. View-local
19
+ * concerns (expanded bodies/runs, filter mode, the lift-cap flag, the
20
+ * UX-04 initial landing, copy feedback) are component state; everything
15
21
  * else comes through props.
16
22
  */
17
23
 
18
- import { useEffect, useRef, useState, type ReactElement } from 'react'
19
24
  import {
25
+ Button,
26
+ Pill,
27
+ StateDot,
28
+ writeClipboard,
29
+ type StateDotState,
30
+ } from '@deepseek-ai/dsh-client-ui-primitives'
31
+ import { Fragment, useEffect, useRef, useState, type ReactElement } from 'react'
32
+ import {
33
+ aggregateChunkRows,
20
34
  buildTimelineRows,
21
35
  deriveDetailBodyState,
22
36
  deriveDetailStatus,
23
37
  deriveSourceBadges,
24
38
  agentGlyph,
39
+ filterTimelineRows,
40
+ formatTemplate,
25
41
  limitTimelineRows,
42
+ shouldStickToLatest,
26
43
  DEFAULT_MAX_RENDER_ROWS,
44
+ type TimelineEventRowVM,
45
+ type TimelineFilterMode,
27
46
  type TimelineRowVM,
28
47
  type TimelineVM,
29
48
  } from './logic.ts'
30
49
  import { DETAIL_STRINGS } from './strings.ts'
50
+ import { StaticPill } from '../primitives/StaticPill.tsx'
51
+ import { surfaceProps } from '../theme/parts.ts'
31
52
  import styles from './detail.module.css'
32
53
 
33
54
  export interface SessionDetailHeaderVM {
@@ -51,8 +72,12 @@ export interface SessionDetailProps {
51
72
  hasMore: boolean
52
73
  /** Listen mode (SSE-triggered newest-page refetch) currently on. */
53
74
  listening: boolean
75
+ /** True while a manual newest-window refresh is in flight (UX-07). */
76
+ refreshing?: boolean
54
77
  onLoadMore: () => void
55
78
  onToggleListen: () => void
79
+ /** Manual newest-window refetch; the button renders only when given. */
80
+ onRefresh?: () => void
56
81
  onClose?: () => void
57
82
  /** Clock injection for deterministic rendering; defaults to Date.now(). */
58
83
  nowMs?: number
@@ -61,7 +86,7 @@ export interface SessionDetailProps {
61
86
  }
62
87
 
63
88
  function EventRow(props: {
64
- row: Extract<TimelineRowVM, { type: 'event' }>
89
+ row: TimelineEventRowVM
65
90
  expanded: boolean
66
91
  onToggleExpand: (key: string) => void
67
92
  }): ReactElement {
@@ -84,19 +109,22 @@ function EventRow(props: {
84
109
  {DETAIL_STRINGS.timeline.seq.replace('{n}', String(entry.seq))}
85
110
  </span>
86
111
  )}
87
- {row.isNew && <span className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</span>}
112
+ {row.isNew && <Pill className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</Pill>}
88
113
  <span className={styles['eventSpacer']} />
89
- <span className={styles['eventTime']}>{row.relativeTime}</span>
114
+ <span className={styles['eventTime']}>{row.timeLabel}</span>
90
115
  </div>
91
116
  {entry.summary !== '' && <div className={styles['eventSummary']}>{entry.summary}</div>}
92
117
  {entry.expandable && (
93
- <button
118
+ <Button
94
119
  type="button"
120
+ size="sm"
121
+ variant="ghost"
95
122
  className={styles['expandButton']}
123
+ aria-expanded={expanded}
96
124
  onClick={() => onToggleExpand(entry.key)}
97
125
  >
98
126
  {expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
99
- </button>
127
+ </Button>
100
128
  )}
101
129
  {entry.expandable && expanded && entry.body !== null && (
102
130
  <pre className={styles['eventBody']}>{entry.body}</pre>
@@ -105,12 +133,62 @@ function EventRow(props: {
105
133
  )
106
134
  }
107
135
 
136
+ /** Collapsed run of adjacent streaming chunks (UX-03); expandable lossless. */
137
+ function ChunkRunRow(props: {
138
+ row: Extract<TimelineRowVM, { type: 'chunks' }>
139
+ expanded: boolean
140
+ onToggleRun: (key: string) => void
141
+ expandedKeys: ReadonlySet<string>
142
+ onToggleExpand: (key: string) => void
143
+ }): ReactElement {
144
+ const { row } = props
145
+ return (
146
+ <Fragment>
147
+ <li
148
+ className={styles['chunkRun']}
149
+ data-new={row.isNew || undefined}
150
+ title={row.hoverTitle}
151
+ data-testid="agent-sidecar-detail-chunks"
152
+ >
153
+ <span className={styles['chunkRunLabel']}>{row.label}</span>
154
+ <Button
155
+ type="button"
156
+ size="sm"
157
+ variant="ghost"
158
+ className={styles['expandButton']}
159
+ aria-expanded={props.expanded}
160
+ onClick={() => props.onToggleRun(row.key)}
161
+ >
162
+ {props.expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
163
+ </Button>
164
+ <span className={styles['eventSpacer']} />
165
+ <span className={styles['eventTime']}>{row.timeLabel}</span>
166
+ </li>
167
+ {props.expanded &&
168
+ row.members.map((member) => (
169
+ <EventRow
170
+ key={member.key}
171
+ row={member}
172
+ expanded={props.expandedKeys.has(member.key)}
173
+ onToggleExpand={props.onToggleExpand}
174
+ />
175
+ ))}
176
+ </Fragment>
177
+ )
178
+ }
179
+
108
180
  /** The session-detail view. Pure render of the logic.ts pipelines over props. */
109
181
  export function SessionDetail(props: SessionDetailProps): ReactElement {
110
182
  const nowMs = props.nowMs ?? Date.now()
111
183
  const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(new Set())
184
+ const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set())
185
+ const [filterMode, setFilterMode] = useState<TimelineFilterMode>('conversation')
112
186
  const [renderAll, setRenderAll] = useState(false)
187
+ const [copied, setCopied] = useState(false)
113
188
  const listRef = useRef<HTMLOListElement | null>(null)
189
+ const positionedRef = useRef(false)
190
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
191
+ const copyAliveRef = useRef(true)
114
192
 
115
193
  const status = deriveDetailStatus(props.header.status)
116
194
  const sourceBadges = deriveSourceBadges(props.timeline.sources)
@@ -121,19 +199,43 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
121
199
  })
122
200
 
123
201
  const allRows = buildTimelineRows(props.timeline, nowMs)
202
+ const filtered = filterTimelineRows(allRows, filterMode)
203
+ const aggregated = aggregateChunkRows(filtered.rows)
124
204
  const limited = renderAll
125
- ? { rows: allRows, hiddenCount: 0, notice: null }
126
- : limitTimelineRows(allRows, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
205
+ ? { rows: aggregated, hiddenCount: 0, notice: null }
206
+ : limitTimelineRows(aggregated, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
127
207
 
128
208
  const entryCount = props.timeline.entries.length
129
209
  const listening = props.listening
130
210
  useEffect(() => {
131
- // Listen mode appends at the tail: keep the newest events in view.
132
- if (!listening) return
211
+ // UX-04 landing + listen-mode tail pinning (shouldStickToLatest):
212
+ // first non-empty render lands on the newest events; listen appends
213
+ // keep them in view; paging back never yanks the viewport.
133
214
  const list = listRef.current
134
- if (list !== null) list.scrollTop = list.scrollHeight
215
+ if (list === null) return
216
+ if (
217
+ shouldStickToLatest({
218
+ entryCount,
219
+ positioned: positionedRef.current,
220
+ listening,
221
+ })
222
+ ) {
223
+ list.scrollTop = list.scrollHeight
224
+ positionedRef.current = true
225
+ }
135
226
  }, [listening, entryCount])
136
227
 
228
+ useEffect(() => {
229
+ copyAliveRef.current = true
230
+ return () => {
231
+ copyAliveRef.current = false
232
+ if (copyTimerRef.current !== null) {
233
+ clearTimeout(copyTimerRef.current)
234
+ copyTimerRef.current = null
235
+ }
236
+ }
237
+ }, [])
238
+
137
239
  const toggleExpand = (key: string): void => {
138
240
  setExpandedKeys((prev) => {
139
241
  const next = new Set(prev)
@@ -143,14 +245,41 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
143
245
  })
144
246
  }
145
247
 
248
+ const toggleRun = (key: string): void => {
249
+ setExpandedRuns((prev) => {
250
+ const next = new Set(prev)
251
+ if (next.has(key)) next.delete(key)
252
+ else next.add(key)
253
+ return next
254
+ })
255
+ }
256
+
257
+ const copySessionId = async (): Promise<void> => {
258
+ if (!(await writeClipboard(props.sessionId))) return
259
+ if (!copyAliveRef.current) return
260
+ if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
261
+ setCopied(true)
262
+ copyTimerRef.current = setTimeout(() => {
263
+ copyTimerRef.current = null
264
+ setCopied(false)
265
+ }, 2000)
266
+ }
267
+
268
+ const statusDotState: StateDotState | null =
269
+ status.status === 'working'
270
+ ? 'ongoing'
271
+ : status.status === 'waiting'
272
+ ? 'warning'
273
+ : null
274
+
146
275
  return (
147
- <div className={styles['root']} data-testid="agent-sidecar-detail">
276
+ <div {...surfaceProps('timeline', styles['root'])} data-testid="agent-sidecar-detail">
148
277
  <header className={styles['header']}>
149
278
  <div className={styles['headerTop']}>
150
279
  {props.onClose !== undefined && (
151
- <button type="button" className={styles['closeButton']} onClick={props.onClose}>
280
+ <Button type="button" size="sm" variant="outline" onClick={props.onClose}>
152
281
  {DETAIL_STRINGS.header.close}
153
- </button>
282
+ </Button>
154
283
  )}
155
284
  <span className={styles['agent']}>
156
285
  <span className={styles['agentGlyph']} aria-hidden>
@@ -158,21 +287,37 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
158
287
  </span>
159
288
  {props.header.agent}
160
289
  </span>
161
- <span className={styles['badge']} data-tone={status.tone} title={DETAIL_STRINGS.header.observedDisclaimer}>
162
- <span className={styles['dot']} data-tone={status.tone} />
290
+ <StaticPill className={styles['badge']} title={DETAIL_STRINGS.header.observedDisclaimer}>
291
+ {statusDotState === null
292
+ ? <span className={styles['dot']} data-tone={status.tone} />
293
+ : <StateDot state={statusDotState} size={8} />}
163
294
  {status.label}
164
- </span>
295
+ </StaticPill>
165
296
  <span className={styles['spacer']} />
166
- <button
297
+ {props.onRefresh !== undefined && (
298
+ <Button
299
+ type="button"
300
+ size="sm"
301
+ variant="outline"
302
+ disabled={props.refreshing === true}
303
+ title={DETAIL_STRINGS.header.refreshHint}
304
+ onClick={props.onRefresh}
305
+ data-testid="agent-sidecar-detail-refresh"
306
+ >
307
+ {props.refreshing === true
308
+ ? DETAIL_STRINGS.header.refreshing
309
+ : DETAIL_STRINGS.header.refresh}
310
+ </Button>
311
+ )}
312
+ <Pill
167
313
  type="button"
168
- className={styles['listenButton']}
314
+ active={props.listening}
169
315
  aria-pressed={props.listening}
170
- data-active={props.listening || undefined}
171
316
  title={DETAIL_STRINGS.header.listenHint}
172
317
  onClick={props.onToggleListen}
173
318
  >
174
319
  {props.listening ? DETAIL_STRINGS.header.listenOn : DETAIL_STRINGS.header.listenOff}
175
- </button>
320
+ </Pill>
176
321
  </div>
177
322
  <div className={styles['title']} title={props.header.title}>
178
323
  {props.header.title.trim() === '' ? DETAIL_STRINGS.header.untitled : props.header.title}
@@ -183,18 +328,35 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
183
328
  ? DETAIL_STRINGS.header.unknownProject
184
329
  : props.header.project}
185
330
  </span>
186
- <span className={styles['sessionId']} title={props.sessionId}>
331
+ <Button
332
+ type="button"
333
+ size="sm"
334
+ variant="ghost"
335
+ className={styles['sessionId']}
336
+ title={`${props.sessionId} · ${DETAIL_STRINGS.header.copyIdTitle}`}
337
+ onClick={() => { void copySessionId() }}
338
+ data-testid="agent-sidecar-detail-copy-id"
339
+ >
187
340
  {props.sessionId}
188
- </span>
341
+ </Button>
342
+ {copied && (
343
+ <StaticPill className={styles['copiedBubble']} role="status">
344
+ {DETAIL_STRINGS.header.copied}
345
+ </StaticPill>
346
+ )}
189
347
  </div>
190
348
  <div className={styles['metaRow']}>
191
349
  <span className={styles['disclaimer']}>{DETAIL_STRINGS.header.observedDisclaimer}</span>
192
350
  {sourceBadges.length > 0 && (
193
351
  <span className={styles['sourceList']} title={DETAIL_STRINGS.sources.title}>
194
352
  {sourceBadges.map((badge) => (
195
- <span key={badge.id} className={styles['sourceBadge']} data-tone={badge.tone}>
353
+ <StaticPill
354
+ key={badge.id}
355
+ className={styles['sourceBadge']}
356
+ data-tone={badge.tone}
357
+ >
196
358
  {badge.label}
197
- </span>
359
+ </StaticPill>
198
360
  ))}
199
361
  </span>
200
362
  )}
@@ -202,30 +364,55 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
202
364
  </header>
203
365
 
204
366
  {bodyState.errorBanner !== null && (
205
- <div className={styles['banner']} role="status">
367
+ <div className={styles['banner']} role="alert">
206
368
  {bodyState.errorBanner}
207
369
  </div>
208
370
  )}
209
371
 
210
372
  {bodyState.kind !== 'list' ? (
211
- <div className={styles['bodyState']} data-kind={bodyState.kind}>
373
+ <div
374
+ className={styles['bodyState']}
375
+ data-kind={bodyState.kind}
376
+ role={bodyState.kind === 'error' ? 'alert' : bodyState.kind === 'loading' ? 'status' : undefined}
377
+ >
212
378
  <div className={styles['bodyStateTitle']}>{bodyState.title}</div>
213
379
  {bodyState.hint !== null && <div className={styles['bodyStateHint']}>{bodyState.hint}</div>}
214
380
  </div>
215
381
  ) : (
216
382
  <>
383
+ <div className={styles['filterRow']} data-testid="agent-sidecar-detail-filter">
384
+ {(['conversation', 'all'] as const).map((mode) => (
385
+ <Pill
386
+ key={mode}
387
+ type="button"
388
+ active={filterMode === mode}
389
+ aria-pressed={filterMode === mode}
390
+ onClick={() => {
391
+ setFilterMode(mode)
392
+ }}
393
+ >
394
+ {DETAIL_STRINGS.filter[mode]}
395
+ </Pill>
396
+ ))}
397
+ {filtered.hiddenCount > 0 && (
398
+ <span className={styles['filterHiddenNote']}>
399
+ {formatTemplate(DETAIL_STRINGS.filter.hiddenNotice, { n: filtered.hiddenCount })}
400
+ </span>
401
+ )}
402
+ </div>
217
403
  <div className={styles['pager']}>
218
404
  {props.hasMore ? (
219
- <button
405
+ <Button
220
406
  type="button"
221
- className={styles['loadMoreButton']}
407
+ size="sm"
408
+ variant="outline"
222
409
  disabled={props.loading}
223
410
  onClick={props.onLoadMore}
224
411
  >
225
412
  {props.loading
226
413
  ? DETAIL_STRINGS.timeline.loadingMore
227
414
  : DETAIL_STRINGS.timeline.loadMore}
228
- </button>
415
+ </Button>
229
416
  ) : (
230
417
  <span className={styles['pagerNote']}>{DETAIL_STRINGS.timeline.noMore}</span>
231
418
  )}
@@ -233,13 +420,14 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
233
420
  {limited.notice !== null && (
234
421
  <div className={styles['hiddenNotice']}>
235
422
  {limited.notice}
236
- <button
423
+ <Button
237
424
  type="button"
238
- className={styles['showAllButton']}
425
+ size="sm"
426
+ variant="ghost"
239
427
  onClick={() => setRenderAll(true)}
240
428
  >
241
429
  {DETAIL_STRINGS.timeline.showAll}
242
- </button>
430
+ </Button>
243
431
  </div>
244
432
  )}
245
433
  <ol className={styles['timeline']} ref={listRef}>
@@ -253,6 +441,15 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
253
441
  >
254
442
  {row.label}
255
443
  </li>
444
+ ) : row.type === 'chunks' ? (
445
+ <ChunkRunRow
446
+ key={row.key}
447
+ row={row}
448
+ expanded={expandedRuns.has(row.key)}
449
+ onToggleRun={toggleRun}
450
+ expandedKeys={expandedKeys}
451
+ onToggleExpand={toggleExpand}
452
+ />
256
453
  ) : (
257
454
  <EventRow
258
455
  key={row.key}