@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,380 @@
1
+ /**
2
+ * Browser-half data layer: same-origin fetch wrappers for the plugin's
3
+ * self-registered route namespace (design §5.3; server: src/routes.ts).
4
+ *
5
+ * Wire types below are hand-mirrored from the host half (routes.ts /
6
+ * session-store.ts / supervisor.ts / bridge.ts response shapes): the
7
+ * client TS program cannot import host modules because they live in the
8
+ * node-typed host program. The API_PREFIX mirror is pinned against the
9
+ * routes.ts constant by test/client-data.test.ts.
10
+ *
11
+ * Calls are same-origin relative paths with no auth headers (ADR-8 trust
12
+ * posture). Browser primitives — fetch, AbortController, timers — are
13
+ * injectable so everything runs under plain node in tests; defaults
14
+ * resolve from globalThis at call time. Pure data layer: no React, no
15
+ * slots SDK; the UI half imports its types from here.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ /** Route namespace; must mirror routes.ts `API_PREFIX` (pinned by test). */
21
+ export const API_PREFIX = '/plugins/agent-sidecar/api'
22
+
23
+ /** Default overall request deadline (design §5.3: 15s fetch timeout). */
24
+ export const DEFAULT_TIMEOUT_MS = 15_000
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Wire types (host source of truth noted per type).
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Daemon supervisor state machine states (host: supervisor.ts). */
31
+ export type SupervisorState =
32
+ | 'probe'
33
+ | 'adopted'
34
+ | 'defer'
35
+ | 'reprobe'
36
+ | 'hosting'
37
+ | 'hosted'
38
+ | 'backoff'
39
+ | 'failed'
40
+
41
+ /** Health of the host↔daemon subscribe stream (host: bridge.ts). */
42
+ export type StreamHealth = 'ok' | 'degraded' | 'unknown'
43
+
44
+ /** Daemon self-description from the Unix-socket ping (host: supervisor.ts). */
45
+ export interface PingInfo {
46
+ pid: number
47
+ version: string
48
+ http: { enabled: boolean; host?: string; port?: number }
49
+ }
50
+
51
+ /** Compact most-recent-event summary per session (host: session-store.ts). */
52
+ export interface SessionEventSummary {
53
+ ts: string
54
+ kind: string
55
+ text: string
56
+ }
57
+
58
+ /** One board row (host: session-store.ts). */
59
+ export interface SessionView {
60
+ agent: string
61
+ session_id: string
62
+ status: string
63
+ title: string
64
+ project: string
65
+ updated_at: number
66
+ last_event: SessionEventSummary | null
67
+ /** True when a dsh seq discontinuity was observed since the last snapshot. */
68
+ gap: boolean
69
+ }
70
+
71
+ /** Full board state (host: session-store.ts). */
72
+ export interface BoardState {
73
+ sessions: SessionView[]
74
+ streamHealth: StreamHealth
75
+ lastReconcileAt: number | null
76
+ }
77
+
78
+ /** Body of `GET state` and of every SSE `state` event (host: routes.ts). */
79
+ export interface StateSnapshot {
80
+ daemon: { state: SupervisorState; lastPing: PingInfo | null }
81
+ board: BoardState
82
+ capabilities: { inject: boolean }
83
+ }
84
+
85
+ /** Body of `GET session/<id>` (host: routes.ts, M1 shape). */
86
+ export interface SessionDetail {
87
+ session: SessionView
88
+ /** Always null in M1; the event timeline lands in M3. */
89
+ timeline: null
90
+ timelineNote: string
91
+ }
92
+
93
+ /** `POST action` body for injection phase one (host: routes.ts `handlePrepare`). */
94
+ export interface PrepareActionBody {
95
+ type: 'inject.prepare'
96
+ target: { agent: string; sessionId: string }
97
+ mode: 'queue' | 'steer'
98
+ message: string
99
+ }
100
+
101
+ /** `POST action` body for injection phase two (host: routes.ts `handleExecute`). */
102
+ export interface ExecuteActionBody {
103
+ type: 'inject.execute'
104
+ requestId: string
105
+ confirmToken: string
106
+ message: string
107
+ }
108
+
109
+ /** `POST action` body for daemon management (host: routes.ts `handleAction`). */
110
+ export interface DaemonRetryActionBody {
111
+ type: 'daemon.retry'
112
+ }
113
+
114
+ /** Analysis target selector (host: routes.ts `AnalysisTargetRequest`). */
115
+ export type AnalysisTargetKind = 'session' | 'project' | 'cross-agent'
116
+
117
+ /** `POST action` body starting one analysis (host: routes.ts, M3). */
118
+ export interface AnalysisRequestActionBody {
119
+ type: 'analysis.request'
120
+ targetKind: AnalysisTargetKind
121
+ /** Session id / project path; required for session and project kinds. */
122
+ targetId?: string
123
+ question?: string
124
+ }
125
+
126
+ /** `POST action` body asking a follow-up in a live analysis session. */
127
+ export interface AnalysisFollowupActionBody {
128
+ type: 'analysis.followup'
129
+ analysisSessionId: string
130
+ question: string
131
+ }
132
+
133
+ /** `POST action` body releasing an analysis session (idempotent). */
134
+ export interface AnalysisCancelActionBody {
135
+ type: 'analysis.cancel'
136
+ analysisSessionId: string
137
+ }
138
+
139
+ /**
140
+ * The action dispatcher envelope (host: routes.ts `handleAction`):
141
+ * a `{type, ...}` union with the phase fields at the top level. M2 inject
142
+ * phases + daemon management + the M3 `analysis.*` trio.
143
+ */
144
+ export type ActionEnvelope =
145
+ | PrepareActionBody
146
+ | ExecuteActionBody
147
+ | DaemonRetryActionBody
148
+ | AnalysisRequestActionBody
149
+ | AnalysisFollowupActionBody
150
+ | AnalysisCancelActionBody
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Injectable browser primitives (structural, so node tests can fake them).
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Opaque timer handle so DOM numbers and fake-timer handles interoperate. */
157
+ export type TimerHandle = unknown
158
+
159
+ export interface AbortSignalLike {
160
+ readonly aborted: boolean
161
+ addEventListener(type: 'abort', listener: () => void): void
162
+ removeEventListener(type: 'abort', listener: () => void): void
163
+ }
164
+
165
+ export interface AbortControllerLike {
166
+ readonly signal: AbortSignalLike
167
+ abort(): void
168
+ }
169
+
170
+ export interface ResponseLike {
171
+ ok: boolean
172
+ status: number
173
+ json(): Promise<unknown>
174
+ }
175
+
176
+ export interface RequestInitLike {
177
+ method: string
178
+ headers?: Record<string, string>
179
+ body?: string
180
+ signal?: AbortSignalLike
181
+ }
182
+
183
+ export type FetchLike = (url: string, init: RequestInitLike) => Promise<ResponseLike>
184
+
185
+ /** Injectable primitives; every field defaults to the globalThis flavor. */
186
+ export interface ApiDeps {
187
+ fetch?: FetchLike
188
+ createAbortController?: () => AbortControllerLike
189
+ setTimeout?: (fn: () => void, ms: number) => TimerHandle
190
+ clearTimeout?: (handle: TimerHandle) => void
191
+ }
192
+
193
+ /** Per-call options for the request helpers. */
194
+ export interface RequestOptions extends ApiDeps {
195
+ /** Overall deadline; default {@link DEFAULT_TIMEOUT_MS}. */
196
+ timeoutMs?: number
197
+ /** Caller-side cancellation; aborting it aborts the underlying fetch. */
198
+ signal?: AbortSignalLike
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Normalized errors.
203
+ // ---------------------------------------------------------------------------
204
+
205
+ export type ApiErrorKind = 'timeout' | 'aborted' | 'network' | 'http' | 'parse'
206
+
207
+ /**
208
+ * Single normalized failure shape for every request path, so the UI has
209
+ * one catch surface. `reason` carries the server `{reason}` envelope for
210
+ * kind 'http' and a stable local code otherwise.
211
+ */
212
+ export class ApiError extends Error {
213
+ readonly kind: ApiErrorKind
214
+ readonly reason: string
215
+ /** HTTP status when a response was received, else null. */
216
+ readonly status: number | null
217
+
218
+ constructor(
219
+ kind: ApiErrorKind,
220
+ reason: string,
221
+ status: number | null = null,
222
+ cause?: unknown,
223
+ ) {
224
+ super(
225
+ status === null ? `api ${kind}: ${reason}` : `api ${kind}: ${reason} (http ${status})`,
226
+ cause === undefined ? undefined : { cause },
227
+ )
228
+ this.name = 'ApiError'
229
+ this.kind = kind
230
+ this.reason = reason
231
+ this.status = status
232
+ }
233
+ }
234
+
235
+ export function isApiError(value: unknown): value is ApiError {
236
+ return value instanceof ApiError
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Defaults (resolved lazily so late-stubbed globals and fake timers work).
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
244
+ globalThis.setTimeout(fn, ms)
245
+
246
+ const defaultClearTimeout = (handle: TimerHandle): void => {
247
+ globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
248
+ }
249
+
250
+ const defaultCreateAbortController = (): AbortControllerLike => new AbortController()
251
+
252
+ function resolveFetch(opts: ApiDeps): FetchLike {
253
+ if (opts.fetch !== undefined) return opts.fetch
254
+ // DOM fetch is only nominally stricter than FetchLike (its RequestInit
255
+ // wants a branded AbortSignal); every value this module actually passes
256
+ // is a real one when the default controller factory is in play.
257
+ return globalThis.fetch as unknown as FetchLike
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Core request helper.
262
+ // ---------------------------------------------------------------------------
263
+
264
+ interface RequestInitInput {
265
+ method: string
266
+ headers?: Record<string, string>
267
+ body?: string
268
+ }
269
+
270
+ async function request(
271
+ path: string,
272
+ init: RequestInitInput,
273
+ opts: RequestOptions,
274
+ ): Promise<unknown> {
275
+ const doFetch = resolveFetch(opts)
276
+ const controller = (opts.createAbortController ?? defaultCreateAbortController)()
277
+ const setT = opts.setTimeout ?? defaultSetTimeout
278
+ const clearT = opts.clearTimeout ?? defaultClearTimeout
279
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
280
+
281
+ let timedOut = false
282
+ let externallyAborted = false
283
+ const timer = setT(() => {
284
+ timedOut = true
285
+ controller.abort()
286
+ }, timeoutMs)
287
+
288
+ const external = opts.signal
289
+ const onExternalAbort = (): void => {
290
+ externallyAborted = true
291
+ controller.abort()
292
+ }
293
+ if (external !== undefined) {
294
+ if (external.aborted) onExternalAbort()
295
+ else external.addEventListener('abort', onExternalAbort)
296
+ }
297
+
298
+ try {
299
+ let res: ResponseLike
300
+ try {
301
+ res = await doFetch(path, { ...init, signal: controller.signal })
302
+ } catch (err) {
303
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
304
+ if (externallyAborted) throw new ApiError('aborted', 'request_aborted', null, err)
305
+ throw new ApiError('network', 'network_error', null, err)
306
+ }
307
+ if (!res.ok) {
308
+ // routes.ts answers most failures with a `{reason}` envelope; the M2
309
+ // inject.execute failure answers the result view itself ({outcome:
310
+ // 'failed', errorCode}) with no reason key, so the vocabulary code is
311
+ // read as the fallback. Status-derived reason covers non-JSON bodies.
312
+ let reason = `http_${res.status}`
313
+ try {
314
+ const body = await res.json()
315
+ if (typeof body === 'object' && body !== null) {
316
+ const record = body as Record<string, unknown>
317
+ const value = record['reason']
318
+ const code = record['errorCode']
319
+ if (typeof value === 'string' && value !== '') reason = value
320
+ else if (typeof code === 'string' && code !== '') reason = code
321
+ }
322
+ } catch {
323
+ // Non-JSON error body: the fallback reason stands.
324
+ }
325
+ throw new ApiError('http', reason, res.status)
326
+ }
327
+ try {
328
+ return await res.json()
329
+ } catch (err) {
330
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
331
+ throw new ApiError('parse', 'invalid_json', res.status, err)
332
+ }
333
+ } finally {
334
+ clearT(timer)
335
+ if (external !== undefined) external.removeEventListener('abort', onExternalAbort)
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Public surface.
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /** `GET <prefix>/state` — full board snapshot. */
344
+ export async function fetchState(opts: RequestOptions = {}): Promise<StateSnapshot> {
345
+ return (await request(`${API_PREFIX}/state`, { method: 'GET' }, opts)) as StateSnapshot
346
+ }
347
+
348
+ /**
349
+ * `GET <prefix>/session/<id>` — single-session detail. Unknown ids reject
350
+ * with an ApiError carrying the server's `session_not_found` reason.
351
+ */
352
+ export async function fetchSession(
353
+ sessionId: string,
354
+ opts: RequestOptions = {},
355
+ ): Promise<SessionDetail> {
356
+ const path = `${API_PREFIX}/session/${encodeURIComponent(sessionId)}`
357
+ return (await request(path, { method: 'GET' }, opts)) as SessionDetail
358
+ }
359
+
360
+ /**
361
+ * `POST <prefix>/action` — transport layer only: the envelope is passed
362
+ * through verbatim, failures are normalized, and there is deliberately NO
363
+ * retry here — requestId idempotency and the `delivery:unknown` no-retry
364
+ * rule (S6) are gateway/UI policy, not transport policy.
365
+ */
366
+ export async function postAction(
367
+ body: ActionEnvelope,
368
+ opts: RequestOptions = {},
369
+ ): Promise<unknown> {
370
+ return request(
371
+ `${API_PREFIX}/action`,
372
+ {
373
+ method: 'POST',
374
+ // The guard requires application/json on POST (415 otherwise).
375
+ headers: { 'content-type': 'application/json' },
376
+ body: JSON.stringify(body),
377
+ },
378
+ opts,
379
+ )
380
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Cross-agent session board (design §5.1 view 1).
3
+ *
4
+ * Presentation-only: no data fetching, no api/sse imports. Everything
5
+ * arrives through props already shaped as the view models of `logic.ts`;
6
+ * the integration layer (T2.4) owns state, transport, and the mapping
7
+ * from the host wire types (epoch-seconds → epoch-ms conversion included).
8
+ *
9
+ * Interaction surface handed back to the owner:
10
+ * - `onFiltersChange` — time-window select / show-dead checkbox (controlled);
11
+ * - `onRefresh` — manual snapshot pull button;
12
+ * - `onSelectSession` — card click, pass-through for the M3 detail view.
13
+ */
14
+
15
+ import type { ReactElement } from 'react'
16
+ import {
17
+ buildBoardViewModel,
18
+ timeWindowLabel,
19
+ formatTemplate,
20
+ type BoardFilterState,
21
+ type DaemonStateToken,
22
+ type DerivedSessionCardVM,
23
+ type ProjectGroupVM,
24
+ type SessionCardVM,
25
+ type StreamHealthToken,
26
+ } from './logic.ts'
27
+ import { BOARD_STRINGS } from './strings.ts'
28
+ import styles from './board.module.css'
29
+
30
+ /** Time-window choices offered by the top bar (hours). */
31
+ export const TIME_WINDOW_OPTIONS: readonly number[] = [6, 12, 24, 48, 168]
32
+
33
+ export interface BoardProps {
34
+ daemonState: DaemonStateToken
35
+ /** Optional raw detail for the daemon badge hover (e.g. "pid 123 · v0.6.0"). */
36
+ daemonDetail?: string
37
+ streamHealth: StreamHealthToken
38
+ /** Epoch ms of the last authoritative snapshot reconcile, or null. */
39
+ lastReconcileAtMs: number | null
40
+ sessions: SessionCardVM[]
41
+ /** Controlled filter state (owner persists it to the settings namespace). */
42
+ filters: BoardFilterState
43
+ onFiltersChange: (next: BoardFilterState) => void
44
+ onRefresh: () => void
45
+ onSelectSession: (sessionId: string) => void
46
+ /** Clock injection for deterministic rendering; defaults to Date.now(). */
47
+ nowMs?: number
48
+ }
49
+
50
+ function SessionCard(props: {
51
+ card: DerivedSessionCardVM
52
+ onSelect: (sessionId: string) => void
53
+ }): ReactElement {
54
+ const { card, onSelect } = props
55
+ return (
56
+ <button
57
+ type="button"
58
+ className={styles['card']}
59
+ onClick={() => onSelect(card.sessionId)}
60
+ data-testid="agent-sidecar-card"
61
+ >
62
+ <div className={styles['cardHead']}>
63
+ <span className={styles['agent']}>
64
+ <span className={styles['glyph']} aria-hidden>
65
+ {card.glyph}
66
+ </span>
67
+ {card.agent}
68
+ </span>
69
+ <span className={styles['badge']} data-tone={card.badge.tone} title={card.hoverTitle}>
70
+ <span className={styles['dot']} data-tone={card.badge.tone} />
71
+ {card.badge.label}
72
+ {card.badge.attention !== null && (
73
+ <span className={styles['attention']} data-kind={card.badge.attention}>
74
+ {card.badge.attentionLabel}
75
+ </span>
76
+ )}
77
+ </span>
78
+ </div>
79
+ <div className={styles['cardTitle']} title={card.title}>
80
+ {card.title.trim() === '' ? BOARD_STRINGS.card.untitled : card.title}
81
+ </div>
82
+ <div className={styles['cardId']} title={card.sessionId}>
83
+ {card.shortId}
84
+ </div>
85
+ <div className={styles['cardEvent']}>
86
+ {card.lastEvent === null
87
+ ? BOARD_STRINGS.card.noEvent
88
+ : `${card.lastEvent.kind} · ${card.lastEvent.text}`}
89
+ </div>
90
+ <div className={styles['cardTime']}>{card.relativeTime}</div>
91
+ </button>
92
+ )
93
+ }
94
+
95
+ function ProjectGroup(props: {
96
+ group: ProjectGroupVM<DerivedSessionCardVM>
97
+ onSelect: (sessionId: string) => void
98
+ }): ReactElement {
99
+ const { group, onSelect } = props
100
+ return (
101
+ <section className={styles['group']}>
102
+ <div className={styles['groupHead']}>
103
+ <span
104
+ className={styles['groupName']}
105
+ title={group.fullPath === '' ? undefined : group.fullPath}
106
+ >
107
+ {group.label}
108
+ </span>
109
+ <span className={styles['groupCount']}>
110
+ {formatTemplate(BOARD_STRINGS.groupCount, { n: group.cards.length })}
111
+ </span>
112
+ </div>
113
+ <div className={styles['grid']}>
114
+ {group.cards.map((card) => (
115
+ <SessionCard key={`${card.agent}:${card.sessionId}`} card={card} onSelect={onSelect} />
116
+ ))}
117
+ </div>
118
+ </section>
119
+ )
120
+ }
121
+
122
+ /** The board view. Pure render of `buildBoardViewModel` over the props. */
123
+ export function Board(props: BoardProps): ReactElement {
124
+ const nowMs = props.nowMs ?? Date.now()
125
+ const vm = buildBoardViewModel({
126
+ sessions: props.sessions,
127
+ filters: props.filters,
128
+ daemonState: props.daemonState,
129
+ streamHealth: props.streamHealth,
130
+ lastReconcileAtMs: props.lastReconcileAtMs,
131
+ nowMs,
132
+ })
133
+ // Keep the select renderable even when the persisted setting is not one
134
+ // of the stock options (e.g. a hand-edited settings.yaml value).
135
+ const windowOptions = TIME_WINDOW_OPTIONS.includes(props.filters.timeWindowHours)
136
+ ? TIME_WINDOW_OPTIONS
137
+ : [...TIME_WINDOW_OPTIONS, props.filters.timeWindowHours].sort((a, b) => a - b)
138
+
139
+ return (
140
+ <div className={styles['root']} data-testid="agent-sidecar-board">
141
+ <header className={styles['topbar']}>
142
+ <span className={styles['title']}>{BOARD_STRINGS.topbar.title}</span>
143
+ <span className={styles['badge']} data-tone={vm.daemonBadge.tone} title={props.daemonDetail}>
144
+ <span className={styles['dot']} data-tone={vm.daemonBadge.tone} />
145
+ {vm.daemonBadge.label}
146
+ </span>
147
+ <span className={styles['badge']} data-tone={vm.streamTone}>
148
+ <span className={styles['dot']} data-tone={vm.streamTone} />
149
+ {vm.streamLabel}
150
+ </span>
151
+ <span className={styles['spacer']} />
152
+ <label className={styles['control']}>
153
+ {BOARD_STRINGS.topbar.timeWindow}
154
+ <select
155
+ className={styles['select']}
156
+ value={String(props.filters.timeWindowHours)}
157
+ onChange={(ev) =>
158
+ props.onFiltersChange({
159
+ ...props.filters,
160
+ timeWindowHours: Number(ev.target.value),
161
+ })
162
+ }
163
+ >
164
+ {windowOptions.map((hours) => (
165
+ <option key={hours} value={String(hours)}>
166
+ {timeWindowLabel(hours)}
167
+ </option>
168
+ ))}
169
+ </select>
170
+ </label>
171
+ <label className={styles['control']}>
172
+ <input
173
+ type="checkbox"
174
+ className={styles['checkbox']}
175
+ checked={props.filters.showDead}
176
+ onChange={(ev) =>
177
+ props.onFiltersChange({ ...props.filters, showDead: ev.target.checked })
178
+ }
179
+ />
180
+ {BOARD_STRINGS.topbar.showDead}
181
+ </label>
182
+ <button
183
+ type="button"
184
+ className={styles['refresh']}
185
+ title={BOARD_STRINGS.topbar.refreshTitle}
186
+ onClick={props.onRefresh}
187
+ >
188
+ {BOARD_STRINGS.topbar.refresh}
189
+ </button>
190
+ </header>
191
+
192
+ {vm.banner !== null && (
193
+ <div className={styles['banner']} data-tone={vm.banner.tone} role="status">
194
+ {vm.banner.text}
195
+ </div>
196
+ )}
197
+
198
+ {vm.emptyState !== null ? (
199
+ <div className={styles['empty']} data-kind={vm.emptyState.kind}>
200
+ <div className={styles['emptyTitle']}>{vm.emptyState.title}</div>
201
+ <div className={styles['emptyHint']}>{vm.emptyState.hint}</div>
202
+ </div>
203
+ ) : (
204
+ vm.groups.map((group) => (
205
+ <ProjectGroup
206
+ key={group.key === '' ? '\u0000unknown' : group.key}
207
+ group={group}
208
+ onSelect={props.onSelectSession}
209
+ />
210
+ ))
211
+ )}
212
+ </div>
213
+ )
214
+ }