@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shendeguize/dsh-agent-sidecar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Agent Sidecar as a native DSH plugin: cross-agent monitoring, injection and bypass analysis / 跨 agent 监控、注入与旁路分析",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -7,17 +7,27 @@
|
|
|
7
7
|
* from the host wire types (epoch-seconds → epoch-ms conversion included).
|
|
8
8
|
*
|
|
9
9
|
* Interaction surface handed back to the owner:
|
|
10
|
-
* - `onFiltersChange` — time-window select / show-dead checkbox
|
|
11
|
-
* -
|
|
10
|
+
* - `onFiltersChange` — time-window select / show-dead checkbox / the
|
|
11
|
+
* top-bar status-filter badges (controlled, UX-01);
|
|
12
|
+
* - `onRefresh` — manual snapshot pull button; a Promise<boolean>
|
|
13
|
+
* return drives the in-flight/failure feedback (UX-07);
|
|
12
14
|
* - `onSelectSession` — card click, pass-through for the M3 detail view.
|
|
15
|
+
*
|
|
16
|
+
* Local UI state (deliberately NOT lifted into the controller stores):
|
|
17
|
+
* group collapse and per-group truncation (UX-02) are ephemeral view
|
|
18
|
+
* concerns — useState here, reset on tab remount.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
|
-
import
|
|
21
|
+
import { useState } from 'react'
|
|
22
|
+
import type { MouseEvent, ReactElement } from 'react'
|
|
16
23
|
import {
|
|
17
24
|
buildBoardViewModel,
|
|
18
|
-
timeWindowLabel,
|
|
19
25
|
formatTemplate,
|
|
26
|
+
sliceCardsForDisplay,
|
|
27
|
+
timeWindowLabel,
|
|
28
|
+
GROUP_CARD_LIMIT,
|
|
20
29
|
type BoardFilterState,
|
|
30
|
+
type BoardStatusFilter,
|
|
21
31
|
type DaemonStateToken,
|
|
22
32
|
type DerivedSessionCardVM,
|
|
23
33
|
type ProjectGroupVM,
|
|
@@ -41,7 +51,12 @@ export interface BoardProps {
|
|
|
41
51
|
/** Controlled filter state (owner persists it to the settings namespace). */
|
|
42
52
|
filters: BoardFilterState
|
|
43
53
|
onFiltersChange: (next: BoardFilterState) => void
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Manual snapshot pull. A `Promise<boolean>` return (true = snapshot
|
|
56
|
+
* applied) lets the board render the in-flight state and a dismissible
|
|
57
|
+
* failure notice; a void return keeps the button fire-and-forget.
|
|
58
|
+
*/
|
|
59
|
+
onRefresh: () => void | Promise<boolean>
|
|
45
60
|
onSelectSession: (sessionId: string) => void
|
|
46
61
|
/** Clock injection for deterministic rendering; defaults to Date.now(). */
|
|
47
62
|
nowMs?: number
|
|
@@ -52,6 +67,26 @@ function SessionCard(props: {
|
|
|
52
67
|
onSelect: (sessionId: string) => void
|
|
53
68
|
}): ReactElement {
|
|
54
69
|
const { card, onSelect } = props
|
|
70
|
+
const [copied, setCopied] = useState(false)
|
|
71
|
+
|
|
72
|
+
// UX-17: click the id row to copy the full session id. stopPropagation
|
|
73
|
+
// keeps the card's open-detail click intact; the row stays a non-focusable
|
|
74
|
+
// span because the card itself is already a <button> (no nested controls).
|
|
75
|
+
const onCopyId = (ev: MouseEvent): void => {
|
|
76
|
+
ev.stopPropagation()
|
|
77
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
78
|
+
if (clipboard === undefined) return
|
|
79
|
+
clipboard.writeText(card.sessionId).then(
|
|
80
|
+
() => {
|
|
81
|
+
setCopied(true)
|
|
82
|
+
setTimeout(() => { setCopied(false) }, 2000)
|
|
83
|
+
},
|
|
84
|
+
() => {
|
|
85
|
+
// Clipboard permission denied: the hover title still carries the id.
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
return (
|
|
56
91
|
<button
|
|
57
92
|
type="button"
|
|
@@ -79,8 +114,14 @@ function SessionCard(props: {
|
|
|
79
114
|
<div className={styles['cardTitle']} title={card.title}>
|
|
80
115
|
{card.title.trim() === '' ? BOARD_STRINGS.card.untitled : card.title}
|
|
81
116
|
</div>
|
|
82
|
-
<div
|
|
117
|
+
<div
|
|
118
|
+
className={styles['cardId']}
|
|
119
|
+
title={`${card.sessionId}\n${BOARD_STRINGS.card.copyId}`}
|
|
120
|
+
onClick={onCopyId}
|
|
121
|
+
data-testid="agent-sidecar-card-id"
|
|
122
|
+
>
|
|
83
123
|
{card.shortId}
|
|
124
|
+
{copied && <span className={styles['copied']} role="status">{BOARD_STRINGS.card.copied}</span>}
|
|
84
125
|
</div>
|
|
85
126
|
<div className={styles['cardEvent']}>
|
|
86
127
|
{card.lastEvent === null
|
|
@@ -97,9 +138,25 @@ function ProjectGroup(props: {
|
|
|
97
138
|
onSelect: (sessionId: string) => void
|
|
98
139
|
}): ReactElement {
|
|
99
140
|
const { group, onSelect } = props
|
|
141
|
+
// UX-02: collapse + truncation are per-group ephemeral view state.
|
|
142
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
143
|
+
const [expanded, setExpanded] = useState(false)
|
|
144
|
+
const { shown, hiddenCount } = sliceCardsForDisplay(group.cards, GROUP_CARD_LIMIT, expanded)
|
|
145
|
+
// Honesty guard: a collapsed group must not silently hide waiting
|
|
146
|
+
// sessions, so the header keeps a waiting counter while folded.
|
|
147
|
+
const waitingInGroup = group.cards.filter((card) => card.badge.status === 'waiting').length
|
|
100
148
|
return (
|
|
101
149
|
<section className={styles['group']}>
|
|
102
|
-
<
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={styles['groupHead']}
|
|
153
|
+
aria-expanded={!collapsed}
|
|
154
|
+
title={collapsed ? BOARD_STRINGS.group.expandTitle : BOARD_STRINGS.group.collapseTitle}
|
|
155
|
+
onClick={() => { setCollapsed(!collapsed) }}
|
|
156
|
+
>
|
|
157
|
+
<span className={styles['chevron']} aria-hidden>
|
|
158
|
+
{collapsed ? '▸' : '▾'}
|
|
159
|
+
</span>
|
|
103
160
|
<span
|
|
104
161
|
className={styles['groupName']}
|
|
105
162
|
title={group.fullPath === '' ? undefined : group.fullPath}
|
|
@@ -109,12 +166,39 @@ function ProjectGroup(props: {
|
|
|
109
166
|
<span className={styles['groupCount']}>
|
|
110
167
|
{formatTemplate(BOARD_STRINGS.groupCount, { n: group.cards.length })}
|
|
111
168
|
</span>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
117
|
-
</
|
|
169
|
+
{collapsed && waitingInGroup > 0 && (
|
|
170
|
+
<span className={styles['groupAttention']}>
|
|
171
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: waitingInGroup })}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</button>
|
|
175
|
+
{!collapsed && (
|
|
176
|
+
<>
|
|
177
|
+
<div className={styles['grid']}>
|
|
178
|
+
{shown.map((card) => (
|
|
179
|
+
<SessionCard key={`${card.agent}:${card.sessionId}`} card={card} onSelect={onSelect} />
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
{hiddenCount > 0 && (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className={styles['showMore']}
|
|
186
|
+
onClick={() => { setExpanded(true) }}
|
|
187
|
+
>
|
|
188
|
+
{formatTemplate(BOARD_STRINGS.group.showAll, { n: group.cards.length })}
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
{expanded && group.cards.length > GROUP_CARD_LIMIT && (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className={styles['showMore']}
|
|
195
|
+
onClick={() => { setExpanded(false) }}
|
|
196
|
+
>
|
|
197
|
+
{formatTemplate(BOARD_STRINGS.group.showLess, { n: GROUP_CARD_LIMIT })}
|
|
198
|
+
</button>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
118
202
|
</section>
|
|
119
203
|
)
|
|
120
204
|
}
|
|
@@ -136,6 +220,36 @@ export function Board(props: BoardProps): ReactElement {
|
|
|
136
220
|
? TIME_WINDOW_OPTIONS
|
|
137
221
|
: [...TIME_WINDOW_OPTIONS, props.filters.timeWindowHours].sort((a, b) => a - b)
|
|
138
222
|
|
|
223
|
+
// UX-07: manual-refresh feedback (in-flight + dismissible failure line).
|
|
224
|
+
const [refreshing, setRefreshing] = useState(false)
|
|
225
|
+
const [refreshFailed, setRefreshFailed] = useState(false)
|
|
226
|
+
const onRefreshClick = (): void => {
|
|
227
|
+
if (refreshing) return
|
|
228
|
+
setRefreshFailed(false)
|
|
229
|
+
const result = props.onRefresh()
|
|
230
|
+
if (result instanceof Promise) {
|
|
231
|
+
setRefreshing(true)
|
|
232
|
+
result
|
|
233
|
+
.then((ok) => { setRefreshFailed(!ok) })
|
|
234
|
+
.catch(() => { setRefreshFailed(true) })
|
|
235
|
+
.finally(() => { setRefreshing(false) })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// UX-01: the working/waiting badges toggle the status-only view.
|
|
240
|
+
const toggleStatusFilter = (status: BoardStatusFilter): void => {
|
|
241
|
+
const next: BoardFilterState = { ...props.filters }
|
|
242
|
+
if (next.statusFilter === status) delete next.statusFilter
|
|
243
|
+
else next.statusFilter = status
|
|
244
|
+
props.onFiltersChange(next)
|
|
245
|
+
}
|
|
246
|
+
const statusBadgeTitle = (status: BoardStatusFilter): string =>
|
|
247
|
+
props.filters.statusFilter === status
|
|
248
|
+
? BOARD_STRINGS.topbar.clearStatusFilterTitle
|
|
249
|
+
: formatTemplate(BOARD_STRINGS.topbar.filterByStatusTitle, {
|
|
250
|
+
label: BOARD_STRINGS.status[status],
|
|
251
|
+
})
|
|
252
|
+
|
|
139
253
|
return (
|
|
140
254
|
<div className={styles['root']} data-testid="agent-sidecar-board">
|
|
141
255
|
<header className={styles['topbar']}>
|
|
@@ -148,6 +262,31 @@ export function Board(props: BoardProps): ReactElement {
|
|
|
148
262
|
<span className={styles['dot']} data-tone={vm.streamTone} />
|
|
149
263
|
{vm.streamLabel}
|
|
150
264
|
</span>
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className={styles['countBadge']}
|
|
268
|
+
aria-pressed={props.filters.statusFilter === 'working'}
|
|
269
|
+
title={statusBadgeTitle('working')}
|
|
270
|
+
onClick={() => { toggleStatusFilter('working') }}
|
|
271
|
+
data-testid="agent-sidecar-count-working"
|
|
272
|
+
>
|
|
273
|
+
<span className={styles['dot']} data-tone={vm.workingCount > 0 ? 'success' : 'neutral'} />
|
|
274
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWorking, { n: vm.workingCount })}
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
className={styles['countBadge']}
|
|
279
|
+
aria-pressed={props.filters.statusFilter === 'waiting'}
|
|
280
|
+
title={statusBadgeTitle('waiting')}
|
|
281
|
+
onClick={() => { toggleStatusFilter('waiting') }}
|
|
282
|
+
data-testid="agent-sidecar-count-waiting"
|
|
283
|
+
>
|
|
284
|
+
<span className={styles['dot']} data-tone={vm.waitingCount > 0 ? 'warn' : 'neutral'} />
|
|
285
|
+
{formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: vm.waitingCount })}
|
|
286
|
+
</button>
|
|
287
|
+
<span className={styles['countTotal']} data-testid="agent-sidecar-count-total">
|
|
288
|
+
{formatTemplate(BOARD_STRINGS.topbar.countTotal, { n: vm.totalCount })}
|
|
289
|
+
</span>
|
|
151
290
|
<span className={styles['spacer']} />
|
|
152
291
|
<label className={styles['control']}>
|
|
153
292
|
{BOARD_STRINGS.topbar.timeWindow}
|
|
@@ -183,12 +322,26 @@ export function Board(props: BoardProps): ReactElement {
|
|
|
183
322
|
type="button"
|
|
184
323
|
className={styles['refresh']}
|
|
185
324
|
title={BOARD_STRINGS.topbar.refreshTitle}
|
|
186
|
-
|
|
325
|
+
disabled={refreshing}
|
|
326
|
+
onClick={onRefreshClick}
|
|
187
327
|
>
|
|
188
|
-
{BOARD_STRINGS.topbar.refresh}
|
|
328
|
+
{refreshing ? BOARD_STRINGS.topbar.refreshing : BOARD_STRINGS.topbar.refresh}
|
|
189
329
|
</button>
|
|
190
330
|
</header>
|
|
191
331
|
|
|
332
|
+
{refreshFailed && (
|
|
333
|
+
<div className={styles['banner']} data-tone="warn" role="status">
|
|
334
|
+
{BOARD_STRINGS.topbar.refreshFailed}
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
className={styles['bannerDismiss']}
|
|
338
|
+
onClick={() => { setRefreshFailed(false) }}
|
|
339
|
+
>
|
|
340
|
+
{BOARD_STRINGS.topbar.dismiss}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
192
345
|
{vm.banner !== null && (
|
|
193
346
|
<div className={styles['banner']} data-tone={vm.banner.tone} role="status">
|
|
194
347
|
{vm.banner.text}
|
|
@@ -76,6 +76,41 @@
|
|
|
76
76
|
background: var(--dsw-alias-label-dimmed, #8c959f);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/* UX-01: clickable working/waiting count badges + the total counter. */
|
|
80
|
+
|
|
81
|
+
.countBadge {
|
|
82
|
+
display: inline-flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
gap: 5px;
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
font-family: inherit;
|
|
87
|
+
line-height: 20px;
|
|
88
|
+
padding: 0 8px;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
|
|
91
|
+
background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.03));
|
|
92
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
93
|
+
white-space: nowrap;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.countBadge:hover {
|
|
98
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
99
|
+
border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
100
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.countBadge[aria-pressed='true'] {
|
|
104
|
+
color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
105
|
+
border-color: var(--dsw-alias-brand-primary, #4d6bfe);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.countTotal {
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
}
|
|
113
|
+
|
|
79
114
|
.spacer {
|
|
80
115
|
flex: 1;
|
|
81
116
|
}
|
|
@@ -121,6 +156,11 @@
|
|
|
121
156
|
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
122
157
|
}
|
|
123
158
|
|
|
159
|
+
.refresh:disabled {
|
|
160
|
+
cursor: default;
|
|
161
|
+
opacity: 0.6;
|
|
162
|
+
}
|
|
163
|
+
|
|
124
164
|
/* -------------------------------------------------------------- banner */
|
|
125
165
|
|
|
126
166
|
.banner {
|
|
@@ -143,6 +183,18 @@
|
|
|
143
183
|
border-color: var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.24));
|
|
144
184
|
}
|
|
145
185
|
|
|
186
|
+
.bannerDismiss {
|
|
187
|
+
margin-left: 10px;
|
|
188
|
+
padding: 0;
|
|
189
|
+
border: none;
|
|
190
|
+
background: transparent;
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
font-family: inherit;
|
|
193
|
+
color: inherit;
|
|
194
|
+
text-decoration: underline;
|
|
195
|
+
cursor: pointer;
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
/* -------------------------------------------------------- empty states */
|
|
147
199
|
|
|
148
200
|
.empty {
|
|
@@ -178,11 +230,48 @@
|
|
|
178
230
|
gap: 8px;
|
|
179
231
|
}
|
|
180
232
|
|
|
233
|
+
/* Collapsible group header (UX-02): a full-width transparent button. */
|
|
181
234
|
.groupHead {
|
|
182
235
|
display: flex;
|
|
183
236
|
align-items: baseline;
|
|
184
237
|
gap: 8px;
|
|
185
238
|
min-width: 0;
|
|
239
|
+
width: 100%;
|
|
240
|
+
padding: 0;
|
|
241
|
+
border: none;
|
|
242
|
+
background: transparent;
|
|
243
|
+
font-family: inherit;
|
|
244
|
+
text-align: left;
|
|
245
|
+
cursor: pointer;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.chevron {
|
|
249
|
+
flex: none;
|
|
250
|
+
font-size: 10px;
|
|
251
|
+
color: var(--dsw-alias-label-tertiary, #6e7781);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.groupAttention {
|
|
255
|
+
flex: none;
|
|
256
|
+
font-size: 11px;
|
|
257
|
+
color: var(--dsw-alias-state-warn-primary, #9a6700);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.showMore {
|
|
261
|
+
align-self: flex-start;
|
|
262
|
+
font-size: 12px;
|
|
263
|
+
font-family: inherit;
|
|
264
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
265
|
+
background: transparent;
|
|
266
|
+
border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
267
|
+
border-radius: 6px;
|
|
268
|
+
padding: 2px 10px;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.showMore:hover {
|
|
273
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
274
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
186
275
|
}
|
|
187
276
|
|
|
188
277
|
.groupName {
|
|
@@ -286,6 +375,17 @@
|
|
|
286
375
|
overflow: hidden;
|
|
287
376
|
text-overflow: ellipsis;
|
|
288
377
|
white-space: nowrap;
|
|
378
|
+
cursor: copy;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.cardId:hover {
|
|
382
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.copied {
|
|
386
|
+
margin-left: 6px;
|
|
387
|
+
font-family: inherit;
|
|
388
|
+
color: var(--dsw-alias-state-success-primary, #1a7f37);
|
|
289
389
|
}
|
|
290
390
|
|
|
291
391
|
.cardEvent {
|
|
@@ -58,10 +58,20 @@ export interface SessionCardVM {
|
|
|
58
58
|
gap: boolean
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Statuses the top-bar count badges can filter down to (UX-01). */
|
|
62
|
+
export type BoardStatusFilter = 'working' | 'waiting'
|
|
63
|
+
|
|
61
64
|
/** Board filter controls (wired to ui.time-window-hours / ui.show-dead). */
|
|
62
65
|
export interface BoardFilterState {
|
|
63
66
|
timeWindowHours: number
|
|
64
67
|
showDead: boolean
|
|
68
|
+
/**
|
|
69
|
+
* Status-only view toggled by the top-bar count badges (UX-01). While
|
|
70
|
+
* set, ONLY sessions of this status are visible — the time window and
|
|
71
|
+
* showDead do not apply (the user explicitly asked for the全板 answer
|
|
72
|
+
* to "who is working/waiting"). Absent = no status filter.
|
|
73
|
+
*/
|
|
74
|
+
statusFilter?: BoardStatusFilter
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** Derived status badge: color token + label + attention marker. */
|
|
@@ -69,8 +79,11 @@ export interface StatusBadgeVM {
|
|
|
69
79
|
status: SessionStatusToken
|
|
70
80
|
tone: BadgeTone
|
|
71
81
|
label: string
|
|
72
|
-
/**
|
|
73
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Per-session data-hole marker. The global stream-health notice is NOT
|
|
84
|
+
* mirrored here (UX-18): the top banner already carries it once.
|
|
85
|
+
*/
|
|
86
|
+
attention: 'gap' | null
|
|
74
87
|
attentionLabel: string | null
|
|
75
88
|
}
|
|
76
89
|
|
|
@@ -138,7 +151,9 @@ export interface BoardViewModel {
|
|
|
138
151
|
streamTone: BadgeTone
|
|
139
152
|
visibleCount: number
|
|
140
153
|
totalCount: number
|
|
154
|
+
/** Whole-board counts (window/filter independent — the honest answer). */
|
|
141
155
|
workingCount: number
|
|
156
|
+
waitingCount: number
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
// ---------------------------------------------------------------------------
|
|
@@ -204,6 +219,9 @@ const STATUS_TONE: Record<SessionStatusToken, BadgeTone> = {
|
|
|
204
219
|
|
|
205
220
|
/**
|
|
206
221
|
* Visibility rules (task spec):
|
|
222
|
+
* - an active `statusFilter` (UX-01) overrides everything: only sessions
|
|
223
|
+
* of that status are visible, regardless of window or showDead — the
|
|
224
|
+
* count badge and the filtered board therefore always agree;
|
|
207
225
|
* - dead sessions are hidden unless `showDead`;
|
|
208
226
|
* - working sessions are always visible (even outside the window);
|
|
209
227
|
* - everything else hides once `updatedAtMs` falls strictly beyond the
|
|
@@ -216,6 +234,7 @@ export function isSessionVisible(
|
|
|
216
234
|
nowMs: number,
|
|
217
235
|
): boolean {
|
|
218
236
|
const status = normalizeStatus(session.status)
|
|
237
|
+
if (filters.statusFilter !== undefined) return status === filters.statusFilter
|
|
219
238
|
if (status === 'dead' && !filters.showDead) return false
|
|
220
239
|
if (status === 'working') return true
|
|
221
240
|
const windowMs = filters.timeWindowHours * HOUR_MS
|
|
@@ -295,18 +314,15 @@ export function groupSessions<T extends SessionCardVM>(
|
|
|
295
314
|
// ---------------------------------------------------------------------------
|
|
296
315
|
|
|
297
316
|
/**
|
|
298
|
-
* status + gap
|
|
317
|
+
* status + gap → badge tone/label/attention.
|
|
299
318
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
319
|
+
* Card-level attention carries ONLY the per-session `gap` marker (a known
|
|
320
|
+
* data hole for THIS session). The global stream-health state is a
|
|
321
|
+
* board-wide fact and lives in the top banner alone — repeating it on
|
|
322
|
+
* every card was noise, not signal (UX-18). Unknown raw statuses keep
|
|
303
323
|
* their raw text as the label — the board never invents a state.
|
|
304
324
|
*/
|
|
305
|
-
export function deriveBadge(
|
|
306
|
-
rawStatus: string,
|
|
307
|
-
gap: boolean,
|
|
308
|
-
streamHealth: StreamHealthToken,
|
|
309
|
-
): StatusBadgeVM {
|
|
325
|
+
export function deriveBadge(rawStatus: string, gap: boolean): StatusBadgeVM {
|
|
310
326
|
const status = normalizeStatus(rawStatus)
|
|
311
327
|
const trimmed = rawStatus.trim()
|
|
312
328
|
const label =
|
|
@@ -315,9 +331,7 @@ export function deriveBadge(
|
|
|
315
331
|
? BOARD_STRINGS.status.unknown
|
|
316
332
|
: trimmed
|
|
317
333
|
: BOARD_STRINGS.status[status]
|
|
318
|
-
|
|
319
|
-
if (gap) attention = 'gap'
|
|
320
|
-
else if (streamHealth !== 'ok') attention = 'stale'
|
|
334
|
+
const attention: StatusBadgeVM['attention'] = gap ? 'gap' : null
|
|
321
335
|
return {
|
|
322
336
|
status,
|
|
323
337
|
tone: STATUS_TONE[status],
|
|
@@ -355,8 +369,22 @@ export function badgeHoverTitle(
|
|
|
355
369
|
// ---------------------------------------------------------------------------
|
|
356
370
|
|
|
357
371
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
372
|
+
* Absolute short timestamp in local time: `MM-DD HH:mm`. Used for ages
|
|
373
|
+
* beyond 24h where relative buckets stop discriminating (UX-13).
|
|
374
|
+
*/
|
|
375
|
+
export function formatAbsoluteShort(thenMs: number): string {
|
|
376
|
+
const date = new Date(thenMs)
|
|
377
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
378
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
|
|
379
|
+
date.getMinutes(),
|
|
380
|
+
)}`
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Card time: <60s (including clock skew into the future) is 刚刚, then
|
|
385
|
+
* whole minutes/hours; from 24h on the absolute short date takes over —
|
|
386
|
+
* a column of "3 天前" carries no information, "08-22 14:03" does
|
|
387
|
+
* (UX-13). Non-finite input renders empty.
|
|
360
388
|
*/
|
|
361
389
|
export function formatRelativeTime(thenMs: number, nowMs: number): string {
|
|
362
390
|
if (!Number.isFinite(thenMs)) return ''
|
|
@@ -368,7 +396,7 @@ export function formatRelativeTime(thenMs: number, nowMs: number): string {
|
|
|
368
396
|
if (delta < DAY_MS) {
|
|
369
397
|
return formatTemplate(BOARD_STRINGS.time.hoursAgo, { n: Math.floor(delta / HOUR_MS) })
|
|
370
398
|
}
|
|
371
|
-
return
|
|
399
|
+
return formatAbsoluteShort(thenMs)
|
|
372
400
|
}
|
|
373
401
|
|
|
374
402
|
/** Label for a time-window option: whole days as 天, otherwise 小时. */
|
|
@@ -490,6 +518,47 @@ export function abbreviateSessionId(id: string, max = 20): string {
|
|
|
490
518
|
return `${id.slice(0, 12)}…${id.slice(-6)}`
|
|
491
519
|
}
|
|
492
520
|
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Display truncation (UX-02 board groups / UX-20 project lanes).
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/** Cards a board group renders before the 「展开全部」 fold (UX-02). */
|
|
526
|
+
export const GROUP_CARD_LIMIT = 20
|
|
527
|
+
|
|
528
|
+
/** A truncated card list plus how many items the fold is hiding. */
|
|
529
|
+
export interface DisplaySlice<T> {
|
|
530
|
+
shown: T[]
|
|
531
|
+
hiddenCount: number
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Slice a status-sorted card list down to `limit` for display, unless
|
|
536
|
+
* `expanded`. The cut never lands inside the leading working/waiting run:
|
|
537
|
+
* attention-worthy sessions are the reason the board exists, so the
|
|
538
|
+
* effective limit grows to cover all of them (an all-active group renders
|
|
539
|
+
* fully — honest, and rare). Non-positive limits disable truncation.
|
|
540
|
+
*/
|
|
541
|
+
export function sliceCardsForDisplay<T extends { status: string }>(
|
|
542
|
+
cards: readonly T[],
|
|
543
|
+
limit: number,
|
|
544
|
+
expanded: boolean,
|
|
545
|
+
): DisplaySlice<T> {
|
|
546
|
+
if (expanded || limit <= 0 || cards.length <= limit) {
|
|
547
|
+
return { shown: [...cards], hiddenCount: 0 }
|
|
548
|
+
}
|
|
549
|
+
let activeRun = 0
|
|
550
|
+
while (activeRun < cards.length) {
|
|
551
|
+
const status = normalizeStatus(cards[activeRun]!.status)
|
|
552
|
+
if (status !== 'working' && status !== 'waiting') break
|
|
553
|
+
activeRun += 1
|
|
554
|
+
}
|
|
555
|
+
const effectiveLimit = Math.max(limit, activeRun)
|
|
556
|
+
return {
|
|
557
|
+
shown: cards.slice(0, effectiveLimit),
|
|
558
|
+
hiddenCount: cards.length - Math.min(cards.length, effectiveLimit),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
493
562
|
// ---------------------------------------------------------------------------
|
|
494
563
|
// Footer widget derivation.
|
|
495
564
|
// ---------------------------------------------------------------------------
|
|
@@ -510,15 +579,23 @@ export function deriveWidgetConnection(
|
|
|
510
579
|
return 'degraded'
|
|
511
580
|
}
|
|
512
581
|
|
|
513
|
-
/** Count of sessions
|
|
514
|
-
export function
|
|
582
|
+
/** Count of sessions observed in one normalized status. */
|
|
583
|
+
export function countByStatus(
|
|
584
|
+
sessions: ReadonlyArray<{ status: string }>,
|
|
585
|
+
status: SessionStatusToken,
|
|
586
|
+
): number {
|
|
515
587
|
let count = 0
|
|
516
588
|
for (const session of sessions) {
|
|
517
|
-
if (normalizeStatus(session.status) ===
|
|
589
|
+
if (normalizeStatus(session.status) === status) count += 1
|
|
518
590
|
}
|
|
519
591
|
return count
|
|
520
592
|
}
|
|
521
593
|
|
|
594
|
+
/** Count of sessions currently observed as working. */
|
|
595
|
+
export function countWorking(sessions: ReadonlyArray<{ status: string }>): number {
|
|
596
|
+
return countByStatus(sessions, 'working')
|
|
597
|
+
}
|
|
598
|
+
|
|
522
599
|
/** Widget hover/aria text: connection state, plus the count when nonzero. */
|
|
523
600
|
export function widgetTitle(connection: WidgetConnection, workingCount: number): string {
|
|
524
601
|
const base = `${BOARD_STRINGS.widget.label}: ${BOARD_STRINGS.widget.connection[connection]}`
|
|
@@ -536,7 +613,7 @@ export function buildBoardViewModel(input: BoardComputeInput): BoardViewModel {
|
|
|
536
613
|
const visible = filterSessions(sessions, filters, nowMs)
|
|
537
614
|
const derived: DerivedSessionCardVM[] = visible.map((session) => ({
|
|
538
615
|
...session,
|
|
539
|
-
badge: deriveBadge(session.status, session.gap
|
|
616
|
+
badge: deriveBadge(session.status, session.gap),
|
|
540
617
|
glyph: agentGlyph(session.agent),
|
|
541
618
|
shortId: abbreviateSessionId(session.sessionId),
|
|
542
619
|
relativeTime: formatRelativeTime(session.updatedAtMs, nowMs),
|
|
@@ -552,5 +629,6 @@ export function buildBoardViewModel(input: BoardComputeInput): BoardViewModel {
|
|
|
552
629
|
visibleCount: visible.length,
|
|
553
630
|
totalCount: sessions.length,
|
|
554
631
|
workingCount: countWorking(sessions),
|
|
632
|
+
waitingCount: countByStatus(sessions, 'waiting'),
|
|
555
633
|
}
|
|
556
634
|
}
|
|
@@ -55,6 +55,9 @@ export const PROJECT_VIEW_STRINGS = {
|
|
|
55
55
|
liveChip: '实时',
|
|
56
56
|
/** Untitled-session fallback. */
|
|
57
57
|
untitled: '(无标题)',
|
|
58
|
+
/** Lane truncation fold (UX-20; mirrors the board's group fold). */
|
|
59
|
+
showAllSessions: '展开全部 {n} 个会话',
|
|
60
|
+
showLessSessions: '只看前 {n} 个',
|
|
58
61
|
/** Empty state (no groups at all). */
|
|
59
62
|
empty: {
|
|
60
63
|
title: '暂无项目关联',
|
|
@@ -68,6 +71,9 @@ export const PROJECT_VIEW_STRINGS = {
|
|
|
68
71
|
|
|
69
72
|
export type ProjectViewStrings = typeof PROJECT_VIEW_STRINGS
|
|
70
73
|
|
|
74
|
+
/** Rows a lane renders before the 「展开全部」 fold (UX-20). */
|
|
75
|
+
export const LANE_SESSION_LIMIT = 10
|
|
76
|
+
|
|
71
77
|
// ---------------------------------------------------------------------------
|
|
72
78
|
// Input view models (hand-written wire mirror, see module doc).
|
|
73
79
|
// ---------------------------------------------------------------------------
|
|
@@ -224,9 +230,9 @@ function deriveSession(session: ProjectSessionVM, nowMs: number): DerivedProject
|
|
|
224
230
|
lastActivityAt: session.lastActivityAt,
|
|
225
231
|
live,
|
|
226
232
|
gap,
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
badge: deriveBadge(session.status, gap
|
|
233
|
+
// Badge attention reflects only the per-session gap marker (the badge
|
|
234
|
+
// vocabulary has no global-stream mirror at card level, UX-18).
|
|
235
|
+
badge: deriveBadge(session.status, gap),
|
|
230
236
|
glyph: agentGlyph(session.agent),
|
|
231
237
|
shortId: abbreviateSessionId(session.sessionId),
|
|
232
238
|
relativeTime: formatRelativeTime(session.lastActivityAt, nowMs),
|
|
@@ -195,6 +195,24 @@
|
|
|
195
195
|
gap: 4px;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/* UX-20: lane truncation fold (same look as the board's group fold). */
|
|
199
|
+
.showMore {
|
|
200
|
+
align-self: flex-start;
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
font-family: inherit;
|
|
203
|
+
color: var(--dsw-alias-label-secondary, #57606a);
|
|
204
|
+
background: transparent;
|
|
205
|
+
border: 1px dashed var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
|
206
|
+
border-radius: 6px;
|
|
207
|
+
padding: 2px 10px;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.showMore:hover {
|
|
212
|
+
color: var(--dsw-alias-label-primary, #1f2328);
|
|
213
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
|
|
214
|
+
}
|
|
215
|
+
|
|
198
216
|
/* -------------------------------------------------------- session rows */
|
|
199
217
|
|
|
200
218
|
.session {
|