@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shendeguize/dsh-agent-sidecar",
3
- "version": "0.1.0",
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 (controlled);
11
- * - `onRefresh` — manual snapshot pull button;
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 type { ReactElement } from 'react'
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
- onRefresh: () => void
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 className={styles['cardId']} title={card.sessionId}>
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
- <div className={styles['groupHead']}>
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
- </div>
113
- <div className={styles['grid']}>
114
- {group.cards.map((card) => (
115
- <SessionCard key={`${card.agent}:${card.sessionId}`} card={card} onSelect={onSelect} />
116
- ))}
117
- </div>
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
- onClick={props.onRefresh}
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
- /** 'gap' (per-session data hole) outranks 'stale' (global stream health). */
73
- attention: 'gap' | 'stale' | null
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 + streamHealth → badge tone/label/attention.
317
+ * status + gap → badge tone/label/attention.
299
318
  *
300
- * Priority: a per-session `gap` marker (a known data hole for THIS session)
301
- * outranks the global stale marker (stream reconnecting affects everyone
302
- * and is already surfaced by the top banner). Unknown raw statuses keep
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
- let attention: StatusBadgeVM['attention'] = null
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
- * Coarse relative time: <60s (including clock skew into the future) is
359
- * 刚刚, then whole minutes/hours/days. Non-finite input renders empty.
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 formatTemplate(BOARD_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
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 currently observed as working. */
514
- export function countWorking(sessions: ReadonlyArray<{ status: string }>): number {
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) === 'working') count += 1
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, streamHealth),
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
- // 'ok' stream health: the project view has no global stream signal,
228
- // so badge attention reflects only the per-session gap marker.
229
- badge: deriveBadge(session.status, gap, 'ok'),
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 {