dsh-better-sidebar 0.10.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/README_EN.md +139 -0
  4. package/lib/client-docx.js +7649 -0
  5. package/lib/client-editor.js +32677 -0
  6. package/lib/client-pptx.js +63438 -0
  7. package/lib/client-registry.js +8021 -0
  8. package/lib/client-terminal.js +8060 -0
  9. package/lib/client-xlsx.js +359755 -0
  10. package/lib/client.js +8021 -0
  11. package/lib/index.js +2604 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-pty.d.ts +220 -0
  14. package/lib/types/browser-probe.d.ts +15 -0
  15. package/lib/types/bundle-route.d.ts +13 -0
  16. package/lib/types/client/BrowserView.d.ts +22 -0
  17. package/lib/types/client/DiffTab.d.ts +6 -0
  18. package/lib/types/client/DiffView.d.ts +51 -0
  19. package/lib/types/client/EditorHost.d.ts +10 -0
  20. package/lib/types/client/ExplorerView.d.ts +9 -0
  21. package/lib/types/client/GitView.d.ts +8 -0
  22. package/lib/types/client/OrphanedTab.d.ts +11 -0
  23. package/lib/types/client/PdfView.d.ts +6 -0
  24. package/lib/types/client/PptxView.d.ts +6 -0
  25. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  26. package/lib/types/client/SideCardSection.d.ts +27 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +38 -0
  30. package/lib/types/client/TerminalView.d.ts +8 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/api.d.ts +170 -0
  33. package/lib/types/client/binary-download.d.ts +12 -0
  34. package/lib/types/client/breakpoints.d.ts +12 -0
  35. package/lib/types/client/browser.d.ts +50 -0
  36. package/lib/types/client/builtins/index.d.ts +17 -0
  37. package/lib/types/client/builtins/tabs.d.ts +6 -0
  38. package/lib/types/client/builtins/viewers.d.ts +3 -0
  39. package/lib/types/client/chunk-loader.d.ts +74 -0
  40. package/lib/types/client/chunks/docx.d.ts +8 -0
  41. package/lib/types/client/chunks/editor.d.ts +9 -0
  42. package/lib/types/client/chunks/pptx.d.ts +8 -0
  43. package/lib/types/client/chunks/terminal.d.ts +9 -0
  44. package/lib/types/client/chunks/xlsx.d.ts +9 -0
  45. package/lib/types/client/cm-themes.d.ts +24 -0
  46. package/lib/types/client/conversation-draft.d.ts +14 -0
  47. package/lib/types/client/docx-view.d.ts +8 -0
  48. package/lib/types/client/editor-load.d.ts +66 -0
  49. package/lib/types/client/icons.d.ts +47 -0
  50. package/lib/types/client/image-types.d.ts +3 -0
  51. package/lib/types/client/ime-guard.d.ts +36 -0
  52. package/lib/types/client/index.d.ts +10 -0
  53. package/lib/types/client/intercept.d.ts +20 -0
  54. package/lib/types/client/lang.d.ts +12 -0
  55. package/lib/types/client/lazy-chunk.d.ts +25 -0
  56. package/lib/types/client/link-intercept.d.ts +35 -0
  57. package/lib/types/client/locales.d.ts +237 -0
  58. package/lib/types/client/office-shared.d.ts +33 -0
  59. package/lib/types/client/office-types.d.ts +36 -0
  60. package/lib/types/client/office-view.d.ts +12 -0
  61. package/lib/types/client/openpath-intercept.d.ts +41 -0
  62. package/lib/types/client/paths.d.ts +19 -0
  63. package/lib/types/client/pdf-types.d.ts +2 -0
  64. package/lib/types/client/prefs.d.ts +30 -0
  65. package/lib/types/client/produced-files.d.ts +27 -0
  66. package/lib/types/client/selection-payload.d.ts +27 -0
  67. package/lib/types/client/service.d.ts +214 -0
  68. package/lib/types/client/split-pane.d.ts +29 -0
  69. package/lib/types/client/state.d.ts +322 -0
  70. package/lib/types/client/subagent-activity.d.ts +35 -0
  71. package/lib/types/client/subagent-detect.d.ts +47 -0
  72. package/lib/types/client/subagent-jobs.d.ts +63 -0
  73. package/lib/types/client/theme.d.ts +28 -0
  74. package/lib/types/client/xlsx-to-univer.d.ts +33 -0
  75. package/lib/types/client/xlsx-view.d.ts +10 -0
  76. package/lib/types/config.d.ts +41 -0
  77. package/lib/types/context-types.d.ts +431 -0
  78. package/lib/types/fs-tree.d.ts +41 -0
  79. package/lib/types/git.d.ts +75 -0
  80. package/lib/types/html-route.d.ts +48 -0
  81. package/lib/types/index.d.ts +39 -0
  82. package/lib/types/invariant.d.ts +15 -0
  83. package/lib/types/jobs-routes.d.ts +45 -0
  84. package/lib/types/prefs-shared.d.ts +106 -0
  85. package/lib/types/pty-manager.d.ts +76 -0
  86. package/lib/types/tools.d.ts +28 -0
  87. package/lib/types/trust-fence.d.ts +24 -0
  88. package/lib/types/wire.d.ts +38 -0
  89. package/package.json +168 -0
  90. package/src/agent-pty.ts +519 -0
  91. package/src/browser-probe.ts +26 -0
  92. package/src/bundle-route.ts +130 -0
  93. package/src/client/BrowserView.tsx +253 -0
  94. package/src/client/DiffTab.tsx +110 -0
  95. package/src/client/DiffView.tsx +266 -0
  96. package/src/client/EditorHost.tsx +101 -0
  97. package/src/client/ExplorerView.tsx +283 -0
  98. package/src/client/GitView.tsx +540 -0
  99. package/src/client/OrphanedTab.tsx +27 -0
  100. package/src/client/PdfView.tsx +110 -0
  101. package/src/client/PptxView.tsx +131 -0
  102. package/src/client/SandboxStatusBar.tsx +60 -0
  103. package/src/client/SideCardSection.module.css +271 -0
  104. package/src/client/SideCardSection.tsx +426 -0
  105. package/src/client/Sidebar.tsx +861 -0
  106. package/src/client/SubagentView.module.css +547 -0
  107. package/src/client/SubagentView.tsx +866 -0
  108. package/src/client/TabBar.tsx +188 -0
  109. package/src/client/TerminalView.tsx +233 -0
  110. package/src/client/TextEditor.tsx +382 -0
  111. package/src/client/api.ts +215 -0
  112. package/src/client/binary-download.tsx +23 -0
  113. package/src/client/breakpoints.ts +51 -0
  114. package/src/client/browser.ts +119 -0
  115. package/src/client/builtins/index.ts +33 -0
  116. package/src/client/builtins/tabs.tsx +211 -0
  117. package/src/client/builtins/viewers.tsx +151 -0
  118. package/src/client/chunk-loader.ts +196 -0
  119. package/src/client/chunks/docx.tsx +8 -0
  120. package/src/client/chunks/editor.tsx +9 -0
  121. package/src/client/chunks/pptx.tsx +8 -0
  122. package/src/client/chunks/terminal.tsx +9 -0
  123. package/src/client/chunks/xlsx.tsx +9 -0
  124. package/src/client/cm-themes.ts +129 -0
  125. package/src/client/conversation-draft.ts +29 -0
  126. package/src/client/css-modules.d.ts +5 -0
  127. package/src/client/docx-view.tsx +113 -0
  128. package/src/client/editor-load.ts +92 -0
  129. package/src/client/icons.tsx +137 -0
  130. package/src/client/image-types.ts +8 -0
  131. package/src/client/ime-guard.ts +47 -0
  132. package/src/client/index.tsx +238 -0
  133. package/src/client/intercept.tsx +94 -0
  134. package/src/client/lang.ts +95 -0
  135. package/src/client/layout.css +54 -0
  136. package/src/client/lazy-chunk.tsx +89 -0
  137. package/src/client/link-intercept.ts +66 -0
  138. package/src/client/locales.ts +476 -0
  139. package/src/client/office-shared.tsx +40 -0
  140. package/src/client/office-types.ts +48 -0
  141. package/src/client/office-view.tsx +12 -0
  142. package/src/client/openpath-intercept.ts +62 -0
  143. package/src/client/paths.ts +28 -0
  144. package/src/client/pdf-types.ts +4 -0
  145. package/src/client/prefs.ts +100 -0
  146. package/src/client/produced-files.ts +87 -0
  147. package/src/client/selection-payload.ts +86 -0
  148. package/src/client/service.ts +434 -0
  149. package/src/client/sidebar.module.css +1917 -0
  150. package/src/client/split-pane.tsx +292 -0
  151. package/src/client/state.ts +1048 -0
  152. package/src/client/subagent-activity.ts +67 -0
  153. package/src/client/subagent-detect.ts +125 -0
  154. package/src/client/subagent-jobs.ts +161 -0
  155. package/src/client/theme.ts +44 -0
  156. package/src/client/xlsx-to-univer.ts +260 -0
  157. package/src/client/xlsx-view.tsx +116 -0
  158. package/src/config.ts +95 -0
  159. package/src/context-types.ts +445 -0
  160. package/src/fs-tree.ts +115 -0
  161. package/src/git.ts +236 -0
  162. package/src/html-route.ts +70 -0
  163. package/src/index.ts +897 -0
  164. package/src/invariant.ts +32 -0
  165. package/src/jobs-routes.ts +257 -0
  166. package/src/prefs-shared.ts +127 -0
  167. package/src/pty-manager.ts +199 -0
  168. package/src/tools.ts +477 -0
  169. package/src/trust-fence.ts +77 -0
  170. package/src/wire.ts +94 -0
