@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
@@ -1,52 +1,31 @@
1
1
  /**
2
- * Optional better-sidebar mini tab (T6.3, design §5.2 / ADR-5 option C).
3
- *
4
- * dsh-better-sidebar (npm, ≥0.4.0) turns the sidebar into a registry
5
- * service: its client half runs `ctx.provide('betterSidebar', service)` and
6
- * consumers call `service.registerTab(descriptor)` (source of record:
7
- * better-sidebar docs/external-plugin-guide.md + src/client/service.ts).
8
- * This module integrates as a SOFT dependency, dsh-sentinel style:
9
- *
10
- * - NO value import and NO type import of `dsh-better-sidebar` — the client
11
- * bundle purity gate stays react-only. The minimal service contract is
12
- * restated locally ({@link BetterSidebarServiceFace}) and the live service
13
- * is duck-typed at runtime ({@link probeBetterSidebar}).
14
- * - Absent → silent skip: {@link mountSidebarTab} parks the registration in
15
- * `ctx.inject(['betterSidebar'], …)` — a cordis fiber that stays PENDING
16
- * until some plugin provides the service (zero timers, zero polling, zero
17
- * resources), plus one debug-level log line at mount time. If
18
- * better-sidebar is installed later (any load order), the fiber activates
19
- * and the tab registers; if never, nothing ever runs.
20
- * - Present → one compact "Sidecar" tab (`agent-sidecar:monitor`): daemon
21
- * connection dot, working/waiting counts, and the most recently active
22
- * sessions, all read from the SHARED {@link SidecarController} stores —
23
- * no second poller, no second SSE.
24
- * - `visible === false` releases resources: better-sidebar hands tab
25
- * components a `visible` prop (false while the panel is collapsed or
26
- * another tab is active). The shared controller has no pause/refcount
27
- * surface (its stream is plugin-lifetime, `stop()` is terminal), so the
28
- * gate lives in this view: {@link VisibleGatedStore} unsubscribes from the
29
- * controller while hidden (re-render churn stops; the view triggers no
30
- * fetches of its own) and resubscribes + catches up on show.
2
+ * Soft dsh-better-sidebar integration (design §5.2 / ADR-5 option C).
3
+ * The dependency stays duck-typed: absence parks a zero-resource inject
4
+ * fiber, presence registers one compact tab over the shared controller.
5
+ * `visible=false` drops only this view's subscription; the plugin-lifetime
6
+ * controller remains untouched.
31
7
  */
32
8
 
33
9
  import { createElement as h, useEffect, useState, useSyncExternalStore } from 'react'
34
- import type { CSSProperties, ReactElement, ReactNode } from 'react'
10
+ import type { ReactElement, ReactNode } from 'react'
35
11
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
36
12
  import type { SidecarController, SidecarViewState } from './controller.ts'
37
13
  import {
38
- agentGlyph,
39
- countWorking,
40
- deriveWidgetConnection,
41
- formatRelativeTime,
42
- normalizeStatus,
43
- projectDisplayName,
44
- widgetTitle,
45
- type SessionCardVM,
46
- type SessionStatusToken,
47
- type WidgetConnection,
48
- } from './board/logic.ts'
49
- import { t, type SidecarLocaleKey } from './locales/index.ts'
14
+ acquireWithHandoff,
15
+ isRegistrationCollision,
16
+ } from './lifecycle/handoff.ts'
17
+ import { SidebarTab, SidebarTabIcon } from './sidebar/SidebarTab.tsx'
18
+ import { deriveMiniVM } from './sidebar/model.ts'
19
+ import { t } from './locales/index.ts'
20
+
21
+ // Compatibility exports for consumers of the original integration module.
22
+ export {
23
+ MAX_RECENT_SESSIONS,
24
+ countWaiting,
25
+ deriveMiniVM,
26
+ recentActiveSessions,
27
+ } from './sidebar/model.ts'
28
+ export type { SidebarMiniVM } from './sidebar/model.ts'
50
29
 
51
30
  /** Registered tab type id (design §5.2 names it verbatim). */
52
31
  export const SIDEBAR_TAB_ID = 'agent-sidecar:monitor'
@@ -54,12 +33,7 @@ export const SIDEBAR_TAB_ID = 'agent-sidecar:monitor'
54
33
  /** `+`-menu order: after the built-ins (explorer=10 … browser=50). */
