@shendeguize/dsh-agent-sidecar 0.1.0 → 0.1.1
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/lib/client.js +874 -168
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/board/Board.tsx +168 -15
- package/src/client/board/board.module.css +100 -0
- package/src/client/board/logic.ts +99 -21
- package/src/client/board/project-view-logic.ts +9 -3
- package/src/client/board/project-view.module.css +18 -0
- package/src/client/board/project-view.tsx +27 -1
- package/src/client/board/strings.ts +29 -5
- package/src/client/controller.ts +27 -7
- package/src/client/detail/SessionDetail.tsx +177 -14
- package/src/client/detail/detail.module.css +105 -2
- package/src/client/detail/logic.ts +220 -29
- package/src/client/detail/strings.ts +13 -0
- package/src/client/detail-glue.ts +33 -0
- package/src/client/detail-view.module.css +36 -3
- package/src/client/detail-view.tsx +76 -28
- package/src/client/dsh-tools/dsh-tools.module.css +6 -4
- package/src/client/inject/InjectPanel.tsx +57 -4
- package/src/client/inject/inject.module.css +68 -67
- package/src/client/inject/logic.ts +38 -0
- package/src/client/inject/overlay.module.css +1 -1
- package/src/client/locales/en.ts +17 -2
- package/src/client/locales/zh.ts +17 -2
- package/src/client/mount.tsx +3 -3
- package/src/client/widget.tsx +37 -12
|
@@ -14,15 +14,18 @@
|
|
|
14
14
|
* blanking data the user already has.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { useState } from 'react'
|
|
17
18
|
import type { ReactElement } from 'react'
|
|
18
19
|
import {
|
|
19
20
|
buildProjectViewModel,
|
|
21
|
+
LANE_SESSION_LIMIT,
|
|
20
22
|
PROJECT_VIEW_STRINGS,
|
|
21
23
|
type AgentLaneVM,
|
|
22
24
|
type DerivedProjectGroupVM,
|
|
23
25
|
type DerivedProjectSessionVM,
|
|
24
26
|
type ProjectGroupVM,
|
|
25
27
|
} from './project-view-logic.ts'
|
|
28
|
+
import { formatTemplate, sliceCardsForDisplay } from './logic.ts'
|
|
26
29
|
import styles from './project-view.module.css'
|
|
27
30
|
|
|
28
31
|
export interface ProjectViewProps {
|
|
@@ -74,6 +77,11 @@ function AgentLane(props: {
|
|
|
74
77
|
onSelect: (sessionId: string) => void
|
|
75
78
|
}): ReactElement {
|
|
76
79
|
const { lane, onSelect } = props
|
|
80
|
+
// UX-20: lanes fold past the row limit — ephemeral view state, same
|
|
81
|
+
// pattern as the board's group truncation. Rows are status-sorted, so
|
|
82
|
+
// the fold never hides a leading working/waiting run (slice guard).
|
|
83
|
+
const [expanded, setExpanded] = useState(false)
|
|
84
|
+
const { shown, hiddenCount } = sliceCardsForDisplay(lane.sessions, LANE_SESSION_LIMIT, expanded)
|
|
77
85
|
return (
|
|
78
86
|
<div className={styles['lane']}>
|
|
79
87
|
<div className={styles['laneHead']}>
|
|
@@ -83,7 +91,7 @@ function AgentLane(props: {
|
|
|
83
91
|
{lane.agent}
|
|
84
92
|
</div>
|
|
85
93
|
<div className={styles['laneSessions']}>
|
|
86
|
-
{
|
|
94
|
+
{shown.map((session) => (
|
|
87
95
|
<SessionRow
|
|
88
96
|
key={`${session.agent}:${session.sessionId}`}
|
|
89
97
|
session={session}
|
|
@@ -91,6 +99,24 @@ function AgentLane(props: {
|
|
|
91
99
|
/>
|
|
92
100
|
))}
|
|
93
101
|
</div>
|
|
102
|
+
{hiddenCount > 0 && (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={styles['showMore']}
|
|
106
|
+
onClick={() => { setExpanded(true) }}
|
|
107
|
+
>
|
|
108
|
+
{formatTemplate(PROJECT_VIEW_STRINGS.showAllSessions, { n: lane.sessions.length })}
|
|
109
|
+
</button>
|
|
110
|
+
)}
|
|
111
|
+
{expanded && lane.sessions.length > LANE_SESSION_LIMIT && (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className={styles['showMore']}
|
|
115
|
+
onClick={() => { setExpanded(false) }}
|
|
116
|
+
>
|
|
117
|
+
{formatTemplate(PROJECT_VIEW_STRINGS.showLessSessions, { n: LANE_SESSION_LIMIT })}
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
94
120
|
</div>
|
|
95
121
|
)
|
|
96
122
|
}
|
|
@@ -22,10 +22,13 @@ export const BOARD_STRINGS = {
|
|
|
22
22
|
dead: '已结束',
|
|
23
23
|
unknown: '未知',
|
|
24
24
|
},
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Badge attention markers. Only the per-session gap marker renders at
|
|
27
|
+
* card level; the global stream-health notice lives in the top banner
|
|
28
|
+
* alone (UX-18: repeating it on every card was pure noise).
|
|
29
|
+
*/
|
|
26
30
|
attention: {
|
|
27
31
|
gap: '事件缺口',
|
|
28
|
-
stale: '可能滞后',
|
|
29
32
|
},
|
|
30
33
|
/** Daemon supervisor state labels for the top-bar badge. */
|
|
31
34
|
daemon: {
|
|
@@ -58,7 +61,7 @@ export const BOARD_STRINGS = {
|
|
|
58
61
|
daemonDeferHint:
|
|
59
62
|
'检测到 LaunchAgent 托管,插件不会自行启动 daemon;服务拉起后看板会自动出数。',
|
|
60
63
|
filteredTitle: '当前过滤条件下没有会话',
|
|
61
|
-
filteredHint: '
|
|
64
|
+
filteredHint: '试试放宽时间窗、取消状态过滤,或打开「显示已结束」。',
|
|
62
65
|
noSessionsTitle: '暂无被观测的会话',
|
|
63
66
|
noSessionsHint:
|
|
64
67
|
'本机 agent(claude / codex / cursor / dsh …)开始工作后会自动出现在这里。',
|
|
@@ -67,9 +70,25 @@ export const BOARD_STRINGS = {
|
|
|
67
70
|
topbar: {
|
|
68
71
|
title: 'Sidecar 多 agent 看板',
|
|
69
72
|
refresh: '刷新',
|
|
73
|
+
refreshing: '刷新中…',
|
|
70
74
|
refreshTitle: '手动拉取最新快照',
|
|
75
|
+
refreshFailed: '手动刷新失败,看板仍显示原有快照',
|
|
76
|
+
dismiss: '知道了',
|
|
71
77
|
showDead: '显示已结束',
|
|
72
78
|
timeWindow: '时间窗',
|
|
79
|
+
/** Count badges (UX-01); working/waiting are clickable status filters. */
|
|
80
|
+
countWorking: '{n} 工作中',
|
|
81
|
+
countWaiting: '{n} 等待中',
|
|
82
|
+
countTotal: '共 {n} 个会话',
|
|
83
|
+
filterByStatusTitle: '只看「{label}」的会话',
|
|
84
|
+
clearStatusFilterTitle: '取消状态过滤,显示全部会话',
|
|
85
|
+
},
|
|
86
|
+
/** Group collapse / truncation controls (UX-02). */
|
|
87
|
+
group: {
|
|
88
|
+
collapseTitle: '收起该分组',
|
|
89
|
+
expandTitle: '展开该分组',
|
|
90
|
+
showAll: '展开全部 {n} 个会话',
|
|
91
|
+
showLess: '只看前 {n} 个',
|
|
73
92
|
},
|
|
74
93
|
/** Session card texts. */
|
|
75
94
|
card: {
|
|
@@ -80,13 +99,18 @@ export const BOARD_STRINGS = {
|
|
|
80
99
|
observedValue: '观察值: {status}',
|
|
81
100
|
lastReconcile: '最近对账: {time}',
|
|
82
101
|
neverReconciled: '尚未对账',
|
|
102
|
+
copyId: '点击复制完整会话 id',
|
|
103
|
+
copied: '已复制',
|
|
83
104
|
},
|
|
84
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Relative time templates. Ages ≥ 24h render as an absolute short date
|
|
107
|
+
* (`MM-DD HH:mm`, see formatRelativeTime) instead of a `{n} 天前` bucket
|
|
108
|
+
* (UX-13: whole columns of "3 天前" carried zero information).
|
|
109
|
+
*/
|
|
85
110
|
time: {
|
|
86
111
|
justNow: '刚刚',
|
|
87
112
|
minutesAgo: '{n} 分钟前',
|
|
88
113
|
hoursAgo: '{n} 小时前',
|
|
89
|
-
daysAgo: '{n} 天前',
|
|
90
114
|
},
|
|
91
115
|
/** Time-window option templates. */
|
|
92
116
|
timeWindow: {
|
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,25 +5,37 @@
|
|
|
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'
|
|
24
|
+
import { Fragment, useEffect, useRef, useState, type ReactElement } from 'react'
|
|
19
25
|
import {
|
|
26
|
+
aggregateChunkRows,
|
|
20
27
|
buildTimelineRows,
|
|
21
28
|
deriveDetailBodyState,
|
|
22
29
|
deriveDetailStatus,
|
|
23
30
|
deriveSourceBadges,
|
|
24
31
|
agentGlyph,
|
|
32
|
+
filterTimelineRows,
|
|
33
|
+
formatTemplate,
|
|
25
34
|
limitTimelineRows,
|
|
35
|
+
shouldStickToLatest,
|
|
26
36
|
DEFAULT_MAX_RENDER_ROWS,
|
|
37
|
+
type TimelineEventRowVM,
|
|
38
|
+
type TimelineFilterMode,
|
|
27
39
|
type TimelineRowVM,
|
|
28
40
|
type TimelineVM,
|
|
29
41
|
} from './logic.ts'
|
|
@@ -51,8 +63,12 @@ export interface SessionDetailProps {
|
|
|
51
63
|
hasMore: boolean
|
|
52
64
|
/** Listen mode (SSE-triggered newest-page refetch) currently on. */
|
|
53
65
|
listening: boolean
|
|
66
|
+
/** True while a manual newest-window refresh is in flight (UX-07). */
|
|
67
|
+
refreshing?: boolean
|
|
54
68
|
onLoadMore: () => void
|
|
55
69
|
onToggleListen: () => void
|
|
70
|
+
/** Manual newest-window refetch; the button renders only when given. */
|
|
71
|
+
onRefresh?: () => void
|
|
56
72
|
onClose?: () => void
|
|
57
73
|
/** Clock injection for deterministic rendering; defaults to Date.now(). */
|
|
58
74
|
nowMs?: number
|
|
@@ -61,7 +77,7 @@ export interface SessionDetailProps {
|
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
function EventRow(props: {
|
|
64
|
-
row:
|
|
80
|
+
row: TimelineEventRowVM
|
|
65
81
|
expanded: boolean
|
|
66
82
|
onToggleExpand: (key: string) => void
|
|
67
83
|
}): ReactElement {
|
|
@@ -86,7 +102,7 @@ function EventRow(props: {
|
|
|
86
102
|
)}
|
|
87
103
|
{row.isNew && <span className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</span>}
|
|
88
104
|
<span className={styles['eventSpacer']} />
|
|
89
|
-
<span className={styles['eventTime']}>{row.
|
|
105
|
+
<span className={styles['eventTime']}>{row.timeLabel}</span>
|
|
90
106
|
</div>
|
|
91
107
|
{entry.summary !== '' && <div className={styles['eventSummary']}>{entry.summary}</div>}
|
|
92
108
|
{entry.expandable && (
|
|
@@ -105,12 +121,58 @@ function EventRow(props: {
|
|
|
105
121
|
)
|
|
106
122
|
}
|
|
107
123
|
|
|
124
|
+
/** Collapsed run of adjacent streaming chunks (UX-03); expandable lossless. */
|
|
125
|
+
function ChunkRunRow(props: {
|
|
126
|
+
row: Extract<TimelineRowVM, { type: 'chunks' }>
|
|
127
|
+
expanded: boolean
|
|
128
|
+
onToggleRun: (key: string) => void
|
|
129
|
+
expandedKeys: ReadonlySet<string>
|
|
130
|
+
onToggleExpand: (key: string) => void
|
|
131
|
+
}): ReactElement {
|
|
132
|
+
const { row } = props
|
|
133
|
+
return (
|
|
134
|
+
<Fragment>
|
|
135
|
+
<li
|
|
136
|
+
className={styles['chunkRun']}
|
|
137
|
+
data-new={row.isNew || undefined}
|
|
138
|
+
title={row.hoverTitle}
|
|
139
|
+
data-testid="agent-sidecar-detail-chunks"
|
|
140
|
+
>
|
|
141
|
+
<span className={styles['chunkRunLabel']}>{row.label}</span>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
className={styles['expandButton']}
|
|
145
|
+
onClick={() => props.onToggleRun(row.key)}
|
|
146
|
+
>
|
|
147
|
+
{props.expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
|
|
148
|
+
</button>
|
|
149
|
+
<span className={styles['eventSpacer']} />
|
|
150
|
+
<span className={styles['eventTime']}>{row.timeLabel}</span>
|
|
151
|
+
</li>
|
|
152
|
+
{props.expanded &&
|
|
153
|
+
row.members.map((member) => (
|
|
154
|
+
<EventRow
|
|
155
|
+
key={member.key}
|
|
156
|
+
row={member}
|
|
157
|
+
expanded={props.expandedKeys.has(member.key)}
|
|
158
|
+
onToggleExpand={props.onToggleExpand}
|
|
159
|
+
/>
|
|
160
|
+
))}
|
|
161
|
+
</Fragment>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
108
165
|
/** The session-detail view. Pure render of the logic.ts pipelines over props. */
|
|
109
166
|
export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
110
167
|
const nowMs = props.nowMs ?? Date.now()
|
|
111
168
|
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(new Set())
|
|
169
|
+
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set())
|
|
170
|
+
const [filterMode, setFilterMode] = useState<TimelineFilterMode>('conversation')
|
|
112
171
|
const [renderAll, setRenderAll] = useState(false)
|
|
172
|
+
const [copied, setCopied] = useState(false)
|
|
113
173
|
const listRef = useRef<HTMLOListElement | null>(null)
|
|
174
|
+
const positionedRef = useRef(false)
|
|
175
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
114
176
|
|
|
115
177
|
const status = deriveDetailStatus(props.header.status)
|
|
116
178
|
const sourceBadges = deriveSourceBadges(props.timeline.sources)
|
|
@@ -121,19 +183,39 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
121
183
|
})
|
|
122
184
|
|
|
123
185
|
const allRows = buildTimelineRows(props.timeline, nowMs)
|
|
186
|
+
const filtered = filterTimelineRows(allRows, filterMode)
|
|
187
|
+
const aggregated = aggregateChunkRows(filtered.rows)
|
|
124
188
|
const limited = renderAll
|
|
125
|
-
? { rows:
|
|
126
|
-
: limitTimelineRows(
|
|
189
|
+
? { rows: aggregated, hiddenCount: 0, notice: null }
|
|
190
|
+
: limitTimelineRows(aggregated, props.maxRenderRows ?? DEFAULT_MAX_RENDER_ROWS)
|
|
127
191
|
|
|
128
192
|
const entryCount = props.timeline.entries.length
|
|
129
193
|
const listening = props.listening
|
|
130
194
|
useEffect(() => {
|
|
131
|
-
//
|
|
132
|
-
|
|
195
|
+
// UX-04 landing + listen-mode tail pinning (shouldStickToLatest):
|
|
196
|
+
// first non-empty render lands on the newest events; listen appends
|
|
197
|
+
// keep them in view; paging back never yanks the viewport.
|
|
133
198
|
const list = listRef.current
|
|
134
|
-
if (list
|
|
199
|
+
if (list === null) return
|
|
200
|
+
if (
|
|
201
|
+
shouldStickToLatest({
|
|
202
|
+
entryCount,
|
|
203
|
+
positioned: positionedRef.current,
|
|
204
|
+
listening,
|
|
205
|
+
})
|
|
206
|
+
) {
|
|
207
|
+
list.scrollTop = list.scrollHeight
|
|
208
|
+
positionedRef.current = true
|
|
209
|
+
}
|
|
135
210
|
}, [listening, entryCount])
|
|
136
211
|
|
|
212
|
+
useEffect(
|
|
213
|
+
() => () => {
|
|
214
|
+
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
|
|
215
|
+
},
|
|
216
|
+
[],
|
|
217
|
+
)
|
|
218
|
+
|
|
137
219
|
const toggleExpand = (key: string): void => {
|
|
138
220
|
setExpandedKeys((prev) => {
|
|
139
221
|
const next = new Set(prev)
|
|
@@ -143,6 +225,32 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
143
225
|
})
|
|
144
226
|
}
|
|
145
227
|
|
|
228
|
+
const toggleRun = (key: string): void => {
|
|
229
|
+
setExpandedRuns((prev) => {
|
|
230
|
+
const next = new Set(prev)
|
|
231
|
+
if (next.has(key)) next.delete(key)
|
|
232
|
+
else next.add(key)
|
|
233
|
+
return next
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const copySessionId = (): void => {
|
|
238
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
239
|
+
if (clipboard === undefined) return
|
|
240
|
+
clipboard.writeText(props.sessionId).then(
|
|
241
|
+
() => {
|
|
242
|
+
setCopied(true)
|
|
243
|
+
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
|
|
244
|
+
copyTimerRef.current = setTimeout(() => {
|
|
245
|
+
setCopied(false)
|
|
246
|
+
}, 2000)
|
|
247
|
+
},
|
|
248
|
+
() => {
|
|
249
|
+
// Clipboard permission denied: silently keep the plain id display.
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
146
254
|
return (
|
|
147
255
|
<div className={styles['root']} data-testid="agent-sidecar-detail">
|
|
148
256
|
<header className={styles['header']}>
|
|
@@ -163,6 +271,20 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
163
271
|
{status.label}
|
|
164
272
|
</span>
|
|
165
273
|
<span className={styles['spacer']} />
|
|
274
|
+
{props.onRefresh !== undefined && (
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
className={styles['refreshButton']}
|
|
278
|
+
disabled={props.refreshing === true}
|
|
279
|
+
title={DETAIL_STRINGS.header.refreshHint}
|
|
280
|
+
onClick={props.onRefresh}
|
|
281
|
+
data-testid="agent-sidecar-detail-refresh"
|
|
282
|
+
>
|
|
283
|
+
{props.refreshing === true
|
|
284
|
+
? DETAIL_STRINGS.header.refreshing
|
|
285
|
+
: DETAIL_STRINGS.header.refresh}
|
|
286
|
+
</button>
|
|
287
|
+
)}
|
|
166
288
|
<button
|
|
167
289
|
type="button"
|
|
168
290
|
className={styles['listenButton']}
|
|
@@ -183,9 +305,20 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
183
305
|
? DETAIL_STRINGS.header.unknownProject
|
|
184
306
|
: props.header.project}
|
|
185
307
|
</span>
|
|
186
|
-
<
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className={styles['sessionId']}
|
|
311
|
+
title={`${props.sessionId} · ${DETAIL_STRINGS.header.copyIdTitle}`}
|
|
312
|
+
onClick={copySessionId}
|
|
313
|
+
data-testid="agent-sidecar-detail-copy-id"
|
|
314
|
+
>
|
|
187
315
|
{props.sessionId}
|
|
188
|
-
</
|
|
316
|
+
</button>
|
|
317
|
+
{copied && (
|
|
318
|
+
<span className={styles['copiedBubble']} role="status">
|
|
319
|
+
{DETAIL_STRINGS.header.copied}
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
189
322
|
</div>
|
|
190
323
|
<div className={styles['metaRow']}>
|
|
191
324
|
<span className={styles['disclaimer']}>{DETAIL_STRINGS.header.observedDisclaimer}</span>
|
|
@@ -214,6 +347,27 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
214
347
|
</div>
|
|
215
348
|
) : (
|
|
216
349
|
<>
|
|
350
|
+
<div className={styles['filterRow']} data-testid="agent-sidecar-detail-filter">
|
|
351
|
+
{(['conversation', 'all'] as const).map((mode) => (
|
|
352
|
+
<button
|
|
353
|
+
key={mode}
|
|
354
|
+
type="button"
|
|
355
|
+
className={styles['filterChip']}
|
|
356
|
+
data-active={filterMode === mode || undefined}
|
|
357
|
+
aria-pressed={filterMode === mode}
|
|
358
|
+
onClick={() => {
|
|
359
|
+
setFilterMode(mode)
|
|
360
|
+
}}
|
|
361
|
+
>
|
|
362
|
+
{DETAIL_STRINGS.filter[mode]}
|
|
363
|
+
</button>
|
|
364
|
+
))}
|
|
365
|
+
{filtered.hiddenCount > 0 && (
|
|
366
|
+
<span className={styles['filterHiddenNote']}>
|
|
367
|
+
{formatTemplate(DETAIL_STRINGS.filter.hiddenNotice, { n: filtered.hiddenCount })}
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
217
371
|
<div className={styles['pager']}>
|
|
218
372
|
{props.hasMore ? (
|
|
219
373
|
<button
|
|
@@ -253,6 +407,15 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
|
|
|
253
407
|
>
|
|
254
408
|
{row.label}
|
|
255
409
|
</li>
|
|
410
|
+
) : row.type === 'chunks' ? (
|
|
411
|
+
<ChunkRunRow
|
|
412
|
+
key={row.key}
|
|
413
|
+
row={row}
|
|
414
|
+
expanded={expandedRuns.has(row.key)}
|
|
415
|
+
onToggleRun={toggleRun}
|
|
416
|
+
expandedKeys={expandedKeys}
|
|
417
|
+
onToggleExpand={toggleExpand}
|
|
418
|
+
/>
|
|
256
419
|
) : (
|
|
257
420
|
<EventRow
|
|
258
421
|
key={row.key}
|
|
@@ -128,7 +128,30 @@
|
|
|
128
128
|
.listenButton[data-active] {
|
|
129
129
|
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
130
130
|
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
131
|
-
|
|
131
|
+
/* brand tint from the real token (brand-secondary/tertiary do not exist
|
|
132
|
+
* in the dsh token table — UX-15). */
|
|
133
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.refreshButton {
|
|
137
|
+
font-size: 12px;
|
|
138
|
+
font-family: inherit;
|
|
139
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
140
|
+
background: transparent;
|
|
141
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
|
|
142
|
+
border-radius: 999px;
|
|
143
|
+
padding: 2px 12px;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.refreshButton:hover:enabled {
|
|
148
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
149
|
+
border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.refreshButton:disabled {
|
|
153
|
+
cursor: default;
|
|
154
|
+
opacity: 0.6;
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
.title {
|
|
@@ -155,15 +178,35 @@
|
|
|
155
178
|
white-space: nowrap;
|
|
156
179
|
}
|
|
157
180
|
|
|
181
|
+
/* Click-to-copy session id (UX-17): plain-text look, button semantics. */
|
|
158
182
|
.sessionId {
|
|
159
183
|
font-size: 11px;
|
|
160
184
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
161
185
|
color: var(--dsw-alias-label-tertiary, #6e7781);
|
|
186
|
+
background: transparent;
|
|
187
|
+
border: none;
|
|
188
|
+
padding: 0;
|
|
162
189
|
overflow: hidden;
|
|
163
190
|
text-overflow: ellipsis;
|
|
164
191
|
white-space: nowrap;
|
|
165
192
|
flex: none;
|
|
166
193
|
max-width: 40%;
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.sessionId:hover {
|
|
198
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
199
|
+
text-decoration: underline;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.copiedBubble {
|
|
203
|
+
flex: none;
|
|
204
|
+
font-size: 10px;
|
|
205
|
+
line-height: 16px;
|
|
206
|
+
padding: 0 6px;
|
|
207
|
+
border-radius: 999px;
|
|
208
|
+
color: var(--dsw-alias-state-success-primary, #1a7f37);
|
|
209
|
+
border: 1px solid var(--dsw-alias-state-success-secondary, rgba(26, 127, 55, 0.24));
|
|
167
210
|
}
|
|
168
211
|
|
|
169
212
|
.metaRow {
|
|
@@ -247,6 +290,43 @@
|
|
|
247
290
|
color: var(--dsw-alias-label-secondary, #57606a);
|
|
248
291
|
}
|
|
249
292
|
|
|
293
|
+
/* --------------------------------------- kind filter chips (UX-03) */
|
|
294
|
+
|
|
295
|
+
.filterRow {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: 6px;
|
|
299
|
+
flex-wrap: wrap;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.filterChip {
|
|
303
|
+
font-size: 11px;
|
|
304
|
+
font-family: inherit;
|
|
305
|
+
line-height: 18px;
|
|
306
|
+
padding: 0 10px;
|
|
307
|
+
border-radius: 999px;
|
|
308
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
309
|
+
background: transparent;
|
|
310
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
|
|
311
|
+
cursor: pointer;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.filterChip:hover {
|
|
315
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
316
|
+
border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.filterChip[data-active] {
|
|
320
|
+
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
321
|
+
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
322
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 8%, transparent);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.filterHiddenNote {
|
|
326
|
+
font-size: 11px;
|
|
327
|
+
color: var(--dsw-alias-label-tertiary, #6e7781);
|
|
328
|
+
}
|
|
329
|
+
|
|
250
330
|
/* --------------------------------------------------------------- pager */
|
|
251
331
|
|
|
252
332
|
.pager {
|
|
@@ -325,7 +405,30 @@
|
|
|
325
405
|
|
|
326
406
|
.event[data-new] {
|
|
327
407
|
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
328
|
-
|
|
408
|
+
/* brand tint from the real token (UX-15). */
|
|
409
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 6%, transparent);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* Aggregated streaming-chunk run (UX-03): quiet one-line stand-in. */
|
|
413
|
+
.chunkRun {
|
|
414
|
+
display: flex;
|
|
415
|
+
align-items: center;
|
|
416
|
+
gap: 8px;
|
|
417
|
+
font-size: 11px;
|
|
418
|
+
line-height: 16px;
|
|
419
|
+
padding: 4px 10px;
|
|
420
|
+
border-radius: 6px;
|
|
421
|
+
color: var(--dsw-alias-label-tertiary, #6e7781);
|
|
422
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
|
|
423
|
+
border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.chunkRun[data-new] {
|
|
427
|
+
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.chunkRunLabel {
|
|
431
|
+
flex: none;
|
|
329
432
|
}
|
|
330
433
|
|
|
331
434
|
.eventHead {
|