@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.
Files changed (58) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +4708 -2932
  3. package/lib/client.js.map +1 -1
  4. package/package.json +3 -1
  5. package/src/client/analysis/AnalysisPanel.tsx +19 -27
  6. package/src/client/analysis/analysis.module.css +36 -97
  7. package/src/client/board/Board.tsx +255 -48
  8. package/src/client/board/board.module.css +113 -92
  9. package/src/client/board/logic.ts +99 -21
  10. package/src/client/board/project-view-logic.ts +27 -34
  11. package/src/client/board/project-view.module.css +46 -83
  12. package/src/client/board/project-view.tsx +50 -8
  13. package/src/client/board/strings.ts +70 -85
  14. package/src/client/commands.ts +30 -15
  15. package/src/client/controller.ts +27 -7
  16. package/src/client/detail/SessionDetail.tsx +234 -37
  17. package/src/client/detail/detail.module.css +100 -153
  18. package/src/client/detail/logic.ts +220 -29
  19. package/src/client/detail/strings.ts +66 -77
  20. package/src/client/detail-glue.ts +33 -0
  21. package/src/client/detail-view.module.css +43 -35
  22. package/src/client/detail-view.tsx +138 -118
  23. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  24. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  25. package/src/client/dsh-tools/dsh-tools.module.css +53 -140
  26. package/src/client/dsh-tools/strings.ts +42 -77
  27. package/src/client/index.ts +285 -124
  28. package/src/client/inject/InjectPanel.tsx +81 -61
  29. package/src/client/inject/inject.module.css +97 -197
  30. package/src/client/inject/logic.ts +38 -0
  31. package/src/client/lifecycle/handoff.ts +83 -0
  32. package/src/client/locales/en.ts +91 -4
  33. package/src/client/locales/host.ts +131 -0
  34. package/src/client/locales/index.ts +196 -8
  35. package/src/client/locales/react.ts +10 -0
  36. package/src/client/locales/view.ts +38 -0
  37. package/src/client/locales/zh.ts +200 -125
  38. package/src/client/mount.tsx +75 -41
  39. package/src/client/navigation/CenterOverlay.tsx +40 -0
  40. package/src/client/navigation/center-overlay.module.css +51 -0
  41. package/src/client/navigation/center.ts +45 -0
  42. package/src/client/navigation/modal-isolation.ts +152 -0
  43. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  44. package/src/client/navigation/sidebar-entry.module.css +73 -0
  45. package/src/client/navigation/sidebar-entry.ts +309 -0
  46. package/src/client/primitives/StaticPill.tsx +21 -0
  47. package/src/client/settings-card.module.css +35 -119
  48. package/src/client/settings-card.tsx +31 -153
  49. package/src/client/settings-fields.tsx +131 -0
  50. package/src/client/sidebar/SidebarTab.tsx +156 -0
  51. package/src/client/sidebar/model.ts +65 -0
  52. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  53. package/src/client/sidebar-tab.tsx +46 -320
  54. package/src/client/theme/agsc.module.css +31 -0
  55. package/src/client/theme/parts.ts +56 -0
  56. package/src/client/ui-integration.ts +86 -0
  57. package/src/client/widget.tsx +42 -16
  58. package/src/client/inject/overlay.module.css +0 -22
@@ -1,9 +1,4 @@
1
- /*
2
- * Board styles (design §5.4): every color rides a --dsw-alias-* design
3
- * token with a neutral fallback — no theme colors are hardcoded as the
4
- * only value. Layout follows the dsh-project-kanban blueprint (flex top
5
- * bar, grouped sections, auto-fill card grid).
6
- */
1
+ /* Board layout consumes the public Agent Sidecar theme contract. */
7
2
 