55
34
  export const SIDEBAR_TAB_ORDER = 60
56
35
 
57
- /** Compact view caps the session list at this many rows (task spec: 3-5). */
58
- export const MAX_RECENT_SESSIONS = 5
59
-
60
- // ---------------------------------------------------------------------------
61
36
  // Duck-typed better-sidebar contract (locally restated, never imported).
62
- // ---------------------------------------------------------------------------
63
37
 
64
38
  /** Props better-sidebar hands every tab component; only `visible` is used. */
65
39
  export interface SidebarTabComponentProps {
@@ -98,9 +72,7 @@ export function probeBetterSidebar(candidate: unknown): BetterSidebarServiceFace
98
72
  : null
99
73
  }
100
74
 
101
- // ---------------------------------------------------------------------------
102
75
  // Visibility gate over the shared controller.
103
- // ---------------------------------------------------------------------------
104
76
 
105
77
  /** The slice of {@link SidecarController} this view consumes (tests fake it). */
106
78
  export type SidecarStateSource = Pick<SidecarController, 'subscribe' | 'getState'>
@@ -169,214 +141,10 @@ export class VisibleGatedStore {
169
141
  }
170
142
  }
171
143
 
172
- // ---------------------------------------------------------------------------
173
- // Pure view model (exported for tests).
174
- // ---------------------------------------------------------------------------
175
-
176
- /** Count of sessions currently observed as waiting. */
177
- export function countWaiting(sessions: ReadonlyArray<{ status: string }>): number {
178
- let count = 0
179
- for (const session of sessions) {
180
- if (normalizeStatus(session.status) === 'waiting') count += 1
181
- }
182
- return count
183
- }
144
+ // React integration adapter.
184
145
 
