@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
@@ -7,17 +7,28 @@
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
 
21
+ import { Button, Pill, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
22
+ import { useEffect, useRef, useState } from 'react'
15
23
  import type { ReactElement } from 'react'
16
24
  import {
17
25
  buildBoardViewModel,
18
- timeWindowLabel,
19
26
  formatTemplate,
27
+ sliceCardsForDisplay,
28
+ timeWindowLabel,
29
+ GROUP_CARD_LIMIT,
20
30
  type BoardFilterState,
31
+ type BoardStatusFilter,
21
32
  type DaemonStateToken,
22
33
  type DerivedSessionCardVM,
23
34
  type ProjectGroupVM,
@@ -25,6 +36,7 @@ import {
25
36
  type StreamHealthToken,
26
37
  } from './logic.ts'
27
38
  import { BOARD_STRINGS } from './strings.ts'
39
+ import { surfaceProps } from '../theme/parts.ts'
28
40
  import styles from './board.module.css'
29
41
 
30
42
  /** Time-window choices offered by the top bar (hours). */
@@ -41,54 +53,118 @@ export interface BoardProps {
41
53
  /** Controlled filter state (owner persists it to the settings namespace). */
42
54
  filters: BoardFilterState
43
55
  onFiltersChange: (next: BoardFilterState) => void
44
- onRefresh: () => void
56
+ /**
57
+ * Manual snapshot pull. A `Promise<boolean>` return (true = snapshot
58
+ * applied) lets the board render the in-flight state and a dismissible
59
+ * failure notice; a void return keeps the button fire-and-forget.
60
+ */
61
+ onRefresh: () => void | Promise<boolean>
45
62
  onSelectSession: (sessionId: string) => void
46
63
  /** Clock injection for deterministic rendering; defaults to Date.now(). */
47
64
  nowMs?: number
48
65
  }
49
66
 
67
+ function sessionDotState(status: DerivedSessionCardVM['badge']['status']): StateDotState | null {
68
+ if (status === 'working') return 'ongoing'
69
+ if (status === 'waiting') return 'warning'
70
+ return null
71
+ }
72
+
50
73
  function SessionCard(props: {
51
74
  card: DerivedSessionCardVM
52
75
  onSelect: (sessionId: string) => void
53
76
  }): ReactElement {
54
77
  const { card, onSelect } = props
78
+ const [copied, setCopied] = useState(false)
79
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
80
+ const copyAliveRef = useRef(true)
81
+ const dotState = sessionDotState(card.badge.status)
82
+
83
+ useEffect(() => {
84
+ copyAliveRef.current = true
85
+ return () => {
86
+ copyAliveRef.current = false
87
+ if (copyTimerRef.current !== null) {
88
+ clearTimeout(copyTimerRef.current)
89
+ copyTimerRef.current = null
90
+ }
91
+ }
92
+ }, [])
93
+
94
+ // UX-17: opening and copying are sibling buttons inside a semantic card,
95
+ // so both actions are independently keyboard reachable.
96
+ const onCopyId = (): void => {
97
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
98
+ if (clipboard === undefined) return
99
+ clipboard.writeText(card.sessionId).then(
100
+ () => {
101
+ if (!copyAliveRef.current) return
102
+ if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
103
+ setCopied(true)
104
+ copyTimerRef.current = setTimeout(() => {
105
+ copyTimerRef.current = null
106
+ setCopied(false)
107
+ }, 2000)
108
+ },
109
+ () => {
110
+ // Clipboard permission denied: the hover title still carries the id.
111
+ },
112
+ )
113
+ }
114
+
55
115
  return (
56
- <button
57
- type="button"
58
- className={styles['card']}
59
- onClick={() => onSelect(card.sessionId)}
116
+ <article
117
+ {...surfaceProps('board-card', styles['card'])}
60
118
  data-testid="agent-sidecar-card"
61
119
  >
62
- <div className={styles['cardHead']}>
63
- <span className={styles['agent']}>
64
- <span className={styles['glyph']} aria-hidden>
65
- {card.glyph}
120
+ <button
121
+ type="button"
122
+ className={styles['cardOpen']}
123
+ onClick={() => onSelect(card.sessionId)}
124
+ >
125
+ <span className={styles['cardHead']}>
126
+ <span className={styles['agent']}>
127
+ <span className={styles['glyph']} aria-hidden>
128
+ {card.glyph}
129
+ </span>
130
+ {card.agent}
131
+ </span>
132
+ <span title={card.hoverTitle}>
133
+ <Pill className={styles['statusPill']}>
134
+ {dotState === null
135
+ ? <span className={styles['dot']} data-tone={card.badge.tone} />
136
+ : <StateDot state={dotState} size={8} />}
137
+ {card.badge.label}
138
+ {card.badge.attention !== null && (
139
+ <span className={styles['attention']} data-kind={card.badge.attention}>
140
+ {card.badge.attentionLabel}
141
+ </span>
142
+ )}
143
+ </Pill>
66
144
  </span>
67
- {card.agent}
68
145
  </span>
69
- <span className={styles['badge']} data-tone={card.badge.tone} title={card.hoverTitle}>
70
- <span className={styles['dot']} data-tone={card.badge.tone} />
71
- {card.badge.label}
72
- {card.badge.attention !== null && (
73
- <span className={styles['attention']} data-kind={card.badge.attention}>
74
- {card.badge.attentionLabel}
75
- </span>
76
- )}
146
+ <span className={styles['cardTitle']} title={card.title}>
147
+ {card.title.trim() === '' ? BOARD_STRINGS.card.untitled : card.title}
77
148
  </span>
78
- </div>
79
- <div className={styles['cardTitle']} title={card.title}>
80
- {card.title.trim() === '' ? BOARD_STRINGS.card.untitled : card.title}
81
- </div>
82
- <div className={styles['cardId']} title={card.sessionId}>
149
+ </button>
150
+ <Button
151
+ size="sm"
152
+ variant="ghost"
153
+ className={styles['cardId']}
154
+ title={`${card.sessionId}\n${BOARD_STRINGS.card.copyId}`}
155
+ onClick={onCopyId}
156
+ data-testid="agent-sidecar-card-id"
157
+ >
83
158
  {card.shortId}
84
- </div>
159
+ {copied && <span className={styles['copied']} role="status">{BOARD_STRINGS.card.copied}</span>}
160
+ </Button>
85
161
  <div className={styles['cardEvent']}>
86
162
  {card.lastEvent === null
87
163
  ? BOARD_STRINGS.card.noEvent
88
164
  : `${card.lastEvent.kind} · ${card.lastEvent.text}`}
89
165
  </div>
90
166
  <div className={styles['cardTime']}>{card.relativeTime}</div>
91
- </button>
167
+ </article>
92
168
  )
93
169
  }
94
170
 
@@ -97,9 +173,25 @@ function ProjectGroup(props: {
97
173
  onSelect: (sessionId: string) => void
98
174
  }): ReactElement {
99
175
  const { group, onSelect } = props
176
+ // UX-02: collapse + truncation are per-group ephemeral view state.
177
+ const [collapsed, setCollapsed] = useState(false)
178
+ const [expanded, setExpanded] = useState(false)
179
+ const { shown, hiddenCount } = sliceCardsForDisplay(group.cards, GROUP_CARD_LIMIT, expanded)
180
+ // Honesty guard: a collapsed group must not silently hide waiting
181
+ // sessions, so the header keeps a waiting counter while folded.
182
+ const waitingInGroup = group.cards.filter((card) => card.badge.status === 'waiting').length
100
183
  return (
101
184
  <section className={styles['group']}>
102
- <div className={styles['groupHead']}>
185
+ <button
186
+ type="button"
187
+ className={styles['groupHead']}
188
+ aria-expanded={!collapsed}
189
+ title={collapsed ? BOARD_STRINGS.group.expandTitle : BOARD_STRINGS.group.collapseTitle}
190
+ onClick={() => { setCollapsed(!collapsed) }}
191
+ >
192
+ <span className={styles['chevron']} aria-hidden>
193
+ {collapsed ? '▸' : '▾'}
194
+ </span>
103
195
  <span
104
196
  className={styles['groupName']}
105
197
  title={group.fullPath === '' ? undefined : group.fullPath}
@@ -109,12 +201,41 @@ function ProjectGroup(props: {
109
201
  <span className={styles['groupCount']}>
110
202
  {formatTemplate(BOARD_STRINGS.groupCount, { n: group.cards.length })}
111
203
  </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>
204
+ {collapsed && waitingInGroup > 0 && (
205
+ <span className={styles['groupAttention']}>
206
+ {formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: waitingInGroup })}
207
+ </span>
208
+ )}
209
+ </button>
210
+ {!collapsed && (
211
+ <>
212
+ <div className={styles['grid']}>
213
+ {shown.map((card) => (
214
+ <SessionCard key={`${card.agent}:${card.sessionId}`} card={card} onSelect={onSelect} />
215
+ ))}
216
+ </div>
217
+ {hiddenCount > 0 && (
218
+ <Button
219
+ size="sm"
220
+ variant="outline"
221
+ className={styles['showMore']}
222
+ onClick={() => { setExpanded(true) }}
223
+ >
224
+ {formatTemplate(BOARD_STRINGS.group.showAll, { n: group.cards.length })}
225
+ </Button>
226
+ )}
227
+ {expanded && group.cards.length > GROUP_CARD_LIMIT && (
228
+ <Button
229
+ size="sm"
230
+ variant="outline"
231
+ className={styles['showMore']}
232
+ onClick={() => { setExpanded(false) }}
233
+ >
234
+ {formatTemplate(BOARD_STRINGS.group.showLess, { n: GROUP_CARD_LIMIT })}
235
+ </Button>
236
+ )}
237
+ </>
238
+ )}
118
239
  </section>
119
240
  )
120
241
  }
@@ -136,17 +257,88 @@ export function Board(props: BoardProps): ReactElement {
136
257
  ? TIME_WINDOW_OPTIONS
137
258
  : [...TIME_WINDOW_OPTIONS, props.filters.timeWindowHours].sort((a, b) => a - b)
138
259
 
260
+ // UX-07: manual-refresh feedback (in-flight + dismissible failure line).
261
+ const [refreshing, setRefreshing] = useState(false)
262
+ const [refreshFailed, setRefreshFailed] = useState(false)
263
+ const onRefreshClick = (): void => {
264
+ if (refreshing) return
265
+ setRefreshFailed(false)
266
+ const result = props.onRefresh()
267
+ if (result instanceof Promise) {
268
+ setRefreshing(true)
269
+ result
270
+ .then((ok) => { setRefreshFailed(!ok) })
271
+ .catch(() => { setRefreshFailed(true) })
272
+ .finally(() => { setRefreshing(false) })
273
+ }
274
+ }
275
+
276
+ // UX-01: the working/waiting badges toggle the status-only view.
277
+ const toggleStatusFilter = (status: BoardStatusFilter): void => {
278
+ const next: BoardFilterState = { ...props.filters }
279
+ if (next.statusFilter === status) delete next.statusFilter
280
+ else next.statusFilter = status
281
+ props.onFiltersChange(next)
282
+ }
283
+ const statusBadgeTitle = (status: BoardStatusFilter): string =>
284
+ props.filters.statusFilter === status
285
+ ? BOARD_STRINGS.topbar.clearStatusFilterTitle
286
+ : formatTemplate(BOARD_STRINGS.topbar.filterByStatusTitle, {
287
+ label: BOARD_STRINGS.status[status],
288
+ })
289
+ const daemonDotState: StateDotState =
290
+ props.daemonState === 'failed'
291
+ ? 'error'
292
+ : props.daemonState === 'defer' || props.daemonState === 'backoff'
293
+ ? 'warning'
294
+ : props.daemonState === 'adopted' || props.daemonState === 'hosted'
295
+ ? 'done'
296
+ : 'ongoing'
297
+ const streamDotState: StateDotState =
298
+ props.streamHealth === 'ok' ? 'done' : props.streamHealth === 'degraded' ? 'warning' : 'ongoing'
299
+
139
300
  return (
140
- <div className={styles['root']} data-testid="agent-sidecar-board">
141
- <header className={styles['topbar']}>
301
+ <div {...surfaceProps('board', styles['root'])} data-testid="agent-sidecar-board">
302
+ <header {...surfaceProps('board-toolbar', styles['topbar'])}>
142
303
  <span className={styles['title']}>{BOARD_STRINGS.topbar.title}</span>
143
- <span className={styles['badge']} data-tone={vm.daemonBadge.tone} title={props.daemonDetail}>
144
- <span className={styles['dot']} data-tone={vm.daemonBadge.tone} />
145
- {vm.daemonBadge.label}
304
+ <span title={props.daemonDetail}>
305
+ <Pill>
306
+ <StateDot state={daemonDotState} size={8} />
307
+ {vm.daemonBadge.label}
308
+ </Pill>
146
309
  </span>
147
- <span className={styles['badge']} data-tone={vm.streamTone}>
148
- <span className={styles['dot']} data-tone={vm.streamTone} />
310
+ <Pill>
311
+ <StateDot state={streamDotState} size={8} />
149
312
  {vm.streamLabel}
313
+ </Pill>
314
+ <Pill
315
+ className={styles['countBadge']}
316
+ active={props.filters.statusFilter === 'working'}
317
+ aria-pressed={props.filters.statusFilter === 'working'}
318
+ title={statusBadgeTitle('working')}
319
+ onClick={() => { toggleStatusFilter('working') }}
320
+ data-testid="agent-sidecar-count-working"
321
+ >
322
+ {vm.workingCount > 0
323
+ ? <StateDot state="ongoing" size={8} />
324
+ : <span className={styles['dot']} data-tone="neutral" />}
325
+ {formatTemplate(BOARD_STRINGS.topbar.countWorking, { n: vm.workingCount })}
326
+ </Pill>
327
+ <Pill
328
+ className={styles['countBadge']}
329
+ active={props.filters.statusFilter === 'waiting'}
330
+ aria-pressed={props.filters.statusFilter === 'waiting'}
331
+ title={statusBadgeTitle('waiting')}
332
+ onClick={() => { toggleStatusFilter('waiting') }}
333
+ data-testid="agent-sidecar-count-waiting"
334
+ >
335
+ {vm.waitingCount > 0
336
+ ? <StateDot state="warning" size={8} />
337
+ : <span className={styles['dot']} data-tone="neutral" />}
338
+ {formatTemplate(BOARD_STRINGS.topbar.countWaiting, { n: vm.waitingCount })}
339
+ </Pill>
340
+ <span className={styles['countTotal']} data-testid="agent-sidecar-count-total">
341
+ {formatTemplate(BOARD_STRINGS.topbar.countTotal, { n: vm.totalCount })}
150
342
  </span>
151
343
  <span className={styles['spacer']} />
152
344
  <label className={styles['control']}>
@@ -179,16 +371,31 @@ export function Board(props: BoardProps): ReactElement {
179
371
  />
180
372
  {BOARD_STRINGS.topbar.showDead}
181
373
  </label>
182
- <button
183
- type="button"
184
- className={styles['refresh']}
374
+ <Button
375
+ size="sm"
376
+ variant="toolbar"
185
377
  title={BOARD_STRINGS.topbar.refreshTitle}
186
- onClick={props.onRefresh}
378
+ disabled={refreshing}
379
+ onClick={onRefreshClick}
187
380
  >
188
- {BOARD_STRINGS.topbar.refresh}
189
- </button>
381
+ {refreshing ? BOARD_STRINGS.topbar.refreshing : BOARD_STRINGS.topbar.refresh}
382
+ </Button>
190
383
  </header>
191
384
 
385
+ {refreshFailed && (
386
+ <div className={styles['banner']} data-tone="warn" role="status">
387
+ {BOARD_STRINGS.topbar.refreshFailed}
388
+ <Button
389
+ size="sm"
390
+ variant="ghost"
391
+ className={styles['bannerDismiss']}
392
+ onClick={() => { setRefreshFailed(false) }}
393
+ >
394
+ {BOARD_STRINGS.topbar.dismiss}
395
+ </Button>
396
+ </div>
397
+ )}
398
+
192
399
  {vm.banner !== null && (
193
400
  <div className={styles['banner']} data-tone={vm.banner.tone} role="status">
194
401
  {vm.banner.text}