8
3
  .root {
9
4
  display: flex;
@@ -14,8 +9,8 @@
14
9
  padding: 16px 20px;
15
10
  box-sizing: border-box;
16
11
  overflow-y: auto;
17
- color: var(--dsw-alias-label-primary, #1f2328);
18
- background: var(--dsw-alias-bg-base, transparent);
12
+ color: var(--agsc-fg);
13
+ background: var(--agsc-bg);
19
14
  }
20
15
 
21
16
  /* ------------------------------------------------------------- top bar */
@@ -30,50 +25,36 @@
30
25
  .title {
31
26
  font-size: 15px;
32
27
  font-weight: 650;
33
- color: var(--dsw-alias-label-primary, #1f2328);
28
+ color: var(--agsc-fg);
34
29
  margin-right: 2px;
35
30
  }
36
31
 
37
- .badge {
38
- display: inline-flex;
39
- align-items: center;
40
- gap: 5px;
41
- font-size: 12px;
42
- line-height: 20px;
43
- padding: 0 8px;
44
- border-radius: 999px;
45
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
46
- background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.03));
47
- color: var(--dsw-alias-label-secondary, #57606a);
48
- white-space: nowrap;
49
- }
50
-
51
32
  .dot {
52
33
  width: 8px;
53
34
  height: 8px;
54
35
  border-radius: 50%;
55
36
  flex: none;
56
- background: var(--dsw-alias-label-tertiary, #6e7781);
37
+ background: var(--dsw-alias-label-tertiary);
57
38
  }
58
39
 
59
- .dot[data-tone='success'] {
60
- background: var(--dsw-alias-state-success-primary, #1a7f37);
40
+ .dot[data-tone='neutral'] {
41
+ background: var(--dsw-alias-label-tertiary);
61
42
  }
62
43
 
63
- .dot[data-tone='warn'] {
64
- background: var(--dsw-alias-state-warn-primary, #9a6700);
44
+ .dot[data-tone='muted'] {
45
+ background: var(--agsc-fg-dimmed);
65
46
  }
66
47
 
67
- .dot[data-tone='danger'] {
68
- background: var(--dsw-alias-state-error-primary, #cf222e);
69
- }
48
+ /* UX-01: clickable working/waiting count badges + the total counter. */
70
49
 
71
- .dot[data-tone='neutral'] {
72
- background: var(--dsw-alias-label-tertiary, #6e7781);
50
+ .countBadge {
51
+ white-space: nowrap;
73
52
  }
74
53
 
75
- .dot[data-tone='muted'] {
76
- background: var(--dsw-alias-label-dimmed, #8c959f);
54
+ .countTotal {
55
+ font-size: 12px;
56
+ color: var(--agsc-fg-secondary);
57
+ white-space: nowrap;
77
58
  }
78
59
 
79
60
  .spacer {
@@ -85,62 +66,52 @@
85
66
  align-items: center;
86
67
  gap: 5px;
87
68
  font-size: 12px;
88
- color: var(--dsw-alias-label-secondary, #57606a);
69
+ color: var(--agsc-fg-secondary);
89
70
  white-space: nowrap;
90
71
  }
91
72
 
92
73
  .select {
74
+ height: 28px;
93
75
  font-size: 12px;
94
76
  font-family: inherit;
95
- color: var(--dsw-alias-label-primary, #1f2328);
96
- background: var(--dsw-alias-bg-layer-1, transparent);
97
- border: 1px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
98
- border-radius: 6px;
99
- padding: 2px 6px;
77
+ color: var(--agsc-fg);
78
+ background: var(--agsc-bg-raised);
79
+ border: 1px solid var(--agsc-border-strong);
80
+ border-radius: 14px;
81
+ padding: 0 10px;
100
82
  }
101
83
 
102
84
  .checkbox {
103
- accent-color: var(--dsw-alias-brand-primary, #4d6bfe);
85
+ accent-color: var(--agsc-accent);
104
86
  margin: 0;
105
87
  }
106
88
 
107
- .refresh {
108
- font-size: 12px;
109
- font-family: inherit;
110
- color: var(--dsw-alias-label-secondary, #57606a);
111
- background: transparent;
112
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
113
- border-radius: 6px;
114
- padding: 2px 10px;
115
- cursor: pointer;
116
- }
117
-
118
- .refresh:hover {
119
- color: var(--dsw-alias-label-primary, #1f2328);
120
- border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
121
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
122
- }
123
-
124
89
  /* -------------------------------------------------------------- banner */
125
90
 
126
91
  .banner {
127
92
  font-size: 12px;
128
93
  line-height: 18px;
129
94
  padding: 6px 10px;
130
- border-radius: 8px;
131
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
95
+ border-radius: var(--agsc-radius-control);
96
+ border: 1px solid var(--agsc-border);
132
97
  }
133
98
 
134
99
  .banner[data-tone='warn'] {
135
- color: var(--dsw-alias-state-warn-label, var(--dsw-alias-state-warn-primary, #9a6700));
136
- background: var(--dsw-alias-state-warn-tertiary, rgba(154, 103, 0, 0.08));
137
- border-color: var(--dsw-alias-state-warn-secondary, rgba(154, 103, 0, 0.24));
100
+ color: var(--agsc-warn);
101
+ background: var(--dsw-alias-state-warn-tertiary);
102
+ border-color: var(--dsw-alias-state-warn-secondary);
138
103
  }
139
104
 
140
105
  .banner[data-tone='danger'] {
141
- color: var(--dsw-alias-state-error-primary, #cf222e);
142
- background: var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.08));
143
- border-color: var(--dsw-alias-state-error-secondary, rgba(207, 34, 46, 0.24));
106
+ color: var(--agsc-err);
107
+ background: var(--dsw-alias-state-error-secondary);
108
+ border-color: var(--dsw-alias-state-error-secondary);
109
+ }
110
+
111
+ .bannerDismiss {
112
+ margin-left: 10px;
113
+ color: inherit;
114
+ text-decoration: underline;
144
115
  }
145
116
 
146
117
  /* -------------------------------------------------------- empty states */
@@ -160,14 +131,14 @@
160
131
  .emptyTitle {
161
132
  font-size: 14px;
162
133
  font-weight: 600;
163
- color: var(--dsw-alias-label-primary, #1f2328);
134
+ color: var(--agsc-fg);
164
135
  }
165
136
 
166
137
  .emptyHint {
167
138
  font-size: 12px;
168
139
  max-width: 420px;
169
140
  line-height: 20px;
170
- color: var(--dsw-alias-label-secondary, #57606a);
141
+ color: var(--agsc-fg-secondary);
171
142
  }
172
143
 
173
144
  /* -------------------------------------------------------- group blocks */
@@ -178,17 +149,41 @@
178
149
  gap: 8px;
179
150
  }
180
151
 
152
+ /* Collapsible group header (UX-02): a full-width transparent button. */
181
153
  .groupHead {
182
154
  display: flex;
183
155
  align-items: baseline;
184
156
  gap: 8px;
185
157
  min-width: 0;
158
+ width: 100%;
159
+ padding: 0;
160
+ border: none;
161
+ background: transparent;
162
+ font-family: inherit;
163
+ text-align: left;
164
+ cursor: pointer;
165
+ }
166
+
167
+ .chevron {
168
+ flex: none;
169
+ font-size: 10px;
170
+ color: var(--agsc-fg-dimmed);
171
+ }
172
+
173
+ .groupAttention {
174
+ flex: none;
175
+ font-size: 11px;
176
+ color: var(--agsc-warn);
177
+ }
178
+
179
+ .showMore {
180
+ align-self: flex-start;
186
181
  }
187
182
 
188
183
  .groupName {
189
184
  font-size: 13px;
190
185
  font-weight: 600;
191
- color: var(--dsw-alias-label-primary, #1f2328);
186
+ color: var(--agsc-fg);
192
187
  overflow: hidden;
193
188
  text-overflow: ellipsis;
194
189
  white-space: nowrap;
@@ -197,8 +192,8 @@
197
192
  .groupCount {
198
193
  font-size: 11px;
199
194
  flex: none;
200
- color: var(--dsw-alias-label-secondary, #57606a);
201
- background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.04));
195
+ color: var(--agsc-fg-secondary);
196
+ background: var(--dsw-alias-bg-layer-2);
202
197
  border-radius: 999px;
203
198
  padding: 0 8px;
204
199
  line-height: 18px;
@@ -216,24 +211,37 @@
216
211
  display: flex;
217
212
  flex-direction: column;
218
213
  gap: 6px;
219
- text-align: left;
220
- font-family: inherit;
221
214
  min-width: 0;
222
215
  padding: 10px 12px;
223
- border-radius: 8px;
224
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.08));
225
- background: var(--dsw-alias-bg-layer-1, rgba(0, 0, 0, 0.02));
226
- cursor: pointer;
216
+ border-radius: var(--agsc-radius-card);
217
+ border: 1px solid var(--agsc-border-strong);
218
+ background: var(--agsc-bg-raised);
227
219
  }
228
220
 
229
221
  .card:hover {
230
- border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
231
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
222
+ border-color: var(--dsw-alias-label-dimmed);
223
+ background: var(--dsw-alias-interactive-bg-hover);
224
+ }
225
+
226
+ .cardOpen {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 6px;
230
+ width: 100%;
231
+ min-width: 0;
232
+ padding: 0;
233
+ border: 0;
234
+ border-radius: var(--agsc-radius-control);
235
+ color: inherit;
236
+ background: none;
237
+ font: inherit;
238
+ text-align: left;
239
+ cursor: pointer;
232
240
  }
233
241
 
234
- .card:focus-visible {
235
- outline: 2px solid var(--dsw-alias-brand-primary, #4d6bfe);
236
- outline-offset: 1px;
242
+ .cardOpen:focus-visible {
243
+ outline: 2px solid var(--agsc-accent);
244
+ outline-offset: 2px;
237
245
  }
238
246
 
239
247
  .cardHead {
@@ -254,43 +262,56 @@
254
262
  overflow: hidden;
255
263
  text-overflow: ellipsis;
256
264
  white-space: nowrap;
257
- color: var(--dsw-alias-label-primary, #1f2328);
265
+ color: var(--agsc-fg);
258
266
  }
259
267
 
260
268
  .glyph {
261
269
  flex: none;
262
- color: var(--dsw-alias-brand-primary, #4d6bfe);
270
+ color: var(--agsc-accent);
271
+ }
272
+
273
+ .statusPill {
274
+ max-width: 100%;
263
275
  }
264
276
 
265
277
  .attention {
266
278
  font-size: 11px;
267
- color: var(--dsw-alias-state-warn-primary, #9a6700);
279
+ color: var(--agsc-warn);
268
280
  }
269
281
 
270
282
  .attention[data-kind='gap'] {
271
- color: var(--dsw-alias-state-error-primary, #cf222e);
283
+ color: var(--agsc-err);
272
284
  }
273
285
 
274
286
  .cardTitle {
275
287
  font-size: 13px;
276
- color: var(--dsw-alias-label-primary, #1f2328);
288
+ color: var(--agsc-fg);
277
289
  overflow: hidden;
278
290
  text-overflow: ellipsis;
279
291
  white-space: nowrap;
280
292
  }
281
293
 
282
294
  .cardId {
295
+ align-self: flex-start;
296
+ max-width: 100%;
297
+ justify-content: flex-start;
283
298
  font-size: 11px;
284
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
285
- color: var(--dsw-alias-label-tertiary, #6e7781);
299
+ font-family: var(--agsc-font-mono);
300
+ color: var(--agsc-fg-secondary);
286
301
  overflow: hidden;
287
302
  text-overflow: ellipsis;
288
303
  white-space: nowrap;
289
304
  }
290
305
 
306
+ .copied {
307
+ margin-left: 6px;
308
+ font-family: inherit;
309
+ color: var(--agsc-ok);
310
+ }
311
+
291
312
  .cardEvent {
292
313
  font-size: 12px;
293
- color: var(--dsw-alias-label-secondary, #57606a);
314
+ color: var(--agsc-fg-secondary);
294
315
  overflow: hidden;
295
316
  text-overflow: ellipsis;
296
317
  white-space: nowrap;
@@ -298,5 +319,5 @@
298
319
 
299
320
  .cardTime {
300
321
  font-size: 11px;
301
- color: var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary, #6e7781));
322
+ color: var(--agsc-fg-secondary);
302
323
  }
@@ -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
  }
@@ -13,13 +13,10 @@
13
13
  * and the group-level `lastActivityAt`. Only the fields this view renders
14
14
  * are mirrored; extra wire fields are structurally ignored.
15
15
  *
16
- * Reuse contract (T2.2, read-only): tone/glyph/relative-time/label tools
17
- * are imported from `./logic.ts` without touching its exports, so both
18
- * board views speak the same visual language. Strings NEW to this view
19
- * live in the module-local {@link PROJECT_VIEW_STRINGS} table (S7 unifies
20
- * locales later); strings emitted by reused board tools (status labels,
21
- * relative time, the unknown-project label) intentionally stay with the
22
- * board table so the two views can never drift apart.
16
+ * Reuse contract (T2.2): tone/glyph/relative-time/label tools are imported
17
+ * from `./logic.ts`, so both board views speak the same visual language.
18
+ * Project-only copy is exposed through the dynamic
19
+ * {@link PROJECT_VIEW_STRINGS} locale facade.
23
20
  *
24
21
  * @module
25
22
  */
@@ -35,39 +32,35 @@ import {
35
32
  statusRank,
36
33
  type StatusBadgeVM,
37
34
  } from './logic.ts'
35
+ import { createLocaleView } from '../locales/view.ts'
38
36
 
39
37
  // ---------------------------------------------------------------------------
40
- // Module-local strings (project-view-only vocabulary; Chinese primary).
38
+ // Project-view-only locale facade.
41
39
  // ---------------------------------------------------------------------------
42
40
 
43
- export const PROJECT_VIEW_STRINGS = {
44
- /** View heading. */
45
- title: '项目关联',
46
- /** Header count summary. */
47
- summary: '{projects} 个项目 · {sessions} 个会话',
48
- /** Cross-agent badge, shown when a project hosts 2+ agent kinds. */
49
- crossAgent: '{n} 种 agent',
50
- /** Group header session count. */
51
- sessionCount: '{n} 个会话',
52
- /** Group header recency line. */
53
- lastActive: '最近活跃 {time}',
54
- /** Marker on sessions live in this dsh process (UnifiedSession.live). */
55
- liveChip: '实时',
56
- /** Untitled-session fallback. */
57
- untitled: '(无标题)',
58
- /** Empty state (no groups at all). */
41
+ export const PROJECT_VIEW_STRINGS = createLocaleView({
42
+ title: 'project.title',
43
+ summary: 'project.summary',
44
+ crossAgent: 'project.crossAgent',
45
+ sessionCount: 'project.sessionCount',
46
+ lastActive: 'project.lastActive',
47
+ liveChip: 'project.liveChip',
48
+ untitled: 'project.untitled',
49
+ showAllSessions: 'project.showAllSessions',
50
+ showLessSessions: 'project.showLessSessions',
59
51
  empty: {
60
- title: '暂无项目关联',
61
- hint: '时间窗内没有跨 agent 的项目活动;agent 在某个项目目录下开始工作后会出现在这里。',
52
+ title: 'project.empty.title',
53
+ hint: 'project.empty.hint',
62
54
  },
63
- /** Loading placeholder (first fetch, nothing to show yet). */
64
- loading: '正在加载项目关联…',
65
- /** Error banner prefix; the raw error detail is appended by the view. */
66
- errorTitle: '项目关联加载失败',
67
- } as const
55
+ loading: 'project.loading',
56
+ errorTitle: 'project.errorTitle',
57
+ } as const)
68
58
 
69
59
  export type ProjectViewStrings = typeof PROJECT_VIEW_STRINGS
70
60
 
61
+ /** Rows a lane renders before the 「展开全部」 fold (UX-20). */
62
+ export const LANE_SESSION_LIMIT = 10
63
+
71
64
  // ---------------------------------------------------------------------------
72
65
  // Input view models (hand-written wire mirror, see module doc).
73
66
  // ---------------------------------------------------------------------------
@@ -224,9 +217,9 @@ function deriveSession(session: ProjectSessionVM, nowMs: number): DerivedProject
224
217
  lastActivityAt: session.lastActivityAt,
225
218
  live,
226
219
  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'),
220
+ // Badge attention reflects only the per-session gap marker (the badge
221
+ // vocabulary has no global-stream mirror at card level, UX-18).
222
+ badge: deriveBadge(session.status, gap),
230
223
  glyph: agentGlyph(session.agent),
231
224
  shortId: abbreviateSessionId(session.sessionId),
232
225
  relativeTime: formatRelativeTime(session.lastActivityAt, nowMs),