185
146
  /**
186
- * The compact list: non-dead sessions, most recently updated first, capped
187
- * at {@link MAX_RECENT_SESSIONS} (ties break by session id for stability).
188
- */
189
- export function recentActiveSessions<T extends SessionCardVM>(
190
- sessions: readonly T[],
191
- limit: number = MAX_RECENT_SESSIONS,
192
- ): T[] {
193
- return sessions
194
- .filter((session) => normalizeStatus(session.status) !== 'dead')
195
- .sort((a, b) =>
196
- b.updatedAtMs !== a.updatedAtMs
197
- ? b.updatedAtMs - a.updatedAtMs
198
- : a.sessionId.localeCompare(b.sessionId))
199
- .slice(0, limit)
200
- }
201
-
202
- /** Everything the mini tab renders. */
203
- export interface SidebarMiniVM {
204
- connection: WidgetConnection
205
- /** Hover text for the header dot (connection + working count). */
206
- connectionTitle: string
207
- workingCount: number
208
- waitingCount: number
209
- recent: SessionCardVM[]
210
- hasSnapshot: boolean
211
- }
212
-
213
- /** Fold the shared view state into the mini tab's view model (pure). */
214
- export function deriveMiniVM(state: SidecarViewState): SidebarMiniVM {
215
- const connection = deriveWidgetConnection(state.daemonState, state.streamHealth)
216
- const workingCount = countWorking(state.sessions)
217
- return {
218
- connection,
219
- connectionTitle: widgetTitle(connection, workingCount),
220
- workingCount,
221
- waitingCount: countWaiting(state.sessions),
222
- recent: recentActiveSessions(state.sessions),
223
- hasSnapshot: state.hasSnapshot,
224
- }
225
- }
226
-
227
- // ---------------------------------------------------------------------------
228
- // Presentation.
229
- // ---------------------------------------------------------------------------
230
-
231
- const STATUS_LABEL_KEY: Record<SessionStatusToken, SidecarLocaleKey> = {
232
- working: 'detail.status.working',
233
- waiting: 'detail.status.waiting',
234
- idle: 'detail.status.idle',
235
- dead: 'detail.status.dead',
236
- unknown: 'detail.status.unknown',
237
- }
238
-
239
- const DOT_COLORS: Record<WidgetConnection, string> = {
240
- ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
241
- degraded: 'var(--dsw-alias-state-warn-primary, #9a6700)',
242
- off: 'var(--dsw-alias-label-dimmed, #8c959f)',
243
- }
244
-
245
- const rootStyle: CSSProperties = {
246
- display: 'flex',
247
- flexDirection: 'column',
248
- gap: 8,
249
- padding: '10px 12px',
250
- fontSize: 12,
251
- color: 'var(--dsw-alias-label-primary, #1f2328)',
252
- }
253
-
254
- const headerStyle: CSSProperties = {
255
- display: 'flex',
256
- alignItems: 'center',
257
- gap: 6,
258
- }
259
-
260
- const dotStyle = (connection: WidgetConnection): CSSProperties => ({
261
- width: 8,
262
- height: 8,
263
- borderRadius: '50%',
264
- flex: 'none',
265
- background: DOT_COLORS[connection],
266
- })
267
-
268
- const countsStyle: CSSProperties = {
269
- fontVariantNumeric: 'tabular-nums',
270
- color: 'var(--dsw-alias-label-secondary, #57606a)',
271
- }
272
-
273
- const sectionTitleStyle: CSSProperties = {
274
- fontSize: 11,
275
- fontWeight: 600,
276
- color: 'var(--dsw-alias-label-secondary, #57606a)',
277
- }
278
-
279
- const rowStyle: CSSProperties = {
280
- display: 'flex',
281
- alignItems: 'center',
282
- gap: 6,
283
- width: '100%',
284
- padding: '4px 6px',
285
- border: 'none',
286
- borderRadius: 6,
287
- background: 'transparent',
288
- cursor: 'pointer',
289
- font: 'inherit',
290
- textAlign: 'left',
291
- color: 'inherit',
292
- }
293
-
294
- const rowTitleStyle: CSSProperties = {
295
- flex: 1,
296
- minWidth: 0,
297
- overflow: 'hidden',
298
- textOverflow: 'ellipsis',
299
- whiteSpace: 'nowrap',
300
- }
301
-
302
- const rowMetaStyle: CSSProperties = {
303
- flex: 'none',
304
- fontSize: 11,
305
- color: 'var(--dsw-alias-label-dimmed, #8c959f)',
306
- whiteSpace: 'nowrap',
307
- }
308
-
309
- const detailStyle: CSSProperties = {
310
- margin: '0 6px 4px 20px',
311
- fontSize: 11,
312
- lineHeight: '16px',
313
- color: 'var(--dsw-alias-label-secondary, #57606a)',
314
- overflowWrap: 'anywhere',
315
- }
316
-
317
- const mutedStyle: CSSProperties = {
318
- color: 'var(--dsw-alias-label-dimmed, #8c959f)',
319
- }
320
-
321
- const hintStyle: CSSProperties = {
322
- fontSize: 11,
323
- color: 'var(--dsw-alias-label-dimmed, #8c959f)',
324
- }
325
-
326
- /** One session row + its inline expansion (last event, project, id). */
327
- function SessionRow(props: {
328
- session: SessionCardVM
329
- nowMs: number
330
- expanded: boolean
331
- onToggle: () => void
332
- }): ReactElement {
333
- const { session, nowMs, expanded } = props
334
- const status = normalizeStatus(session.status)
335
- const title = session.title.trim() === '' ? t('sidebar.untitled') : session.title
336
- const rowChildren: ReactNode[] = [
337
- h('span', { key: 'glyph', 'aria-hidden': true }, agentGlyph(session.agent)),
338
- h('span', { key: 'title', style: rowTitleStyle, title: session.sessionId }, title),
339
- h(
340
- 'span',
341
- { key: 'meta', style: rowMetaStyle },
342
- `${t(STATUS_LABEL_KEY[status])} · ${formatRelativeTime(session.updatedAtMs, nowMs)}`,
343
- ),
344
- ]
345
- const children: ReactNode[] = [
346
- h(
347
- 'button',
348
- {
349
- key: 'row',
350
- type: 'button',
351
- style: rowStyle,
352
- onClick: props.onToggle,
353
- 'data-testid': 'agent-sidecar-sidebar-session',
354
- 'data-session-id': session.sessionId,
355
- 'data-status': status,
356
- 'aria-expanded': expanded,
357
- },
358
- ...rowChildren,
359
- ),
360
- ]
361
- if (expanded) {
362
- const lastEvent =
363
- session.lastEvent === null
364
- ? h('span', { style: mutedStyle }, t('sidebar.noEvent'))
365
- : `${session.lastEvent.kind}: ${session.lastEvent.text}`
366
- children.push(
367
- h(
368
- 'div',
369
- { key: 'detail', style: detailStyle, 'data-testid': 'agent-sidecar-sidebar-detail' },
370
- h('div', { key: 'project' }, projectDisplayName(session.project)),
371
- h('div', { key: 'event' }, lastEvent),
372
- ),
373
- )
374
- }
375
- return h('li', { style: { listStyle: 'none' } }, ...children)
376
- }
377
-
378
- /**
379
- * Build the tab component bound to the shared controller. One
147
+ * Bind the presentation-only tab to the shared controller. One
380
148
  * {@link VisibleGatedStore} per mounted tab instance: the `visible` prop is
381
149
  * synced into it by effect, unmount disposes it.
382
150
  */
