@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.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- 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
|
+
}
|