@shendeguize/dsh-agent-sidecar 0.1.1 → 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 (53) hide show
  1. package/README.md +78 -11
  2. package/lib/client.js +8659 -7589
  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 +115 -61
  8. package/src/client/board/board.module.css +72 -151
  9. package/src/client/board/project-view-logic.ts +21 -34
  10. package/src/client/board/project-view.module.css +41 -96
  11. package/src/client/board/project-view.tsx +29 -13
  12. package/src/client/board/strings.ts +68 -107
  13. package/src/client/commands.ts +30 -15
  14. package/src/client/detail/SessionDetail.tsx +92 -58
  15. package/src/client/detail/detail.module.css +62 -218
  16. package/src/client/detail/strings.ts +64 -88
  17. package/src/client/detail-view.module.css +30 -55
  18. package/src/client/detail-view.tsx +80 -108
  19. package/src/client/dsh-tools/LineageTree.tsx +22 -13
  20. package/src/client/dsh-tools/SearchPanel.tsx +18 -11
  21. package/src/client/dsh-tools/dsh-tools.module.css +50 -139
  22. package/src/client/dsh-tools/strings.ts +42 -77
  23. package/src/client/index.ts +285 -124
  24. package/src/client/inject/InjectPanel.tsx +31 -64
  25. package/src/client/inject/inject.module.css +97 -198
  26. package/src/client/lifecycle/handoff.ts +83 -0
  27. package/src/client/locales/en.ts +74 -2
  28. package/src/client/locales/host.ts +131 -0
  29. package/src/client/locales/index.ts +196 -8
  30. package/src/client/locales/react.ts +10 -0
  31. package/src/client/locales/view.ts +38 -0
  32. package/src/client/locales/zh.ts +194 -134
  33. package/src/client/mount.tsx +72 -38
  34. package/src/client/navigation/CenterOverlay.tsx +40 -0
  35. package/src/client/navigation/center-overlay.module.css +51 -0
  36. package/src/client/navigation/center.ts +45 -0
  37. package/src/client/navigation/modal-isolation.ts +152 -0
  38. package/src/client/navigation/modal-surface-anchor.ts +72 -0
  39. package/src/client/navigation/sidebar-entry.module.css +73 -0
  40. package/src/client/navigation/sidebar-entry.ts +309 -0
  41. package/src/client/primitives/StaticPill.tsx +21 -0
  42. package/src/client/settings-card.module.css +35 -119
  43. package/src/client/settings-card.tsx +31 -153
  44. package/src/client/settings-fields.tsx +131 -0
  45. package/src/client/sidebar/SidebarTab.tsx +156 -0
  46. package/src/client/sidebar/model.ts +65 -0
  47. package/src/client/sidebar/sidebar-tab.module.css +118 -0
  48. package/src/client/sidebar-tab.tsx +46 -320
  49. package/src/client/theme/agsc.module.css +31 -0
  50. package/src/client/theme/parts.ts +56 -0
  51. package/src/client/ui-integration.ts +86 -0
  52. package/src/client/widget.tsx +9 -8
  53. package/src/client/inject/overlay.module.css +0 -22
@@ -18,8 +18,8 @@
18
18
  * `dsh-agent-teams/src/command.ts`).
19
19
  * - The only `ui.kind` this dsh version supports is `popupSelect`:
20
20
  * an async `options(session, signal)` provider plus `onSelect`. The
21
- * `/sidecar` overview therefore presents as a popup card of rows — a
22
- * read-only glance; every row's onSelect is a no-op.
21
+ * `/sidecar` overview therefore presents as a popup card of rows; its
22
+ * board row opens Agent Center while informational rows remain inert.
23
23
  *
24
24
  * The `commandUi` service type is NOT part of the published plugin SDK
25
25
  * (same situation as `settingsScope` in ./index.ts), so this module keeps
@@ -55,7 +55,12 @@ import type {
55
55
  SessionStatusToken,
56
56
  WidgetConnection,
57
57
  } from './board/logic.ts'
58
+ import {
59
+ acquireWithHandoff,
60
+ isRegistrationCollision,
61
+ } from './lifecycle/handoff.ts'
58
62
  import { tCommand } from './locales/command.ts'