@@ -392,56 +160,11 @@ export function createSidebarTabComponent(
392
160
  gate.setVisible(visible)
393
161
  }, [gate, visible])
394
162
  const state = useSyncExternalStore(gate.subscribe, gate.getState, gate.getState)
395
- const [expandedId, setExpandedId] = useState<string | null>(null)
396
-
397
- const vm = deriveMiniVM(state)
398
- const nowMs = Date.now()
399
-
400
- let body: ReactNode
401
- if (!vm.hasSnapshot) {
402
- body = h('div', { style: mutedStyle }, t('sidebar.connecting'))
403
- } else if (vm.recent.length === 0) {
404
- body = h('div', { style: mutedStyle }, t('sidebar.noSessions'))
405
- } else {
406
- body = h(
407
- 'ul',
408
- { style: { margin: 0, padding: 0 } },
409
- ...vm.recent.map((session) =>
410
- h(SessionRow, {
411
- key: session.sessionId,
412
- session,
413
- nowMs,
414
- expanded: expandedId === session.sessionId,
415
- onToggle: () => {
416
- setExpandedId((prev) => (prev === session.sessionId ? null : session.sessionId))
417
- },
418
- })),
419
- )
420
- }
421
-
422
- return h(
423
- 'div',
424
- { style: rootStyle, 'data-testid': 'agent-sidecar-sidebar-tab', 'data-visible': visible },
425
- h(
426
- 'div',
427
- { style: headerStyle, title: vm.connectionTitle },
428
- h('span', { 'aria-hidden': true, style: dotStyle(vm.connection) }),
429
- h(
430
- 'span',
431
- { style: countsStyle, 'data-testid': 'agent-sidecar-sidebar-counts' },
432
- t('sidebar.countsRow', { working: vm.workingCount, waiting: vm.waitingCount }),
433
- ),
434
- ),
435
- h('div', { style: sectionTitleStyle }, t('sidebar.recentTitle')),
436
- body,
437
- h('div', { style: hintStyle }, t('sidebar.boardHint')),
438
- )
163
+ return h(SidebarTab, { vm: deriveMiniVM(state), visible })
439
164
  }
440
165
  }
441
166
 
442
- // ---------------------------------------------------------------------------
443
167
  // Mount.
444
- // ---------------------------------------------------------------------------
445
168
 
446
169
  /** Context slice {@link mountSidebarTab} consumes (tests hand in a fake). */
