@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,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
|
+
}
|