63
+ import type { CenterNavigation } from './navigation/center.ts'
59
64
 
60
65
  /** The slash command name (without the leading slash). */
61
66
  export const SIDECAR_COMMAND_NAME = 'sidecar'
@@ -399,6 +404,8 @@ export interface SidecarCommandDeps {
399
404
  now?: () => number
400
405
  /** Session-row cap; defaults to {@link DEFAULT_OVERVIEW_TOP_N}. */
401
406
  topN?: number
407
+ /** Opens Agent Center when the board option is selected. */
408
+ openCenter?: CenterNavigation
402
409
  }
403
410
 
404
411
  /**
@@ -435,8 +442,8 @@ export function createSidecarCommandContribution(
435
442
  if (deps.topN !== undefined) overviewOpts.topN = deps.topN
436
443
  return overviewToOptions(buildOverview(snapshot, overviewOpts))
437
444
  },
438
- onSelect: () => {
439
- // Read-only glance: rows are informational, selection is a no-op.
445
+ onSelect: (option) => {
446
+ if (option.id === 'board') deps.openCenter?.()
440
447
  },
441
448
  },
442
449
  }
@@ -451,7 +458,10 @@ export function createSidecarCommandContribution(
451
458
  * `ClientContext` satisfies it structurally (cordis `ctx.inject`).
452
459
  */
453
460
  export interface CommandMountContext {
454
- inject(deps: readonly string[], callback: (ctx: unknown) => void): unknown
461
+ inject(
462
+ deps: readonly string[],
463
+ callback: (ctx: unknown) => (() => void) | void,
464
+ ): unknown
455
465
  }
456
466
 
457
467
  /**
@@ -459,11 +469,9 @@ export interface CommandMountContext {
459
469
  * the design's `ctx.commands` consumption row — a composition without the
460
470
  * slash-menu runtime simply never gains the command).
461
471
  *
462
- * Idempotency: the ui-commands registry throws on a duplicate contribution
463
- * name; that throw is caught and logged, so a double apply (HMR re-apply
464
- * before the old fiber unloads) degrades to a no-op instead of taking the
465
- * client half down. Disposal is owned by the registry's effect on the
466
- * injected fiber — unloading the plugin unregisters the command.
472
+ * HMR handoff: a duplicate contribution means the old fiber still owns the
473
+ * name. The new injected fiber retries briefly, then registers its own fresh
474
+ * contribution after the old disposer runs. Foreign squatters time out.
467
475
  */