447
170
  export interface SidebarMountContext {
@@ -454,9 +177,9 @@ export interface SidebarMountContext {
454
177
  * installed → the inject fiber never activates (silent skip, one debug
455
178
  * line, zero resources). Installed (before or after this plugin, order
456
179
  * does not matter) → duck-type the service and register the mini tab
457
- * inside `ctx.effect`, so plugin unload / HMR unregisters it. A duplicate
458
- * registration (double apply) or a service throw degrades to a logged
459
- * no-op never past this seat.
180
+ * inside `ctx.effect`, so plugin unload / HMR unregisters it. During overlap,
181
+ * the new fiber waits briefly for the old tab to leave, then contributes its
182
+ * own component closure. Foreign collisions time out without eviction.
460
183
  */
461
184
  export function mountSidebarTab(ctx: ClientContext, controller: SidecarStateSource): void {
462
185
  const mount = ctx as unknown as SidebarMountContext
@@ -474,21 +197,24 @@ export function mountSidebarTab(ctx: ClientContext, controller: SidecarStateSour
474
197
  return
475
198
  }
476
199
  const component = createSidebarTabComponent(controller)
477
- bctx.effect(() => {
478
- try {
479
- return service.registerTab({
480
- id: SIDEBAR_TAB_ID,
481
- title: () => t('sidebar.tabTitle'),
482
- icon: (size: number) =>
483
- h('span', { 'aria-hidden': true, style: { fontSize: Math.round(size * 0.75) } }, '◈'),
484
- order: SIDEBAR_TAB_ORDER,
485
- single: true,
486
- component,
487
- })
488
- } catch (err) {
489
- console.error('agent-sidecar: better-sidebar tab registration failed', err)
490
- return () => {}
491
- }
492
- }, 'agent-sidecar: better-sidebar tab')
200
+ bctx.effect(() => acquireWithHandoff(
201
+ () => service.registerTab({
202
+ id: SIDEBAR_TAB_ID,
203
+ title: () => t('sidebar.tabTitle'),
204
+ icon: (size: number) => h(SidebarTabIcon, { size }),
205
+ order: SIDEBAR_TAB_ORDER,
206
+ single: true,
207
+ component,
208
+ }),
209
+ {
210
+ isCollision: isRegistrationCollision,
211
+ onError: (error) => {
212
+ console.error('agent-sidecar: better-sidebar tab registration failed', error)
213
+ },
214
+ onTimeout: () => {
215
+ console.error('agent-sidecar: better-sidebar tab handoff timed out')
216
+ },
217
+ },
218
+ ), 'agent-sidecar: better-sidebar tab')
493
219
  })
494
220
  }
