@keith9681/dsh-sidebar 0.15.2

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