468
476
  export function registerSidecarCommand(
469
477
  ctx: CommandMountContext,
@@ -472,11 +480,18 @@ export function registerSidecarCommand(
472
480
  try {
473
481
  ctx.inject(['commandUi'], (injected) => {
474
482
  const { commandUi } = injected as { commandUi: CommandRegistryFace }
475
- try {
476
- commandUi.register(createSidecarCommandContribution(deps))
477
- } catch (err) {
478
- console.error('agent-sidecar: /sidecar command registration skipped', err)
479
- }
483
+ return acquireWithHandoff(
484
+ () => commandUi.register(createSidecarCommandContribution(deps)),
485
+ {
486
+ isCollision: isRegistrationCollision,
487
+ onError: (error) => {
488
+ console.error('agent-sidecar: /sidecar command registration failed', error)
489
+ },
490
+ onTimeout: () => {
491
+ console.error('agent-sidecar: /sidecar command handoff timed out')
492
+ },
493
+ },
494
+ )
480
495
  })
481
496
  } catch (err) {
482
497
  console.error('agent-sidecar: commandUi injection failed', err)
@@ -21,6 +21,13 @@
21
21
  * else comes through props.
22
22
  */
23
23
 
24
+ import {
25
+ Button,
26
+ Pill,
27
+ StateDot,
28
+ writeClipboard,
29
+ type StateDotState,
30
+ } from '@deepseek-ai/dsh-client-ui-primitives'
24
31
  import { Fragment, useEffect, useRef, useState, type ReactElement } from 'react'
25
32
  import {
26
33
  aggregateChunkRows,
@@ -40,6 +47,8 @@ import {
40
47
  type TimelineVM,
41
48
  } from './logic.ts'
42
49
  import { DETAIL_STRINGS } from './strings.ts'
50
+ import { StaticPill } from '../primitives/StaticPill.tsx'
51
+ import { surfaceProps } from '../theme/parts.ts'
43
52
  import styles from './detail.module.css'
44
53
 
45
54
  export interface SessionDetailHeaderVM {
@@ -100,19 +109,22 @@ function EventRow(props: {
100
109
  {DETAIL_STRINGS.timeline.seq.replace('{n}', String(entry.seq))}
101
110
  </span>
102
111
  )}
103
- {row.isNew && <span className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</span>}
112
+ {row.isNew && <Pill className={styles['eventNew']}>{DETAIL_STRINGS.timeline.newBadge}</Pill>}
104
113
  <span className={styles['eventSpacer']} />
105
114
  <span className={styles['eventTime']}>{row.timeLabel}</span>
106
115
  </div>
107
116
  {entry.summary !== '' && <div className={styles['eventSummary']}>{entry.summary}</div>}
108
117
  {entry.expandable && (
109
- <button
118
+ <Button
110
119
  type="button"
120
+ size="sm"
121
+ variant="ghost"
111
122
  className={styles['expandButton']}
123
+ aria-expanded={expanded}
112
124
  onClick={() => onToggleExpand(entry.key)}
113
125
  >
114
126
  {expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
115
- </button>
127
+ </Button>
116
128
  )}
117
129
  {entry.expandable && expanded && entry.body !== null && (
118
130
  <pre className={styles['eventBody']}>{entry.body}</pre>
@@ -139,13 +151,16 @@ function ChunkRunRow(props: {
139
151
  data-testid="agent-sidecar-detail-chunks"
140
152
  >
141
153
  <span className={styles['chunkRunLabel']}>{row.label}</span>
142
- <button
154
+ <Button
143
155
  type="button"
156
+ size="sm"
157
+ variant="ghost"
144
158
  className={styles['expandButton']}
159
+ aria-expanded={props.expanded}
145
160
  onClick={() => props.onToggleRun(row.key)}
146
161
  >
147
162
  {props.expanded ? DETAIL_STRINGS.timeline.collapse : DETAIL_STRINGS.timeline.expand}
148
- </button>
163
+ </Button>
149
164
  <span className={styles['eventSpacer']} />
150
165
  <span className={styles['eventTime']}>{row.timeLabel}</span>
151
166
  </li>
@@ -173,6 +188,7 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
173
188
  const listRef = useRef<HTMLOListElement | null>(null)
174
189
  const positionedRef = useRef(false)
175
190
  const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
191
+ const copyAliveRef = useRef(true)
176
192
 
177
193
  const status = deriveDetailStatus(props.header.status)
178
194
  const sourceBadges = deriveSourceBadges(props.timeline.sources)
@@ -209,12 +225,16 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
209
225
  }
210
226
  }, [listening, entryCount])
211
227
 
212
- useEffect(
213
- () => () => {
214
- if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
215
- },
216
- [],
217
- )
228
+ useEffect(() => {
229
+ copyAliveRef.current = true
230
+ return () => {
231
+ copyAliveRef.current = false
232
+ if (copyTimerRef.current !== null) {
233
+ clearTimeout(copyTimerRef.current)
234
+ copyTimerRef.current = null
235
+ }
236
+ }
237
+ }, [])
218
238
 
219
239
  const toggleExpand = (key: string): void => {
220
240
  setExpandedKeys((prev) => {
@@ -234,31 +254,32 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
234
254
  })
235
255
  }
236
256
 
237
- const copySessionId = (): void => {
238
- const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
239
- if (clipboard === undefined) return
240
- clipboard.writeText(props.sessionId).then(
241
- () => {
242
- setCopied(true)
243
- if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
244
- copyTimerRef.current = setTimeout(() => {
245
- setCopied(false)
246
- }, 2000)
247
- },
248
- () => {
249
- // Clipboard permission denied: silently keep the plain id display.
250
- },
251
- )
257
+ const copySessionId = async (): Promise<void> => {
258
+ if (!(await writeClipboard(props.sessionId))) return
259
+ if (!copyAliveRef.current) return
260
+ if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
261
+ setCopied(true)
262
+ copyTimerRef.current = setTimeout(() => {
263
+ copyTimerRef.current = null
264
+ setCopied(false)
265
+ }, 2000)
252
266
  }
253
267
 
268
+ const statusDotState: StateDotState | null =
269
+ status.status === 'working'
270
+ ? 'ongoing'
271
+ : status.status === 'waiting'
272
+ ? 'warning'
273
+ : null
274
+
254
275
  return (
255
- <div className={styles['root']} data-testid="agent-sidecar-detail">
276
+ <div {...surfaceProps('timeline', styles['root'])} data-testid="agent-sidecar-detail">
256
277
  <header className={styles['header']}>
257
278
  <div className={styles['headerTop']}>
258
279
  {props.onClose !== undefined && (
259
- <button type="button" className={styles['closeButton']} onClick={props.onClose}>
280
+ <Button type="button" size="sm" variant="outline" onClick={props.onClose}>
260
281
  {DETAIL_STRINGS.header.close}
261
- </button>
282
+ </Button>
262
283
  )}
263
284
  <span className={styles['agent']}>
264
285
  <span className={styles['agentGlyph']} aria-hidden>
@@ -266,15 +287,18 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
266
287
  </span>
267
288
  {props.header.agent}
268
289
  </span>
269
- <span className={styles['badge']} data-tone={status.tone} title={DETAIL_STRINGS.header.observedDisclaimer}>
270
- <span className={styles['dot']} data-tone={status.tone} />
290
+ <StaticPill className={styles['badge']} title={DETAIL_STRINGS.header.observedDisclaimer}>
291
+ {statusDotState === null
292
+ ? <span className={styles['dot']} data-tone={status.tone} />
293
+ : <StateDot state={statusDotState} size={8} />}
271
294
  {status.label}
272
- </span>
295
+ </StaticPill>
273
296
  <span className={styles['spacer']} />
274
297
  {props.onRefresh !== undefined && (
275
- <button
298
+ <Button
276
299
  type="button"
277
- className={styles['refreshButton']}
300
+ size="sm"
301
+ variant="outline"
278
302
  disabled={props.refreshing === true}
279
303
  title={DETAIL_STRINGS.header.refreshHint}
280
304
  onClick={props.onRefresh}
@@ -283,18 +307,17 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
283
307
  {props.refreshing === true
284
308
  ? DETAIL_STRINGS.header.refreshing
285
309
  : DETAIL_STRINGS.header.refresh}
286
- </button>
310
+ </Button>
287
311
  )}
288
- <button
312
+ <Pill
289
313
  type="button"
290
- className={styles['listenButton']}
314
+ active={props.listening}
291
315
  aria-pressed={props.listening}
292
- data-active={props.listening || undefined}
293
316
  title={DETAIL_STRINGS.header.listenHint}
294
317
  onClick={props.onToggleListen}
295
318
  >
296
319
  {props.listening ? DETAIL_STRINGS.header.listenOn : DETAIL_STRINGS.header.listenOff}
297
- </button>
320
+ </Pill>
298
321
  </div>
299
322
  <div className={styles['title']} title={props.header.title}>
300
323
  {props.header.title.trim() === '' ? DETAIL_STRINGS.header.untitled : props.header.title}
@@ -305,19 +328,21 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
305
328
  ? DETAIL_STRINGS.header.unknownProject
306
329
  : props.header.project}
307
330
  </span>
308
- <button
331
+ <Button
309
332
  type="button"
333
+ size="sm"
334
+ variant="ghost"
310
335
  className={styles['sessionId']}
311
336
  title={`${props.sessionId} · ${DETAIL_STRINGS.header.copyIdTitle}`}
312
- onClick={copySessionId}
337
+ onClick={() => { void copySessionId() }}
313
338
  data-testid="agent-sidecar-detail-copy-id"
314
339
  >
315
340
  {props.sessionId}
316
- </button>
341
+ </Button>
317
342
  {copied && (
318
- <span className={styles['copiedBubble']} role="status">
343
+ <StaticPill className={styles['copiedBubble']} role="status">
319
344
  {DETAIL_STRINGS.header.copied}
320
- </span>
345
+ </StaticPill>
321
346
  )}
322
347
  </div>
323
348
  <div className={styles['metaRow']}>
@@ -325,9 +350,13 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
325
350
  {sourceBadges.length > 0 && (
326
351
  <span className={styles['sourceList']} title={DETAIL_STRINGS.sources.title}>
327
352
  {sourceBadges.map((badge) => (
328
- <span key={badge.id} className={styles['sourceBadge']} data-tone={badge.tone}>
353
+ <StaticPill
354
+ key={badge.id}
355
+ className={styles['sourceBadge']}
356
+ data-tone={badge.tone}
357
+ >
329
358
  {badge.label}
330
- </span>
359
+ </StaticPill>
331
360
  ))}
332
361
  </span>
333
362
  )}
@@ -335,13 +364,17 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
335
364
  </header>
336
365
 
337
366
  {bodyState.errorBanner !== null && (
338
- <div className={styles['banner']} role="status">
367
+ <div className={styles['banner']} role="alert">
339
368
  {bodyState.errorBanner}
340
369
  </div>
341
370
  )}
342
371
 
343
372
  {bodyState.kind !== 'list' ? (
344
- <div className={styles['bodyState']} data-kind={bodyState.kind}>
373
+ <div
374
+ className={styles['bodyState']}
375
+ data-kind={bodyState.kind}
376
+ role={bodyState.kind === 'error' ? 'alert' : bodyState.kind === 'loading' ? 'status' : undefined}
377
+ >
345
378
  <div className={styles['bodyStateTitle']}>{bodyState.title}</div>
346
379
  {bodyState.hint !== null && <div className={styles['bodyStateHint']}>{bodyState.hint}</div>}
347
380
  </div>
@@ -349,18 +382,17 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
349
382
  <>
350
383
  <div className={styles['filterRow']} data-testid="agent-sidecar-detail-filter">
351
384
  {(['conversation', 'all'] as const).map((mode) => (
352
- <button
385
+ <Pill
353
386
  key={mode}
354
387
  type="button"
355
- className={styles['filterChip']}
356
- data-active={filterMode === mode || undefined}
388
+ active={filterMode === mode}
357
389
  aria-pressed={filterMode === mode}
358
390
  onClick={() => {
359
391
  setFilterMode(mode)
360
392
  }}
361
393
  >
362
394
  {DETAIL_STRINGS.filter[mode]}
363
- </button>
395
+ </Pill>
364
396
  ))}
365
397
  {filtered.hiddenCount > 0 && (
366
398
  <span className={styles['filterHiddenNote']}>
@@ -370,16 +402,17 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
370
402
  </div>
371
403
  <div className={styles['pager']}>
372
404
  {props.hasMore ? (
373
- <button
405
+ <Button
374
406
  type="button"
375
- className={styles['loadMoreButton']}
407
+ size="sm"
408
+ variant="outline"
376
409
  disabled={props.loading}
377
410
  onClick={props.onLoadMore}
378
411
  >
379
412
  {props.loading
380
413
  ? DETAIL_STRINGS.timeline.loadingMore
381
414
  : DETAIL_STRINGS.timeline.loadMore}
382
- </button>
415
+ </Button>
383
416
  ) : (
384
417
  <span className={styles['pagerNote']}>{DETAIL_STRINGS.timeline.noMore}</span>
385
418
  )}
@@ -387,13 +420,14 @@ export function SessionDetail(props: SessionDetailProps): ReactElement {
387
420
  {limited.notice !== null && (
388
421
  <div className={styles['hiddenNotice']}>
389
422
  {limited.notice}
390
- <button
423
+ <Button
391
424
  type="button"
392
- className={styles['showAllButton']}
425
+ size="sm"
426
+ variant="ghost"
393
427
  onClick={() => setRenderAll(true)}
394
428
  >
395
429
  {DETAIL_STRINGS.timeline.showAll}
396
- </button>
430
+ </Button>
397
431
  </div>
398
432
  )}
399
433
  <ol className={styles['timeline']} ref={listRef}>