@@ -0,0 +1,31 @@
1
+ /*
2
+ * L3 of the theming interface (design agent_center_native_ui.md §4).
3
+ * `surfaceProps()` applies this root class together with stable L2 anchors.
4
+ *
5
+ * Every color and shadow delegates to an official --dsw-* token, so the
6
+ * plugin follows the host theme and Skin Center without owning a parallel
7
+ * palette. Users, skins, or other plugins can override one surface:
8
+ *
9
+ * [data-dsh-plugin='agent-sidecar'] { --agsc-accent: var(--skin-accent); }
10
+ */
11
+
12
+ .root {
13
+ --agsc-accent: var(--dsw-alias-brand-primary);
14
+ --agsc-bg: var(--dsw-alias-bg-layer-1);
15
+ --agsc-bg-raised: var(--dsw-alias-bg-layer-3);
16
+ --agsc-fg: var(--dsw-alias-label-primary);
17
+ --agsc-fg-secondary: var(--dsw-alias-label-secondary);
18
+ --agsc-fg-dimmed: var(--dsw-alias-label-dimmed);
19
+ --agsc-border: var(--dsw-alias-border-l1);
20
+ --agsc-border-strong: var(--dsw-alias-border-l2);
21
+ --agsc-ok: var(--dsw-alias-state-success-primary);
22
+ --agsc-warn: var(--dsw-alias-state-warn-primary);
23
+ --agsc-err: var(--dsw-alias-state-error-primary);
24
+ --agsc-radius-card: 12px;
25
+ --agsc-radius-control: 8px;
26
+ --agsc-shadow-card: var(--dsw-shadow-lv2);
27
+ --agsc-font-mono: var(--ds-font-family-code);
28
+
29
+ color: var(--agsc-fg);
30
+ font-family: inherit;
31
+ }
@@ -0,0 +1,56 @@
1
+ import theme from './agsc.module.css'
2
+
3
+ /** Stable owner value for Agent Sidecar skin anchors. */
4
+ export const PLUGIN_DOM_ID = 'agent-sidecar'
5
+
6
+ /**
7
+ * Closed public registry of bare `data-dsh-part` values.
8
+ * Adding an entry changes the theming contract and requires tests + docs.
9
+ */
10
+ export const SIDECAR_PARTS = Object.freeze([
11
+ 'board',
12
+ 'board-toolbar',
13
+ 'board-card',
14
+ 'project-view',
15
+ 'detail',
16
+ 'timeline',
17
+ 'inject-panel',
18
+ 'analysis-panel',
19
+ 'dsh-tools',
20
+ 'footer-widget',
21
+ 'sidebar-entry',
22
+ 'settings-card',
23
+ 'overlay',
24
+ 'sidebar-tab',
25
+ ] as const)
26
+
27
+ export type SidecarPart = (typeof SIDECAR_PARTS)[number]
28
+
29
+ export interface SurfaceProps {
30
+ readonly className: string
31
+ readonly 'data-dsh-plugin': typeof PLUGIN_DOM_ID
32
+ readonly 'data-dsh-part': SidecarPart
33
+ }
34
+
35
+ function requireThemeClass(name: string): string {
36
+ const className = theme[name]
37
+ if (className === undefined || className.length === 0) {
38
+ throw new Error(`Agent Sidecar theme is missing its ${name} class`)
39
+ }
40
+ return className
41
+ }
42
+
43
+ const rootClassName = requireThemeClass('root')
44
+
45
+ /**
46
+ * Returns the complete, spreadable contract for one mounted surface.
47
+ * This keeps hashed CSS classes internal while exposing stable DSH anchors.
48
+ */
49
+ export function surfaceProps(part: SidecarPart, className?: string): SurfaceProps {
50
+ const localClassName = className?.trim()
51
+ return {
52
+ className: localClassName ? `${rootClassName} ${localClassName}` : rootClassName,
53
+ 'data-dsh-plugin': PLUGIN_DOM_ID,
54
+ 'data-dsh-part': part,
55
+ }
56
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * UI composition ports for the board and session-detail surfaces.
3
+ *
4
+ * Views depend on these narrow factory seams instead of owning the
5
+ * application assembly contract. The default factory binds the existing
6
+ * stores; callers can provide alternate port implementations in tests or
7
+ * other materializations.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { AnalysisStore } from './analysis-glue.ts'
13
+ import { DetailStore, type DetailHeaderHint } from './detail-glue.ts'
14
+ import type { InjectActions } from './inject-glue.ts'
15
+ import type { InjectMode } from './inject/logic.ts'
16
+ import { ProjectsStore } from './project-glue.ts'
17
+ import { SearchStore } from './search-glue.ts'
18
+
19
+ /** Detail-store capabilities consumed by the session-detail surface. */
20
+ export type DetailStorePort = Pick<
21
+ DetailStore,
22
+ | 'subscribe'
23
+ | 'getState'
24
+ | 'open'
25
+ | 'loadMore'
26
+ | 'toggleListen'
27
+ | 'refreshNewest'
28
+ | 'notifySnapshot'
29
+ | 'dispose'
30
+ >
31
+
32
+ /** Search-store capabilities consumed by the detail tools surface. */
33
+ export type SearchStorePort = Pick<
34
+ SearchStore,
35
+ 'subscribe' | 'getState' | 'setQuery' | 'submit' | 'dispose'
36
+ >
37
+
38
+ /** Analysis-store capabilities consumed by the analysis panel. */
39
+ export type AnalysisStorePort = Pick<
40
+ AnalysisStore,
41
+ 'subscribe' | 'getState' | 'start' | 'followup' | 'stop' | 'dispose'
42
+ >
43
+
44
+ /** Project-store capabilities consumed by the project view. */
45
+ export type ProjectsStorePort = Pick<
46
+ ProjectsStore,
47
+ 'subscribe' | 'getState' | 'refresh' | 'notifySnapshot' | 'dispose'
48
+ >
49
+
50
+ /** Optional injection capability consumed by the detail surface. */
51
+ export interface InjectUiPort {
52
+ actions: InjectActions
53
+ /** Read when the panel opens so late settings updates are observed. */
54
+ getDefaultMode: () => InjectMode
55
+ }
56
+
57
+ /** Dependencies required to assemble one session-detail surface. */
58
+ export interface DetailUiPort {
59
+ inject?: InjectUiPort
60
+ /** Live settings-backed analysis gate; defaults remain fail-closed. */
61
+ getAnalysisEnabled: () => boolean
62
+ createDetailStore: (sessionId: string, hint: DetailHeaderHint | null) => DetailStorePort
63
+ createSearchStore: () => SearchStorePort
64
+ createAnalysisStore: () => AnalysisStorePort
65
+ }
66
+
67
+ /** Dependencies required by the board and the detail route it opens. */
68
+ export interface BoardUiPort {
69
+ detail: DetailUiPort
70
+ createProjectsStore: () => ProjectsStorePort
71
+ }
72
+
73
+ type DetailIntegrationBase = Pick<DetailUiPort, 'inject' | 'getAnalysisEnabled'>
74
+
75
+ /** Bind the production store implementations at the client composition root. */
76
+ export function createDefaultIntegration(base: DetailIntegrationBase): BoardUiPort {
77
+ return {
78
+ detail: {
79
+ ...base,
80
+ createDetailStore: (sessionId, hint) => new DetailStore(sessionId, { hint }),
81
+ createSearchStore: () => new SearchStore(),
82
+ createAnalysisStore: () => new AnalysisStore(),
83
+ },
84
+ createProjectsStore: () => new ProjectsStore(),
85
+ }
86
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { CSSProperties, ReactElement } from 'react'
15
15
  import { widgetTitle, type WidgetConnection } from './board/logic.ts'
16
+ import { surfaceProps } from './theme/parts.ts'
16
17
 
17
18
  export interface SidecarWidgetProps {
18
19
  connection: WidgetConnection
@@ -22,9 +23,9 @@ export interface SidecarWidgetProps {
22
23
  }
23
24
 
24
25
  const DOT_COLORS: Record<WidgetConnection, string> = {
25
- ok: 'var(--dsw-alias-state-success-primary, #1a7f37)',
26
- degraded: 'var(--dsw-alias-state-warn-primary, #9a6700)',
27
- off: 'var(--dsw-alias-label-dimmed, #8c959f)',
26
+ ok: 'var(--agsc-ok)',
27
+ degraded: 'var(--agsc-warn)',
28
+ off: 'var(--agsc-fg-dimmed)',
28
29
  }
29
30
 
30
31
  const rootStyle: CSSProperties = {
@@ -36,7 +37,7 @@ const rootStyle: CSSProperties = {
36
37
  borderRadius: 6,
37
38
  background: 'transparent',
38
39
  font: 'inherit',
39
- color: 'var(--dsw-alias-label-secondary, #57606a)',
40
+ color: 'var(--agsc-fg-secondary)',
40
41
  }
41
42
 
42
43
  const countStyle: CSSProperties = {
@@ -49,10 +50,8 @@ const countStyle: CSSProperties = {
49
50
  /**
50
51
  * Connection dot + working-session counter (e.g. `▸2`) for the footer.
51
52
  *
52
- * Without a wired `onOpen` the widget renders as an inert status `<span>`
53
- * (UX-06): button semantics + pointer cursor on a control that does
54
- * nothing would be a lie. The button form returns as soon as the
55
- * integration layer supplies the callback.
53
+ * Without `onOpen` the widget remains an inert status `<span>` so button
54
+ * semantics are only emitted for an actual control.
56
55
  */
57
56
  export function SidecarWidget(props: SidecarWidgetProps): ReactElement {
58
57
  const title = widgetTitle(props.connection, props.workingCount)
@@ -78,6 +77,7 @@ export function SidecarWidget(props: SidecarWidgetProps): ReactElement {
78
77
  if (props.onOpen === undefined) {
79
78
  return (
80
79
  <span
80
+ {...surfaceProps('footer-widget')}
81
81
  style={rootStyle}
82
82
  title={title}
83
83
  aria-label={title}
@@ -91,6 +91,7 @@ export function SidecarWidget(props: SidecarWidgetProps): ReactElement {
91
91
  }
92
92
  return (
93
93
  <button
94
+ {...surfaceProps('footer-widget')}
94
95
  type="button"
95
96
  style={{ ...rootStyle, cursor: 'pointer' }}
96
97
  title={title}
@@ -1,22 +0,0 @@
1
- /* Modal host for the inject panel (S5 wiring): a centered dialog over the
2
- * board, opened by selecting a session card. Backdrop rides the dsh mask
3
- * token --dsw-alias-bg-mask-1 (the ui-attachment / ui-settings-general
4
- * modal precedent) — same no-literal-colors discipline as the panel. */
5
-
6
- .backdrop {
7
- position: fixed;
8
- inset: 0;
9
- z-index: 1000;
10
- display: flex;
11
- align-items: center;
12
- justify-content: center;
13
- padding: 24px;
14
- background: var(--dsw-alias-bg-mask-1, rgba(0, 0, 0, 0.45));
15
- }
16
-
17
- .dialog {
18
- width: min(560px, 100%);
19
- max-height: min(85vh, 720px);
20
- overflow: auto;
21
- border-radius: 12px;
22
- }