@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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- 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
|
+
}
|