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