@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,360 @@
1
+ /**
2
+ * SidecarController — the browser half's one data controller (T2.4).
3
+ *
4
+ * Owns the live feed (a {@link StateStream}, default 'sse' mode on the
5
+ * plugin's stream route) and folds it into two externally-subscribable
6
+ * stores consumed via `useSyncExternalStore`:
7
+ *
8
+ * - view state: the host `StateSnapshot` mapped onto the board view models
9
+ * (wire `updated_at` epoch SECONDS → `updatedAtMs` epoch MILLISECONDS at
10
+ * this boundary, per board/logic.ts's contract), plus the composite
11
+ * stream health (browser stream status overrides the host-reported
12
+ * subscribe health — see {@link combineStreamHealth});
13
+ * - filters: board filter state persisted to localStorage under a
14
+ * package-name-prefixed key; until the user touches them, the ui.*
15
+ * settings defaults may be adopted ({@link SidecarController.adoptConfigDefaults}).
16
+ *
17
+ * Pure mapping functions are exported for unit tests. Browser primitives
18
+ * (stream, storage) are injectable so the whole controller runs under
19
+ * plain node. No React, no slots SDK.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import {
25
+ fetchState,
26
+ type PingInfo,
27
+ type SessionView,
28
+ type StateSnapshot,
29
+ type StreamHealth,
30
+ } from './api.ts'
31
+ import { StateStream, STREAM_PATH, type StreamMode, type StreamStatus } from './sse.ts'
32
+ import {
33
+ DEFAULT_TIME_WINDOW_HOURS,
34
+ type BoardFilterState,
35
+ type DaemonStateToken,
36
+ type SessionCardVM,
37
+ type StreamHealthToken,
38
+ } from './board/logic.ts'
39
+
40
+ /**
41
+ * Package name, used as the localStorage key prefix and the style-tag owner
42
+ * mark. Mirrors package.json `name` (client code cannot read package.json at
43
+ * runtime); pinned against it by test/client-integration.test.ts.
44
+ */
45
+ export const PLUGIN_ID = '@shendeguize/dsh-agent-sidecar'
46
+
47
+ /** localStorage key for the persisted board filters. */
48
+ export const FILTERS_STORAGE_KEY = `${PLUGIN_ID}:board-filters`
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // View state and pure mapping (exported for tests).
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Everything the three mounted surfaces render from. */
55
+ export interface SidecarViewState {
56
+ daemonState: DaemonStateToken
57
+ /** Last successful daemon ping, if any. */
58
+ lastPing: PingInfo | null
59
+ /** Hover detail for the daemon badge, e.g. "pid 123 · v0.6.0". */
60
+ daemonDetail: string | undefined
61
+ /** Composite health: browser stream status folded over host-reported health. */
62
+ streamHealth: StreamHealthToken
63
+ /** Raw browser-side stream status. */
64
+ streamStatus: StreamStatus
65
+ lastReconcileAtMs: number | null
66
+ sessions: SessionCardVM[]
67
+ /** Host capabilities.inject (M2 write surface; informational in M1). */
68
+ injectCapability: boolean
69
+ /** False until the first snapshot arrives in this page life. */
70
+ hasSnapshot: boolean
71
+ }
72
+
73
+ /** Pre-first-snapshot state: probing daemon, unknown health, empty board. */
74
+ export function initialViewState(): SidecarViewState {
75
+ return {
76
+ daemonState: 'probe',
77
+ lastPing: null,
78
+ daemonDetail: undefined,
79
+ streamHealth: 'unknown',
80
+ streamStatus: 'connecting',
81
+ lastReconcileAtMs: null,
82
+ sessions: [],
83
+ injectCapability: false,
84
+ hasSnapshot: false,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Wire sessions → board card view models. The one place the epoch-seconds
90
+ * `updated_at` becomes the epoch-milliseconds `updatedAtMs` (board/logic.ts
91
+ * consumes milliseconds everywhere).
92
+ */
93
+ export function mapSessions(sessions: readonly SessionView[]): SessionCardVM[] {
94
+ return sessions.map((session) => ({
95
+ agent: session.agent,
96
+ sessionId: session.session_id,
97
+ status: session.status,
98
+ title: session.title,
99
+ project: session.project,
100
+ updatedAtMs: session.updated_at * 1000,
101
+ lastEvent:
102
+ session.last_event === null
103
+ ? null
104
+ : { kind: session.last_event.kind, text: session.last_event.text },
105
+ gap: session.gap,
106
+ }))
107
+ }
108
+
109
+ /** Daemon badge hover detail from the last ping ("pid 123 · v0.6.0"). */
110
+ export function daemonDetailOf(ping: PingInfo | null): string | undefined {
111
+ if (ping === null) return undefined
112
+ return `pid ${ping.pid} · v${ping.version}`
113
+ }
114
+
115
+ /**
116
+ * Composite stream health for the UI:
117
+ * - before the first snapshot nothing is known → 'unknown';
118
+ * - a degraded BROWSER stream overrides (the host may still be healthy,
119
+ * but what this page shows is stale);
120
+ * - otherwise the host-reported daemon-subscribe health stands ('connecting'
121
+ * during a native EventSource reconnect keeps the last host verdict —
122
+ * the reconcile timestamp already conveys staleness).
123
+ */
124
+ export function combineStreamHealth(
125
+ hostHealth: StreamHealth | null,
126
+ browserStatus: StreamStatus,
127
+ hasSnapshot: boolean,
128
+ ): StreamHealthToken {
129
+ if (!hasSnapshot || hostHealth === null) return 'unknown'
130
+ if (browserStatus === 'degraded') return 'degraded'
131
+ return hostHealth
132
+ }
133
+
134
+ /** Full snapshot → view state fold (pure; exported for tests). */
135
+ export function mapSnapshot(
136
+ snapshot: StateSnapshot,
137
+ browserStatus: StreamStatus,
138
+ ): SidecarViewState {
139
+ return {
140
+ daemonState: snapshot.daemon.state,
141
+ lastPing: snapshot.daemon.lastPing,
142
+ daemonDetail: daemonDetailOf(snapshot.daemon.lastPing),
143
+ streamHealth: combineStreamHealth(snapshot.board.streamHealth, browserStatus, true),
144
+ streamStatus: browserStatus,
145
+ lastReconcileAtMs: snapshot.board.lastReconcileAt,
146
+ sessions: mapSessions(snapshot.board.sessions),
147
+ injectCapability: snapshot.capabilities.inject,
148
+ hasSnapshot: true,
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Filter persistence.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Structural localStorage face (node tests inject a Map-backed fake). */
157
+ export interface StorageLike {
158
+ getItem(key: string): string | null
159
+ setItem(key: string, value: string): void
160
+ }
161
+
162
+ /** Resolve the real localStorage; some privacy modes throw on access. */
163
+ function defaultStorage(): StorageLike | null {
164
+ try {
165
+ const storage = (globalThis as { localStorage?: StorageLike }).localStorage
166
+ return storage ?? null
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ /** Parse + validate persisted filters; anything malformed reads as absent. */
173
+ export function readStoredFilters(storage: StorageLike | null): BoardFilterState | null {
174
+ if (storage === null) return null
175
+ try {
176
+ const raw = storage.getItem(FILTERS_STORAGE_KEY)
177
+ if (raw === null) return null
178
+ const parsed: unknown = JSON.parse(raw)
179
+ if (typeof parsed !== 'object' || parsed === null) return null
180
+ const candidate = parsed as { timeWindowHours?: unknown; showDead?: unknown }
181
+ if (
182
+ typeof candidate.timeWindowHours !== 'number'
183
+ || !Number.isFinite(candidate.timeWindowHours)
184
+ || candidate.timeWindowHours <= 0
185
+ || typeof candidate.showDead !== 'boolean'
186
+ ) {
187
+ return null
188
+ }
189
+ return { timeWindowHours: candidate.timeWindowHours, showDead: candidate.showDead }
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Controller.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /** Structural stream face ({@link StateStream} satisfies it; tests fake it). */
200
+ export interface StateStreamLike {
201
+ readonly status: StreamStatus
202
+ readonly mode: StreamMode
203
+ start(): void
204
+ stop(): void
205
+ onSnapshot(cb: (snapshot: StateSnapshot) => void): () => void
206
+ onStatus(cb: (status: StreamStatus) => void): () => void
207
+ pollNow(): void
208
+ }
209
+
210
+ export interface SidecarControllerOptions {
211
+ /** Live feed; default: `new StateStream({url: STREAM_PATH, mode: 'sse'})`. */
212
+ stream?: StateStreamLike
213
+ /** Filter persistence; `null` disables, absent resolves real localStorage. */
214
+ storage?: StorageLike | null
215
+ /** Manual-refresh fetch; default api.fetchState. */
216
+ fetchStateFn?: typeof fetchState
217
+ /** Visibility gate handed to the default stream (poll-mode pause). */
218
+ visible?: () => boolean
219
+ }
220
+
221
+ const defaultVisible = (): boolean =>
222
+ typeof document === 'undefined' || !document.hidden
223
+
224
+ /**
225
+ * One instance per plugin apply. `start()` wires the stream, `stop()` is
226
+ * terminal (the underlying StateStream cannot restart — a re-apply builds
227
+ * a fresh controller).
228
+ */
229
+ export class SidecarController {
230
+ private readonly stream: StateStreamLike
231
+ private readonly storage: StorageLike | null
232
+ private readonly fetchFn: typeof fetchState
233
+ private readonly listeners = new Set<() => void>()
234
+
235
+ private state: SidecarViewState = initialViewState()
236
+ private filters: BoardFilterState
237
+ /** True once filters came from storage or a user gesture (config defaults then stop adopting). */
238
+ private filtersTouched: boolean
239
+ private lastHostHealth: StreamHealth | null = null
240
+ private started = false
241
+
242
+ constructor(opts: SidecarControllerOptions = {}) {
243
+ this.storage = opts.storage === undefined ? defaultStorage() : opts.storage
244
+ this.fetchFn = opts.fetchStateFn ?? fetchState
245
+ this.stream =
246
+ opts.stream
247
+ ?? new StateStream({
248
+ url: STREAM_PATH,
249
+ mode: 'sse',
250
+ visible: opts.visible ?? defaultVisible,
251
+ })
252
+ const stored = readStoredFilters(this.storage)
253
+ this.filters = stored ?? { timeWindowHours: DEFAULT_TIME_WINDOW_HOURS, showDead: false }
254
+ this.filtersTouched = stored !== null
255
+ }
256
+
257
+ /** Wire stream listeners and begin streaming (idempotent). */
258
+ start(): void {
259
+ if (this.started) return
260
+ this.started = true
261
+ this.stream.onSnapshot((snapshot) => {
262
+ this.applySnapshot(snapshot)
263
+ })
264
+ this.stream.onStatus((status) => {
265
+ this.applyStatus(status)
266
+ })
267
+ this.stream.start()
268
+ }
269
+
270
+ /** Terminal teardown of the live feed. */
271
+ stop(): void {
272
+ this.stream.stop()
273
+ }
274
+
275
+ /** Forward the visibility resume to the stream (poll-mode immediate fetch). */
276
+ pollNow(): void {
277
+ this.stream.pollNow()
278
+ }
279
+
280
+ /** Change notifications for BOTH stores (state and filters). */
281
+ subscribe(listener: () => void): () => void {
282
+ this.listeners.add(listener)
283
+ return () => {
284
+ this.listeners.delete(listener)
285
+ }
286
+ }
287
+
288
+ /** Stable-reference view state (uSES getSnapshot source). */
289
+ getState(): SidecarViewState {
290
+ return this.state
291
+ }
292
+
293
+ /** Stable-reference filters (uSES getSnapshot source). */
294
+ getFilters(): BoardFilterState {
295
+ return this.filters
296
+ }
297
+
298
+ /** User filter change: persist (package-prefixed key) and notify. */
299
+ setFilters(next: BoardFilterState): void {
300
+ this.filters = { ...next }
301
+ this.filtersTouched = true
302
+ if (this.storage !== null) {
303
+ try {
304
+ this.storage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(this.filters))
305
+ } catch {
306
+ // Quota/privacy failures degrade to session-only filters.
307
+ }
308
+ }
309
+ this.notify()
310
+ }
311
+
312
+ /**
313
+ * Adopt ui.* settings defaults as the filter values — only while the user
314
+ * has never touched the filters (no stored value, no gesture). Not
315
+ * persisted: an untouched board keeps following the settings defaults.
316
+ */
317
+ adoptConfigDefaults(ui: { timeWindowHours: number; showDead: boolean }): void {
318
+ if (this.filtersTouched) return
319
+ if (
320
+ this.filters.timeWindowHours === ui.timeWindowHours
321
+ && this.filters.showDead === ui.showDead
322
+ ) {
323
+ return
324
+ }
325
+ this.filters = { timeWindowHours: ui.timeWindowHours, showDead: ui.showDead }
326
+ this.notify()
327
+ }
328
+
329
+ /** Manual refresh (board's refresh button): one out-of-band snapshot pull. */
330
+ async refresh(): Promise<void> {
331
+ try {
332
+ const snapshot = await this.fetchFn({})
333
+ this.applySnapshot(snapshot)
334
+ } catch (err) {
335
+ // The stream (and its status surface) remains the health authority;
336
+ // a failed manual pull only logs.
337
+ console.error('agent-sidecar: manual refresh failed', err)
338
+ }
339
+ }
340
+
341
+ private applySnapshot(snapshot: StateSnapshot): void {
342
+ this.lastHostHealth = snapshot.board.streamHealth
343
+ this.state = mapSnapshot(snapshot, this.stream.status)
344
+ this.notify()
345
+ }
346
+
347
+ private applyStatus(status: StreamStatus): void {
348
+ if (status === this.state.streamStatus) return
349
+ this.state = {
350
+ ...this.state,
351
+ streamStatus: status,
352
+ streamHealth: combineStreamHealth(this.lastHostHealth, status, this.state.hasSnapshot),
353
+ }
354
+ this.notify()
355
+ }
356
+
357
+ private notify(): void {
358
+ for (const listener of [...this.listeners]) listener()
359
+ }
360
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * CSS Modules ambient declaration for the client TS program (same shape as
3
+ * the dsh-web-ui blueprint packages). The build side is handled by the
4
+ * `dsh-css-modules-inline` plugin in tsdown.client.ts, which compiles
5
+ * `*.module.css` with lightningcss into a hashed class map and a
6
+ * self-injecting `<style data-plugin>` tag.
7
+ */
8
+ declare module '*.module.css' {
9
+ const classes: Record<string, string>
10
+ export default classes
11
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Session-detail view: header + merged event timeline (design §5.1 view 2).
3
+ *
4
+ * Presentation-only and fully controlled: no data fetching, no api/sse
5
+ * imports. The integration layer (S7) owns transport and accumulation —
6
+ * it feeds the {@link TimelineVM} built via logic.ts (`applyTimelinePage`
7
+ * for history pages, `applyListenPage` for listen-mode refetches) and
8
+ * handles `onLoadMore` / `onToggleListen`.
9
+ *
10
+ * Long-list posture (task report): no full virtualization — history only
11
+ * 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
15
+ * else comes through props.
16
+ */
17
+
18
+ import { useEffect, useRef, useState, type ReactElement } from 'react'
19
+ import {
20
+ buildTimelineRows,
21
+ deriveDetailBodyState,
22
+ deriveDetailStatus,
23
+ deriveSourceBadges,
24
+ agentGlyph,
25
+ limitTimelineRows,
26
+ DEFAULT_MAX_RENDER_ROWS,
27
+ type TimelineRowVM,
28
+ type TimelineVM,
29
+ } from './logic.ts'
30
+ import { DETAIL_STRINGS } from './strings.ts'
31
+ import styles from './detail.module.css'
32
+
33
+ export interface SessionDetailHeaderVM {
34
+ agent: string
35
+ title: string
36
+ project: string
37
+ /** Raw observed status string (open vocabulary, normalized in logic.ts). */
38
+ status: string
39
+ }
40
+
41
+ export interface SessionDetailProps {
42
+ sessionId: string
43
+ header: SessionDetailHeaderVM
44
+ /** Accumulated timeline state (logic.ts createTimelineVM/apply* output). */
45
+ timeline: TimelineVM
46
+ /** True while the owner has a fetch in flight (initial or older page). */
47
+ loading: boolean
48
+ /** Machine reason code of the last failure, or null. */
49
+ error: string | null
50
+ /** True when an older history page can still be fetched. */
51
+ hasMore: boolean
52
+ /** Listen mode (SSE-triggered newest-page refetch) currently on. */
53
+ listening: boolean
54
+ onLoadMore: () => void
55
+ onToggleListen: () => void
56
+ onClose?: () => void
57
+ /** Clock injection for deterministic rendering; defaults to Date.now(). */
58
+ nowMs?: number
59
+ /** Render cap override (segmented rendering); mostly for tests/tuning. */
60
+ maxRenderRows?: number
61
+ }
62
+
63
+ function EventRow(props: {
64
+ row: Extract<TimelineRowVM, { type: 'event' }>
65
+ expanded: boolean
66
+ onToggleExpand: (key: string) => void
67
+ }): ReactElement {
68
+ const { row, expanded, onToggleExpand } = props
69
+ const entry = row.entry
70
+ return (
71
+ <li
72
+ className={styles['event']}
73
+ data-kind={entry.kind}
74
+ data-new={row.isNew || undefined}
75
+ data-testid="agent-sidecar-detail-event"
76
+ >
77
+ <div className={styles['eventHead']} title={row.hoverTitle}>
78
+ <span className={styles['eventGlyph']} aria-hidden>
79
+ {entry.glyph}
80
+ </span>
81
+ <span className={styles['eventLabel']}>{entry.label}</span>
82
+ {entry.seq !== null && (
83
+ <span className={styles['eventSeq']}>
84
+ {DETAIL_STRINGS.timeline.seq.replace('{n}', String(entry.seq))}
85
+ </span>
86
+ )}
87
+ {row.isNew && <span className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</span>}
88
+ <span className={styles['eventSpacer']} />
89
+ <span className={styles['eventTime']}>{row.relativeTime}</span>
90
+ </div>
91
+ {entry.summary !== '' && <div className={styles['eventSummary']}>{entry.summary}</div>}
92
+ {entry.expandable && (
93
+ <button
94
+ type="button"
95
+ className={styles['expandButton']}
96
+ onClick={() => onToggleExpand(entry.key)}
97
+ >
98
+ {expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
99
+ </button>
100
+ )}
101
+ {entry.expandable && expanded && entry.body !== null && (
102
+ <pre className={styles['eventBody']}>{entry.body}</pre>
103
+ )}
104
+ </li>
105
+ )
106
+ }
107
+
108
+ /** The session-detail view. Pure render of the logic.ts pipelines over props. */
109
+ export function SessionDetail(props: SessionDetailProps): ReactElement {
110
+ const nowMs = props.nowMs ?? Date.now()
111
+ const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(new Set())
112
+ const [renderAll, setRenderAll] = useState(false)
113
+ const listRef = useRef<HTMLOListElement | null>(null)
114
+
115
+ const status = deriveDetailStatus(props.header.status)
116
+ const sourceBadges = deriveSourceBadges(props.timeline.sources)
117
+ const bodyState = deriveDetailBodyState({
118
+ loading: props.loading,
119
+ error: props.error,
120
+ entryCount: props.timeline.entries.length,
121
+ })
122
+
123
+ const allRows = buildTimelineRows(props.timeline, nowMs)
124
+ const limited = renderAll
125
+ ? { rows: allRows, hiddenCount: 0, notice: null }
126
+ : limitTimelineRows(allRows, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
127
+
128
+ const entryCount = props.timeline.entries.length
129
+ const listening = props.listening
130
+ useEffect(() => {
131
+ // Listen mode appends at the tail: keep the newest events in view.
132
+ if (!listening) return
133
+ const list = listRef.current
134
+ if (list !== null) list.scrollTop = list.scrollHeight
135
+ }, [listening, entryCount])
136
+
137
+ const toggleExpand = (key: string): void => {
138
+ setExpandedKeys((prev) => {
139
+ const next = new Set(prev)
140
+ if (next.has(key)) next.delete(key)
141
+ else next.add(key)
142
+ return next
143
+ })
144
+ }
145
+
146
+ return (
147
+ <div className={styles['root']} data-testid="agent-sidecar-detail">
148
+ <header className={styles['header']}>
149
+ <div className={styles['headerTop']}>
150
+ {props.onClose !== undefined && (
151
+ <button type="button" className={styles['closeButton']} onClick={props.onClose}>
152
+ {DETAIL_STRINGS.header.close}
153
+ </button>
154
+ )}
155
+ <span className={styles['agent']}>
156
+ <span className={styles['agentGlyph']} aria-hidden>
157
+ {agentGlyph(props.header.agent)}
158
+ </span>
159
+ {props.header.agent}
160
+ </span>
161
+ <span className={styles['badge']} data-tone={status.tone} title={DETAIL_STRINGS.header.observedDisclaimer}>
162
+ <span className={styles['dot']} data-tone={status.tone} />
163
+ {status.label}
164
+ </span>
165
+ <span className={styles['spacer']} />
166
+ <button
167
+ type="button"
168
+ className={styles['listenButton']}
169
+ aria-pressed={props.listening}
170
+ data-active={props.listening || undefined}
171
+ title={DETAIL_STRINGS.header.listenHint}
172
+ onClick={props.onToggleListen}
173
+ >
174
+ {props.listening ? DETAIL_STRINGS.header.listenOn : DETAIL_STRINGS.header.listenOff}
175
+ </button>
176
+ </div>
177
+ <div className={styles['title']} title={props.header.title}>
178
+ {props.header.title.trim() === '' ? DETAIL_STRINGS.header.untitled : props.header.title}
179
+ </div>
180
+ <div className={styles['meta']}>
181
+ <span className={styles['project']} title={props.header.project}>
182
+ {props.header.project.trim() === ''
183
+ ? DETAIL_STRINGS.header.unknownProject
184
+ : props.header.project}
185
+ </span>
186
+ <span className={styles['sessionId']} title={props.sessionId}>
187
+ {props.sessionId}
188
+ </span>
189
+ </div>
190
+ <div className={styles['metaRow']}>
191
+ <span className={styles['disclaimer']}>{DETAIL_STRINGS.header.observedDisclaimer}</span>
192
+ {sourceBadges.length > 0 && (
193
+ <span className={styles['sourceList']} title={DETAIL_STRINGS.sources.title}>
194
+ {sourceBadges.map((badge) => (
195
+ <span key={badge.id} className={styles['sourceBadge']} data-tone={badge.tone}>
196
+ {badge.label}
197
+ </span>
198
+ ))}
199
+ </span>
200
+ )}
201
+ </div>
202
+ </header>
203
+
204
+ {bodyState.errorBanner !== null && (
205
+ <div className={styles['banner']} role="status">
206
+ {bodyState.errorBanner}
207
+ </div>
208
+ )}
209
+
210
+ {bodyState.kind !== 'list' ? (
211
+ <div className={styles['bodyState']} data-kind={bodyState.kind}>
212
+ <div className={styles['bodyStateTitle']}>{bodyState.title}</div>
213
+ {bodyState.hint !== null && <div className={styles['bodyStateHint']}>{bodyState.hint}</div>}
214
+ </div>
215
+ ) : (
216
+ <>
217
+ <div className={styles['pager']}>
218
+ {props.hasMore ? (
219
+ <button
220
+ type="button"
221
+ className={styles['loadMoreButton']}
222
+ disabled={props.loading}
223
+ onClick={props.onLoadMore}
224
+ >
225
+ {props.loading
226
+ ? DETAIL_STRINGS.timeline.loadingMore
227
+ : DETAIL_STRINGS.timeline.loadMore}
228
+ </button>
229
+ ) : (
230
+ <span className={styles['pagerNote']}>{DETAIL_STRINGS.timeline.noMore}</span>
231
+ )}
232
+ </div>
233
+ {limited.notice !== null && (
234
+ <div className={styles['hiddenNotice']}>
235
+ {limited.notice}
236
+ <button
237
+ type="button"
238
+ className={styles['showAllButton']}
239
+ onClick={() => setRenderAll(true)}
240
+ >
241
+ {DETAIL_STRINGS.timeline.showAll}
242
+ </button>
243
+ </div>
244
+ )}
245
+ <ol className={styles['timeline']} ref={listRef}>
246
+ {limited.rows.map((row) =>
247
+ row.type === 'gap' ? (
248
+ <li
249
+ key={row.key}
250
+ className={styles['gap']}
251
+ role="note"
252
+ data-testid="agent-sidecar-detail-gap"
253
+ >
254
+ {row.label}
255
+ </li>
256
+ ) : (
257
+ <EventRow
258
+ key={row.key}
259
+ row={row}
260
+ expanded={expandedKeys.has(row.key)}
261
+ onToggleExpand={toggleExpand}
262
+ />
263
+ ),
264
+ )}
265
+ </ol>
266
+ </>
267
+ )}
268
+ </div>
269
+ )
270
+ }