@@ -0,0 +1,866 @@
1
+ /**
2
+ * Subagent page: the FULL agent topology of the current tree's main session.
3
+ *
4
+ * The root is resolved by walking the durable parent chain upward from the
5
+ * current session to the first non-subagent session — the MAIN session — and
6
+ * every subagent under it shares this one topology view, no matter how deep
7
+ * the current selection is (including a subagent transcript opened in the
8
+ * main view). The main agent renders as the root node card (click it to jump
9
+ * back to the main session), with its subagents hanging below it in clearly
10
+ * LAYERED levels: tree connector lines (first level included) and per-level
11
+ * indentation show the hierarchy, and the currently-open session is
12
+ * highlighted in place. Every branch is expanded automatically (lazy
13
+ * catalogs hydrate on demand and consume live membership while visible).
14
+ *
15
+ * Each node card carries live status (state dot, durable label, mode and
16
+ * activity); while a child RUNS, its card additionally shows the LAST text
17
+ * output and LAST tool call pulled from its history tail, auto-refreshing
18
+ * every few seconds while the page is visible. Clicking a card jumps
19
+ * straight into the child transcript (`openSubagent`); the page stays open
20
+ * and the topology remains rooted at the main session.
21
+ */
22
+ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
23
+ import { useSyncExternalStore } from 'react'
24
+ import clsx from 'clsx'
25
+ import {
26
+ IconRefreshOutline14, StateDot,
27
+ } from '@deepseek-ai/dsh-client-ui-primitives'
28
+ import type {
29
+ Context,
30
+ SidebarSessionList,
31
+ SidebarSessionSummary,
32
+ SidebarSubagentAddress,
33
+ SidebarSubagentCatalog,
34
+ SidebarSubagentChildEntry,
35
+ SidebarSubagentDiagnosticEntry,
36
+ SidebarJobView,
37
+ } from '../context-types.ts'
38
+ import {
39
+ collectBranchIds,
40
+ countSubagentDescendants,
41
+ rootAncestor,
42
+ } from './subagent-detect.ts'
43
+ import { lastActivity } from './subagent-activity.ts'
44
+ import {
45
+ collectTreeJobs,
46
+ formatJobDuration,
47
+ isJobLive,
48
+ orderJobs,
49
+ jobDotState,
50
+ jobStatusLabel,
51
+ type TreeJob,
52
+ } from './subagent-jobs.ts'
53
+ import { api, type JobOutputResult } from './api.ts'
54
+ import { IconStopOutline16 } from './icons.tsx'
55
+ import { t } from './locales.ts'
56
+ import css from './SubagentView.module.css'
57
+
58
+ /** Refresh cadence of the live "last text + tool call" lines while a child runs. */
59
+ const POLL_MS = 3000
60
+ /** Preview cap of one tool-call argument line. */
61
+ const ARGS_PREVIEW = 60
62
+ /** Refresh cadence of an expanded job-output panel while its job runs. */
63
+ const JOB_POLL_MS = 2000
64
+ /** How long the kill button stays armed before it needs re-confirming. */
65
+ const JOB_KILL_ARM_MS = 3000
66
+
67
+ /** The direct subagent children of one parent (durable `origin` rows). */
68
+ function directChildren(
69
+ byId: Readonly<Record<string, SidebarSessionSummary>>,
70
+ parentSessionId: string,
71
+ ): SidebarSessionSummary[] {
72
+ return Object.values(byId).filter(
73
+ summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
74
+ )
75
+ }
76
+
77
+ /** Human label of one catalog child: durable label, then summary title, then id. */
78
+ function childLabel(
79
+ entry: SidebarSubagentChildEntry,
80
+ summary: SidebarSessionSummary | undefined,
81
+ ): string {
82
+ return entry.label ?? summary?.displayTitle ?? entry.id
83
+ }
84
+
85
+ function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
86
+ switch (entry.reason) {
87
+ case 'corrupt': return t('subagentDiagCorrupt')
88
+ case 'unsupported': return t('subagentDiagUnsupported')
89
+ case 'unavailable': return t('subagentDiagUnavailable')
90
+ }
91
+ }
92
+
93
+ /** The secondary line of one card: title · mode · activity (skips empty parts). */
94
+ function cardSecondary(
95
+ summary: SidebarSessionSummary | undefined,
96
+ entry: SidebarSubagentChildEntry,
97
+ ): string {
98
+ return [
99
+ summary?.displayTitle,
100
+ entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
101
+ entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
102
+ ].filter(Boolean).join(' · ')
103
+ }
104
+
105
+ /** First `limit` characters with an ellipsis when truncated. */
106
+ function preview(text: string, limit: number): string {
107
+ return text.length > limit ? `${text.slice(0, limit)}…` : text
108
+ }
109
+
110
+ /** Collapse whitespace for the single-paragraph live-text preview. */
111
+ function flatten(text: string): string {
112
+ return text.replace(/\s+/g, ' ').trim()
113
+ }
114
+
115
+ /** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
116
+ function CatalogLoadingRows(props: {
117
+ parentSessionId: string
118
+ byId: Readonly<Record<string, SidebarSessionSummary>>
119
+ level: number
120
+ }) {
121
+ const { parentSessionId, byId, level } = props
122
+ const children = directChildren(byId, parentSessionId)
123
+ if (children.length === 0) {
124
+ return <div className={css.subagentEmpty}>{t('loading')}</div>
125
+ }
126
+ return (
127
+ <>
128
+ {children.map(summary => (
129
+ <div
130
+ key={summary.id}
131
+ role="treeitem"
132
+ aria-disabled="true"
133
+ aria-level={level}
134
+ aria-label={t('loading')}
135
+ className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
136
+ >
137
+ <StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
138
+ <span className={css.subagentContent}>
139
+ <span className={css.subagentLabel}>{t('loading')}</span>
140
+ </span>
141
+ </div>
142
+ ))}
143
+ </>
144
+ )
145
+ }
146
+
147
+ /**
148
+ * The live lines of one RUNNING subagent card: the last text output and the
149
+ * last tool call of the child's history tail, refreshed every few seconds
150
+ * while the page is visible. Idle cards render nothing (a quiet topology); a
151
+ * running child with neither output yet reads "thinking…".
152
+ */
153
+ function SubagentLiveLines(props: {
154
+ ctx: Context
155
+ parentSessionId: string
156
+ childSessionId: string
157
+ mode: SidebarSubagentAddress['mode']
158
+ running: boolean
159
+ /** The page is visible (active tab + open panel): skip polling otherwise. */
160
+ active: boolean
161
+ }) {
162
+ const { ctx, parentSessionId, childSessionId, mode, running, active } = props
163
+ const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
164
+ const controllerRef = useRef<AbortController | undefined>(undefined)
165
+ const address = useMemo(
166
+ () => ({ parentSessionId, childSessionId, mode }),
167
+ [parentSessionId, childSessionId, mode],
168
+ )
169
+
170
+ const load = useCallback(async (): Promise<void> => {
171
+ controllerRef.current?.abort()
172
+ const controller = new AbortController()
173
+ controllerRef.current = controller
174
+ try {
175
+ const response = await ctx.connection.api.subagents.history(
176
+ { ...address, maxMessages: 12 },
177
+ controller.signal,
178
+ )
179
+ if (!response.result.ok) return
180
+ setLive(lastActivity(response.result.value.events))
181
+ } catch {
182
+ // Aborted by a newer pull or a wire failure: keep the last known lines.
183
+ }
184
+ }, [ctx, address])
185
+
186
+ useEffect(() => {
187
+ if (!active) return
188
+ void load()
189
+ if (!running) return
190
+ const timer = window.setInterval(() => { void load() }, POLL_MS)
191
+ return () => { window.clearInterval(timer) }
192
+ }, [load, running, active])
193
+
194
+ useEffect(() => () => { controllerRef.current?.abort() }, [])
195
+
196
+ if (!running) return null
197
+ if (live.text === undefined && live.tool === undefined) {
198
+ return <span className={css.subagentLive}>{t('subagentThinking')}</span>
199
+ }
200
+ return (
201
+ <>
202
+ {live.tool !== undefined && (
203
+ <span className={css.subagentLive}>
204
+ <span className={css.subagentLiveTool}>{live.tool.name}</span>
205
+ {live.tool.args !== '' && (
206
+ <span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
207
+ )}
208
+ </span>
209
+ )}
210
+ {live.text !== undefined && (
211
+ <span className={css.subagentLiveText}>{flatten(live.text)}</span>
212
+ )}
213
+ </>
214
+ )
215
+ }
216
+
217
+ interface RowsProps {
218
+ parentSessionId: string
219
+ catalog: SidebarSubagentCatalog | undefined
220
+ catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
221
+ byId: Readonly<Record<string, SidebarSessionSummary>>
222
+ level: number
223
+ /** The currently-open session id (highlighted in the topology). */
224
+ currentSessionId: string
225
+ /** The page is visible (active tab + open panel): live polling pauses otherwise. */
226
+ active: boolean
227
+ ctx: Context
228
+ openChild: (address: SidebarSubagentAddress) => void
229
+ refresh: (parentSessionId: string) => void
230
+ }
231
+
232
+ /** Render one topology level; branches are always expanded (lazy catalogs). */
233
+ function CatalogRows({
234
+ parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
235
+ openChild, refresh,
236
+ }: RowsProps) {
237
+ const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
238
+ return (
239
+ <>
240
+ {emptyLoading && (
241
+ <CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
242
+ )}
243
+ {catalog?.state === 'error' && (
244
+ <div className={css.subagentError}>
245
+ <span>{catalog.error?.message ?? t('error')}</span>
246
+ <button
247
+ type="button"
248
+ className={css.subagentErrorRetry}
249
+ onClick={() => { refresh(parentSessionId) }}
250
+ >
251
+ <IconRefreshOutline14 />
252
+ {t('retry')}
253
+ </button>
254
+ </div>
255
+ )}
256
+ {(catalog?.entries ?? []).map((entry) => {
257
+ if (entry.kind === 'diagnostic') {
258
+ return (
259
+ <div key={entry.id} className={css.subagentNode}>
260
+ <div
261
+ role="treeitem"
262
+ aria-disabled="true"
263
+ aria-level={level}
264
+ className={`${css.subagentRow} ${css.subagentRowDisabled}`}
265
+ title={diagnosticReason(entry)}
266
+ >
267
+ <StateDot state="error" className={css.subagentDot} />
268
+ <span className={css.subagentContent}>
269
+ <span className={css.subagentLabel}>{entry.id}</span>
270
+ <span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
271
+ </span>
272
+ </div>
273
+ </div>
274
+ )
275
+ }
276
+
277
+ const childCatalog = catalogs[entry.id]
278
+ const knownLeaf = !entry.hasChildren
279
+ const summary = byId[entry.id]
280
+ const label = childLabel(entry, summary)
281
+ const secondary = cardSecondary(summary, entry)
282
+ const childLoading = childCatalog === undefined
283
+ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
284
+ const address: SidebarSubagentAddress = {
285
+ parentSessionId,
286
+ childSessionId: entry.id,
287
+ mode: entry.mode,
288
+ }
289
+ const current = entry.id === currentSessionId
290
+
291
+ return (
292
+ <div key={entry.id} className={css.subagentNode}>
293
+ <div
294
+ role="treeitem"
295
+ tabIndex={0}
296
+ aria-level={level}
297
+ aria-label={`${label} ${secondary}`}
298
+ aria-current={current ? 'true' : undefined}
299
+ {...knownLeaf ? {} : { 'aria-expanded': true }}
300
+ className={clsx(css.subagentRow, current && css.subagentRowActive)}
301
+ onClick={() => { openChild(address) }}
302
+ onKeyDown={(event) => {
303
+ if (event.key === 'Enter' || event.key === ' ') {
304
+ event.preventDefault()
305
+ event.stopPropagation()
306
+ openChild(address)
307
+ }
308
+ }}
309
+ >
310
+ <StateDot
311
+ state={entry.activity === 'running' ? 'ongoing' : 'done'}
312
+ className={css.subagentDot}
313
+ />
314
+ <span className={css.subagentContent}>
315
+ <span className={css.subagentLabel}>{label}</span>
316
+ <span className={css.subagentSecondary}>{secondary}</span>
317
+ <SubagentLiveLines
318
+ ctx={ctx}
319
+ parentSessionId={parentSessionId}
320
+ childSessionId={entry.id}
321
+ mode={entry.mode}
322
+ running={entry.activity === 'running'}
323
+ active={active}
324
+ />
325
+ </span>
326
+ </div>
327
+ {!knownLeaf && (
328
+ <div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
329
+ {childCatalog === undefined
330
+ ? (
331
+ <CatalogLoadingRows
332
+ parentSessionId={entry.id}
333
+ byId={byId}
334
+ level={level + 1}
335
+ />
336
+ )
337
+ : (
338
+ <CatalogRows
339
+ parentSessionId={entry.id}
340
+ catalog={childCatalog}
341
+ catalogs={catalogs}
342
+ byId={byId}
343
+ level={level + 1}
344
+ currentSessionId={currentSessionId}
345
+ active={active}
346
+ ctx={ctx}
347
+ openChild={openChild}
348
+ refresh={refresh}
349
+ />
350
+ )}
351
+ </div>
352
+ )}
353
+ </div>
354
+ )
355
+ })}
356
+ </>
357
+ )
358
+ }
359
+
360
+ /**
361
+ * The shared output dock of the jobs section: ONE pane at the bottom of the
362
+ * sidebar body (sticky, terminal-like) shows the SELECTED job's output as
363
+ * the MODEL has read it so far (replayed from the owner session's event
364
+ * log), refreshed every {@link JOB_POLL_MS} while the job runs and the
365
+ * page is visible. The model's `job_output` cursor is never touched — the
366
+ * pane can never steal the agent's bytes, and it stays empty until the
367
+ * agent reads the job. A single dock — not a panel per row — keeps the
368
+ * job list compact and stable when many jobs are running.
369
+ */
370
+ function JobOutputPane(props: {
371
+ ownerSessionId: string
372
+ job: SidebarJobView
373
+ /** The page is visible (active tab + open panel): skip polling otherwise. */
374
+ active: boolean
375
+ onClose: () => void
376
+ }) {
377
+ const { ownerSessionId, job, active, onClose } = props
378
+ const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
379
+ const controllerRef = useRef<AbortController | undefined>(undefined)
380
+ const preRef = useRef<HTMLPreElement>(null)
381
+
382
+ const load = useCallback(async (): Promise<void> => {
383
+ controllerRef.current?.abort()
384
+ const controller = new AbortController()
385
+ controllerRef.current = controller
386
+ try {
387
+ const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
388
+ setState(result)
389
+ } catch {
390
+ // A newer pull aborted this one, or the wire failed: keep the last
391
+ // known output; only a dock that never loaded anything shows an error.
392
+ setState(current => (current === 'loading' ? 'error' : current))
393
+ }
394
+ }, [ownerSessionId, job.id])
395
+
396
+ useEffect(() => {
397
+ void load()
398
+ if (!active || !isJobLive(job)) return
399
+ const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
400
+ return () => { window.clearInterval(timer) }
401
+ }, [load, active, job.status])
402
+
403
+ useEffect(() => () => { controllerRef.current?.abort() }, [])
404
+
405
+ // Terminal-tail behavior: while the job runs, each refresh pins the view
406
+ // to the newest output; a settled dock leaves scrolling to the reader.
407
+ useEffect(() => {
408
+ if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
409
+ const pre = preRef.current
410
+ if (pre !== null) pre.scrollTop = pre.scrollHeight
411
+ }, [state, job.status])
412
+
413
+ return (
414
+ <div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
415
+ <div className={css.jobsPaneHeader}>
416
+ <StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
417
+ <span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
418
+ <span className={css.jobsPaneStatus}>
419
+ {jobStatusLabel(job.status, t)}
420
+ {job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
421
+ </span>
422
+ <button
423
+ type="button"
424
+ className={css.jobsPaneClose}
425
+ aria-label={t('close')}
426
+ title={t('close')}
427
+ onClick={onClose}
428
+ >
429
+ <IconStopOutline16 size={10} />
430
+ </button>
431
+ </div>
432
+ {state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
433
+ {state === 'error' && (
434
+ <div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
435
+ )}
436
+ {typeof state === 'object' && (
437
+ <>
438
+ {state.text.length > 0
439
+ ? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
440
+ : state.read
441
+ ? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
442
+ : <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
443
+ {state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
444
+ </>
445
+ )}
446
+ </div>
447
+ )
448
+ }
449
+
450
+ /**
451
+ * The background-job section of the Subagent page: every job of the whole
452
+ * current tree (main agent + subagents, owner-labeled), fed by the harness
453
+ * `session/jobs` push mirror. Clicking a row feeds its model-read output to
454
+ * the shared bottom dock (event replay — never the model's cursor); live
455
+ * rows carry a two-click-confirm kill button. Renders nothing while the
456
+ * tree has no jobs.
457
+ */
458
+ function JobsSection(props: {
459
+ byId: SidebarSessionList['byId']
460
+ jobsBySession: SidebarSessionList['jobsBySession']
461
+ rootId: string | undefined
462
+ /** The page is visible (active tab + open panel): skip polling otherwise. */
463
+ active: boolean
464
+ }) {
465
+ const { byId, jobsBySession, rootId, active } = props
466
+ const rows = useMemo(
467
+ () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
468
+ [byId, jobsBySession, rootId],
469
+ )
470
+ const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
471
+ const [armedId, setArmedId] = useState<string | undefined>(undefined)
472
+ const [killingId, setKillingId] = useState<string | undefined>(undefined)
473
+ const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
474
+ // The duration clock only runs while a live row is on screen.
475
+ const [now, setNow] = useState(() => Date.now())
476
+
477
+ const selectedRow = useMemo(
478
+ () => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
479
+ [rows, selectedId],
480
+ )
481
+
482
+ const liveCount = useMemo(
483
+ () => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
484
+ [rows],
485
+ )
486
+ const multiOwner = useMemo(
487
+ () => new Set(rows.map(row => row.ownerSessionId)).size > 1,
488
+ [rows],
489
+ )
490
+
491
+ // The kill button stays armed only briefly; a stray click must never kill.
492
+ useEffect(() => {
493
+ if (armedId === undefined) return
494
+ const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
495
+ return () => { window.clearTimeout(timer) }
496
+ }, [armedId])
497
+
498
+ useEffect(() => {
499
+ if (liveCount === 0) return
500
+ setNow(Date.now())
501
+ const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
502
+ return () => { window.clearInterval(timer) }
503
+ }, [liveCount])
504
+
505
+ // The docked output pane follows its job: when the selected job leaves
506
+ // the mirror (settled and dropped, or the tree switched), close the dock.
507
+ useEffect(() => {
508
+ if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
509
+ }, [selectedId, selectedRow])
510
+
511
+ // NOTE: every hook must live ABOVE the empty-state return — a hook below it
512
+ // would flip this component's hook count when the mirror empties and crash
513
+ // React with "Rendered fewer hooks than expected" (the #300 regression).
514
+ const kill = useCallback(async (row: TreeJob): Promise<void> => {
515
+ setKillingId(row.job.id)
516
+ setKillErrorId(undefined)
517
+ try {
518
+ await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
519
+ } catch {
520
+ setKillErrorId(row.job.id)
521
+ } finally {
522
+ setKillingId(undefined)
523
+ setArmedId(undefined)
524
+ }
525
+ }, [])
526
+
527
+ if (rows.length === 0) return null
528
+
529
+ const countLabel = liveCount > 0
530
+ ? t('jobsCountRunning', { count: rows.length, running: liveCount })
531
+ : t('jobsCount', { count: rows.length })
532
+
533
+ return (
534
+ <>
535
+ <section className={css.jobs} aria-label={t('jobs')}>
536
+ <div className={css.jobsHeader}>
537
+ <span className={css.jobsTitle}>{t('jobs')}</span>
538
+ <span className={css.jobsCount}>{countLabel}</span>
539
+ </div>
540
+ <ul className={css.jobsList} aria-label={t('jobs')}>
541
+ {rows.map((row) => {
542
+ const { job } = row
543
+ const live = isJobLive(job)
544
+ const selected = selectedId === job.id
545
+ const armed = armedId === job.id
546
+ const killing = killingId === job.id
547
+ const killFailed = killErrorId === job.id
548
+ const elapsed = live
549
+ ? now - job.startedAt
550
+ : (job.finishedAt ?? job.startedAt) - job.startedAt
551
+ const secondary = [
552
+ ...(multiOwner ? [row.ownerTitle] : []),
553
+ jobStatusLabel(job.status, t),
554
+ ...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
555
+ formatJobDuration(elapsed, t),
556
+ ].filter(Boolean).join(' · ')
557
+ return (
558
+ <li
559
+ key={job.id}
560
+ className={clsx(
561
+ css.jobsRow,
562
+ !live && css.jobsRowSettled,
563
+ selected && css.jobsRowSelected,
564
+ )}
565
+ >
566
+ <button
567
+ type="button"
568
+ className={css.jobsRowMain}
569
+ aria-pressed={selected}
570
+ aria-label={`${job.label} ${secondary}`}
571
+ onClick={() => { setSelectedId(selected ? undefined : job.id) }}
572
+ >
573
+ <StateDot state={jobDotState(job.status)} className={css.jobsDot} />
574
+ <span className={css.jobsContent}>
575
+ <span className={css.jobsLabelLine}>
576
+ <span className={css.jobsKind}>{job.kind}</span>
577
+ <span className={css.jobsLabel} title={job.label}>{job.label}</span>
578
+ </span>
579
+ <span className={css.jobsSecondary}>{secondary}</span>
580
+ </span>
581
+ </button>
582
+ {job.status === 'running' && (
583
+ <button
584
+ type="button"
585
+ className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
586
+ aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
587
+ title={armed ? t('jobKillConfirm') : t('jobKill')}
588
+ disabled={killing}
589
+ onClick={(event) => {
590
+ event.stopPropagation()
591
+ if (armed) void kill(row)
592
+ else setArmedId(job.id)
593
+ }}
594
+ >
595
+ {armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
596
+ </button>
597
+ )}
598
+ {killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
599
+ </li>
600
+ )
601
+ })}
602
+ </ul>
603
+ </section>
604
+ {selectedRow !== undefined && (
605
+ <JobOutputPane
606
+ ownerSessionId={selectedRow.ownerSessionId}
607
+ job={selectedRow.job}
608
+ active={active}
609
+ onClose={() => { setSelectedId(undefined) }}
610
+ />
611
+ )}
612
+ </>
613
+ )
614
+ }
615
+
616
+ /**
617
+ * The sidebar's Subagent topology page.
618
+ * @param props - current session id, whether the page is actually visible
619
+ * (active tab + open panel), the client context, and an optional
620
+ * jump-notify hook fired right before `openSubagent` (lets the sidebar
621
+ * shell re-open the Subagent page after the conversation switch lands on
622
+ * the child session).
623
+ * @returns the main agent's topology tree, or the empty/error/loading states.
624
+ */
625
+ export function SubagentView(props: {
626
+ sessionId: string
627
+ active: boolean
628
+ ctx: Context
629
+ onOpenChild?: (address: SidebarSubagentAddress) => void
630
+ }) {
631
+ const { sessionId, active, ctx, onOpenChild } = props
632
+ const sessions = ctx.sessions
633
+
634
+ // The same list feed the official catalog consumes (byId lineage + the
635
+ // lazy per-parent catalogs). Older DSH snapshots without the subagent seam
636
+ // simply leave these surfaces empty — the page degrades to the empty state.
637
+ const list = useSyncExternalStore(
638
+ useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
639
+ useCallback(() => sessions.list.getSnapshot(), [sessions]),
640
+ )
641
+ const byId = list.byId
642
+ const catalogs = list.subagentsByParent ?? {}
643
+
644
+ // The topology root: the main agent of the current session's tree.
645
+ const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
646
+ const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
647
+ const rootSummary = rootId === undefined ? undefined : byId[rootId]
648
+
649
+ /** Catalog owners currently consuming live membership updates. */
650
+ const observedRef = useRef(new Set<string>())
651
+
652
+ const observe = useCallback((parentSessionId: string, open: boolean): void => {
653
+ sessions.setSubagentCatalogOpen?.(parentSessionId, open)
654
+ if (open) observedRef.current.add(parentSessionId)
655
+ else observedRef.current.delete(parentSessionId)
656
+ }, [sessions])
657
+
658
+ // While the page is visible the topology root consumes live membership; a
659
+ // root change (switching to another main agent's tree) or the page hiding
660
+ // (tab switched away / panel collapsed) releases everything observed.
661
+ useEffect(() => {
662
+ if (rootId === undefined || !active) return
663
+ observe(rootId, true)
664
+ return () => {
665
+ for (const parentSessionId of observedRef.current) {
666
+ sessions.setSubagentCatalogOpen?.(parentSessionId, false)
667
+ }
668
+ observedRef.current.clear()
669
+ }
670
+ }, [rootId, active, observe, sessions])
671
+
672
+ // Every branch of the always-expanded topology consumes live membership
673
+ // (add-only: a branch stays observed until the root changes or the page
674
+ // hides, which releases the whole set via the root effect's cleanup).
675
+ const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
676
+ useEffect(() => {
677
+ if (!active) return
678
+ for (const id of branches) {
679
+ if (!observedRef.current.has(id)) observe(id, true)
680
+ }
681
+ }, [branches, active, observe])
682
+
683
+ // Unobserve everything on unmount (the host stops refreshing unused catalogs).
684
+ useEffect(() => () => {
685
+ for (const parentSessionId of observedRef.current) {
686
+ sessions.setSubagentCatalogOpen?.(parentSessionId, false)
687
+ }
688
+ observedRef.current.clear()
689
+ }, [sessions])
690
+
691
+ const openChild = useCallback((address: SidebarSubagentAddress): void => {
692
+ // Notify the shell first: the jump switches the sidebar to the child
693
+ // session's own layout, and the shell re-opens the Subagent page on top
694
+ // of it (the topology stays rooted at the main agent with the child
695
+ // highlighted) — the README "page stays open" contract.
696
+ onOpenChild?.(address)
697
+ try {
698
+ sessions.openSubagent?.(address)
699
+ } catch (error) {
700
+ console.warn('[dsh-better-sidebar] openSubagent failed:', error)
701
+ }
702
+ }, [sessions, onOpenChild])
703
+
704
+ /** Jump back to the main agent (the topology root) from its node. */
705
+ const openMain = useCallback((): void => {
706
+ if (rootId === undefined) return
707
+ try {
708
+ sessions.open?.(rootId)
709
+ } catch (error) {
710
+ console.warn('[dsh-better-sidebar] open session failed:', error)
711
+ }
712
+ }, [sessions, rootId])
713
+
714
+ const refresh = useCallback((parentSessionId: string): void => {
715
+ void sessions.refreshSubagents?.(parentSessionId)
716
+ }, [sessions])
717
+
718
+ const totals = useMemo(
719
+ () => rootId === undefined
720
+ ? { count: 0, runningCount: 0 }
721
+ : countSubagentDescendants(byId, rootId),
722
+ [byId, rootId],
723
+ )
724
+ // Session summaries can announce membership before the descriptor-backed
725
+ // catalog catches up (or a catalog that just went ready is still empty).
726
+ const summaryBackedLoading = rootId !== undefined
727
+ && (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
728
+ && directChildren(byId, rootId).length > 0
729
+ const readyEmpty = rootCatalog?.state === 'ready'
730
+ && rootCatalog.entries.length === 0
731
+ && directChildren(byId, rootId ?? '').length === 0
732
+ const countLabel = totals.count === 0
733
+ ? undefined
734
+ : totals.runningCount > 0
735
+ ? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
736
+ : t('subagentCount', { count: totals.count })
737
+
738
+ /** Arrow-key tree navigation over the visible rows (official catalog recipe). */
739
+ const bodyRef = useRef<HTMLDivElement>(null)
740
+ const focusAt = useCallback((index: number): void => {
741
+ const items = bodyRef.current?.querySelectorAll<HTMLElement>(
742
+ '[role="treeitem"]:not([aria-disabled="true"])',
743
+ ) ?? []
744
+ if (items.length === 0) return
745
+ items[(index + items.length) % items.length]?.focus()
746
+ }, [])
747
+ const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
748
+ const items = bodyRef.current?.querySelectorAll<HTMLElement>(
749
+ '[role="treeitem"]:not([aria-disabled="true"])',
750
+ ) ?? []
751
+ const index = Array.prototype.indexOf.call(items, document.activeElement)
752
+ if (event.key === 'ArrowDown') {
753
+ event.preventDefault()
754
+ focusAt(index + 1)
755
+ } else if (event.key === 'ArrowUp') {
756
+ event.preventDefault()
757
+ focusAt(index < 0 ? items.length - 1 : index - 1)
758
+ } else if (event.key === 'Home') {
759
+ event.preventDefault()
760
+ focusAt(0)
761
+ } else if (event.key === 'End') {
762
+ event.preventDefault()
763
+ focusAt(items.length - 1)
764
+ }
765
+ }, [focusAt])
766
+
767
+ return (
768
+ <div className={css.subagent}>
769
+ <div className={css.subagentHeader}>
770
+ <span className={css.subagentTitle}>
771
+ {t('subagent')}
772
+ {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
773
+ ? ` · ${rootSummary.displayTitle}`
774
+ : ''}
775
+ </span>
776
+ {countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
777
+ <button
778
+ type="button"
779
+ className={css.subagentRefresh}
780
+ aria-label={t('refresh')}
781
+ title={t('refresh')}
782
+ disabled={rootId === undefined}
783
+ onClick={() => { if (rootId !== undefined) refresh(rootId) }}
784
+ >
785
+ <IconRefreshOutline14 />
786
+ </button>
787
+ </div>
788
+ <div
789
+ ref={bodyRef}
790
+ className={css.subagentBody}
791
+ onKeyDown={onTreeKeyDown}
792
+ >
793
+ <div
794
+ role="tree"
795
+ aria-label={t('subagent')}
796
+ aria-busy={summaryBackedLoading || undefined}
797
+ >
798
+ {rootId !== undefined && rootSummary !== undefined && (
799
+ <div
800
+ role="treeitem"
801
+ tabIndex={0}
802
+ aria-level={0}
803
+ aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
804
+ aria-current={rootId === sessionId ? 'true' : undefined}
805
+ className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
806
+ onClick={openMain}
807
+ onKeyDown={(event) => {
808
+ if (event.key === 'Enter' || event.key === ' ') {
809
+ event.preventDefault()
810
+ event.stopPropagation()
811
+ openMain()
812
+ }
813
+ }}
814
+ >
815
+ <StateDot
816
+ state={rootSummary.running === true ? 'ongoing' : 'done'}
817
+ className={css.subagentDot}
818
+ />
819
+ <span className={css.subagentContent}>
820
+ <span className={css.subagentLabel}>
821
+ {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
822
+ </span>
823
+ <span className={css.subagentSecondary}>
824
+ {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
825
+ </span>
826
+ </span>
827
+ </div>
828
+ )}
829
+ {rootId !== undefined && (
830
+ <div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
831
+ {summaryBackedLoading && (
832
+ <CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
833
+ )}
834
+ {!summaryBackedLoading && (
835
+ <CatalogRows
836
+ parentSessionId={rootId}
837
+ catalog={rootCatalog}
838
+ catalogs={catalogs}
839
+ byId={byId}
840
+ level={1}
841
+ currentSessionId={sessionId}
842
+ active={active}
843
+ ctx={ctx}
844
+ openChild={openChild}
845
+ refresh={refresh}
846
+ />
847
+ )}
848
+ </div>
849
+ )}
850
+ {readyEmpty && (
851
+ <div className={css.subagentEmpty}>
852
+ <div>{t('subagentEmpty')}</div>
853
+ <div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
854
+ </div>
855
+ )}
856
+ </div>
857
+ <JobsSection
858
+ byId={byId}
859
+ jobsBySession={list.jobsBySession}
860
+ rootId={rootId}
861
+ active={active}
862
+ />
863
+ </div>
864
+ </div>
865
+ )
866
+ }