diff --git a/README.md b/README.md
index b15ae80..3b05ecc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# dsh-better-sidebar
-> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板,一个面板全部搞定。
+> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
@@ -112,6 +112,13 @@ dsh registry enable dsh-external/dsh-better-sidebar
- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史
+### 🧩 Subagent 页面
+
+- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
+- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
+- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
+- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
+
### 🧩 VSCode 式分栏工作台
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
@@ -120,7 +127,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
### 📑 Tab 栏
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
-- `+` 菜单新建 Explorer / Git / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
+- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
- 空分栏显示可打开类型卡片,点击即开
### 🔁 会话隔离与状态自愈
@@ -140,6 +147,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
+ - **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
## ⌨️ 快捷键速查
diff --git a/dsh.plugin.json b/dsh.plugin.json
index 6396b52..2a97498 100644
--- a/dsh.plugin.json
+++ b/dsh.plugin.json
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
- "version": "0.1.3",
+ "version": "0.1.9",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
"engines": {
diff --git a/package.json b/package.json
index ecaf79a..8baee56 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
- "version": "0.1.3",
+ "version": "0.1.9",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"private": true,
"type": "module",
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index e8f6db3..36a6525 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -6,3 +6,4 @@ packages:
# build scripts unless approved.
allowBuilds:
node-pty: true
+ protobufjs: true
diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx
index a3b3c96..696590a 100644
--- a/src/client/SideCardSection.tsx
+++ b/src/client/SideCardSection.tsx
@@ -1,9 +1,11 @@
/**
* "Side card" settings section: the user-facing preferences for the sidebar
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
- * Two rows:
- * - new conversations open the panel by default (native checkbox), and
- * - the default panel width as a percent of the window (number input + %).
+ * Three rows:
+ * - new conversations open the panel by default (native checkbox),
+ * - the default panel width as a percent of the window (number input + %),
+ * - auto-open the Subagent page when a new subagent appears (native checkbox,
+ * on by default).
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
@@ -119,6 +121,13 @@ export function SideCardSection({ store }: SideCardSectionProps) {
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
}
+ const onToggleSubagent = (next: boolean): void => {
+ const previous = prefs
+ setPrefs({ ...previous, autoOpenSubagent: next })
+ setError(null)
+ void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
+ }
+
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
@@ -171,6 +180,19 @@ export function SideCardSection({ store }: SideCardSectionProps) {
{t('settingsWidthSuffix')}
+
{error !== null && (
{error}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index 67c3083..58015bb 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { Context, SidebarConversation } from '../context-types.ts'
+import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
resizeSplit, setWidth, toggleExpanded, togglePanel,
@@ -28,6 +28,8 @@ import { ExplorerView } from './ExplorerView.tsx'
import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
+import { SubagentView } from './SubagentView.tsx'
+import { detectNewDirectSubagent } from './subagent-detect.ts'
import { t } from './locales.ts'
import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
@@ -43,8 +45,12 @@ function TabContent(props: {
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
+ /** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
+ visible: boolean
+ /** Fired before a topology node jumps to its child session (see Sidebar). */
+ onSubagentJump: (childSessionId: string) => void
}) {
- const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store } = props
+ const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
@@ -64,6 +70,15 @@ function TabContent(props: {
return
case 'editor':
return
+ case 'subagent':
+ return (
+
{ onSubagentJump(address.childSessionId) }}
+ />
+ )
}
}
@@ -75,6 +90,7 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] {
return [
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
+ { id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
{
id: 'terminal',
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
@@ -121,6 +137,52 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
+ /**
+ * Subagent auto-activation: the moment the current conversation spawns its
+ * FIRST direct subagent (a 0 → N transition on the list feed) and the
+ * "auto open" pref is on, open the panel (if collapsed) and focus the
+ * Subagent page (single-instance: an existing tab is focused, never
+ * duplicated). Switching to a session that already has subagents never
+ * triggers — its baseline starts at the current count — so a deliberate
+ * layout is never fought.
+ */
+ const listBaselineRef = useRef(undefined)
+ useEffect(() => {
+ const prev = listBaselineRef.current
+ listBaselineRef.current = sessionList
+ if (sessionId === undefined || prev === undefined) return
+ if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
+ if (!store.getPrefs().autoOpenSubagent) return
+ store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
+ id: 'subagent',
+ type: 'subagent',
+ title: t('subagent'),
+ }))
+ }, [sessionList, sessionId, store])
+
+ /**
+ * Topology jump-back: clicking a subagent node on the Subagent page calls
+ * the official `openSubagent`, which switches the sidebar to that child
+ * session's OWN layout (a fresh child session defaults to the explorer).
+ * The README contract says the Subagent page must stay open with the jumped
+ * node highlighted — so once the current session becomes the recorded jump
+ * target, re-open the Subagent page on top of the child's layout (expanding
+ * the panel first if it is collapsed). Only this explicit node click arms
+ * the flag, so switching to a subagent session by any other means keeps
+ * that session's own layout untouched.
+ */
+ const subagentJumpRef = useRef(undefined)
+ useEffect(() => {
+ const pending = subagentJumpRef.current
+ if (pending === undefined || sessionId !== pending) return
+ subagentJumpRef.current = undefined
+ store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
+ id: 'subagent',
+ type: 'subagent',
+ title: t('subagent'),
+ }))
+ }, [sessionId, store])
+
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
@@ -231,6 +293,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (optionId === 'git') {
return openTab(s, { id: 'git', type: 'git', title: t('git') })
}
+ if (optionId === 'subagent') {
+ return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
+ }
if (optionId === 'terminal') {
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
if (count >= TERMINAL_LIMIT) return s
@@ -251,7 +316,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
- const renderTab = (tab: SidebarTab) => (
+ /**
+ * Render one tab's content. `active` (from the workbench) tells whether
+ * this tab is the active one in its pane; combined with the panel's
+ * open/closed state it gates live views (the Subagent topology pauses its
+ * polling while the page is not actually visible).
+ */
+ const renderTab = (tab: SidebarTab, active: boolean) => (
{ subagentJumpRef.current = childSessionId }}
/>
)
diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css
new file mode 100644
index 0000000..bd032e6
--- /dev/null
+++ b/src/client/SubagentView.module.css
@@ -0,0 +1,275 @@
+/* Subagent page: main-agent topology tree. Row/card chrome mirrors the
+ official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8,
+ flex-start alignment, tree connector lines via border-left hairlines). */
+.subagent {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.subagentHeader {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 36px;
+ padding: 0 8px 0 12px;
+}
+
+.subagentTitle {
+ flex: 1;
+ min-width: 0;
+ font: var(--dsw-font-s-14);
+ color: var(--dsw-alias-label-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The descendant count badge ("3 · 1 运行中") beside the title. */
+.subagentCount {
+ flex: none;
+ font: var(--dsw-font-xxxs-11);
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.subagentRefresh {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--dsw-alias-label-secondary);
+ cursor: pointer;
+}
+
+.subagentRefresh:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.subagentBody {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 2px 6px 8px;
+}
+
+/* One node card: state dot + label + secondary + (running) live lines. */
+.subagentRow {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ box-sizing: border-box;
+ width: 100%;
+ min-height: 50px;
+ padding: 7px 8px 7px 11px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ font: var(--dsw-font-s-14);
+ color: var(--dsw-alias-label-primary);
+ text-align: left;
+ cursor: pointer;
+ outline: none;
+}
+
+.subagentRow:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Keyboard focus (mirror of the official catalog row): a visible fill only
+ when focused via keyboard; the selected state below still wins on top. */
+.subagentRow:focus-visible {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* The currently-open session, highlighted in place (mirror of the workspace
+ `.sessionRow.selected` — declared after the hover/focus rules so it wins). */
+.subagentRowActive,
+.subagentRowActive:hover,
+.subagentRowActive:focus-visible {
+ background: var(--dsw-alias-interactive-bg-active);
+}
+
+.subagentRowDisabled {
+ color: var(--dsw-alias-label-dimmed);
+ cursor: not-allowed;
+}
+
+.subagentRowDisabled:hover {
+ background: transparent;
+}
+
+/* Loading placeholders are disabled but keep the default cursor (official
+ `.loadingRow` distinction from `.disabled`). */
+.subagentRowLoading {
+ cursor: default;
+}
+
+/* The state dot aligns to the first text line (official catalog detail). */
+.subagentDot {
+ margin-top: 4px;
+}
+
+.subagentContent {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ gap: 2px;
+}
+
+.subagentLabel,
+.subagentSecondary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.subagentLabel {
+ color: inherit;
+ font-weight: 400;
+}
+
+.subagentSecondary {
+ font: var(--dsw-font-xxxs-11);
+ color: var(--dsw-alias-label-tertiary);
+}
+
+/* ── Live lines (running cards only) ──────────────────────────────────── */
+
+.subagentLive {
+ display: flex;
+ align-items: baseline;
+ gap: 4px;
+ min-width: 0;
+ font: var(--dsw-font-xxxs-11);
+ color: var(--dsw-alias-label-tertiary);
+ overflow: hidden;
+}
+
+.subagentLiveTool {
+ flex: none;
+ font: var(--dsw-font-xxxs-strong-11);
+ color: var(--dsw-alias-label-secondary);
+}
+
+.subagentLiveArgs {
+ min-width: 0;
+ font-family: var(--ds-font-family-code);
+ font-size: var(--dsw-font-xxxs-11-font-size);
+ line-height: var(--dsw-font-xxxs-11-line-height);
+ color: var(--dsw-alias-label-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.subagentLiveText {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ font: var(--dsw-font-xxxs-11);
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* ── Tree connector lines (official catalog recipe) ────────────────────── */
+
+.subagentNode {
+ position: relative;
+ min-width: 0;
+}
+
+.subagentChildren {
+ position: relative;
+ margin-left: 18px;
+ padding-left: 4px;
+}
+
+.subagentChildren::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: -26px;
+ height: 26px;
+ border-left: 1px solid var(--dsw-alias-border-l2);
+}
+
+.subagentChildren[aria-busy='true']::before {
+ content: none;
+}
+
+.subagentChildren > .subagentNode::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -4px;
+ border-left: 1px solid var(--dsw-alias-border-l2);
+}
+
+.subagentChildren > .subagentNode:last-child::before {
+ bottom: auto;
+ height: 17px;
+}
+
+.subagentChildren > .subagentNode > .subagentRow::before {
+ content: '';
+ position: absolute;
+ top: 16px;
+ left: -4px;
+ width: 14px;
+ border-top: 1px solid var(--dsw-alias-border-l2);
+}
+
+.subagentEmpty {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 16px;
+ font: var(--dsw-font-xxs-12);
+ color: var(--dsw-alias-label-tertiary);
+ text-align: center;
+}
+
+.subagentEmptyHint {
+ font: var(--dsw-font-xxxs-11);
+ color: var(--dsw-alias-label-dimmed);
+}
+
+.subagentError {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ font: var(--dsw-font-xxs-12);
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.subagentErrorRetry {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 24px;
+ padding: 0 8px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--dsw-alias-label-secondary);
+ font: var(--dsw-font-xxxs-strong-11);
+ cursor: pointer;
+}
+
+.subagentErrorRetry:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+ color: var(--dsw-alias-label-primary);
+}
diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx
new file mode 100644
index 0000000..414b570
--- /dev/null
+++ b/src/client/SubagentView.tsx
@@ -0,0 +1,584 @@
+/**
+ * Subagent page: the FULL agent topology of the current tree's main session.
+ *
+ * The root is resolved by walking the durable parent chain upward from the
+ * current session to the first non-subagent session — the MAIN session — and
+ * every subagent under it shares this one topology view, no matter how deep
+ * the current selection is (including a subagent transcript opened in the
+ * main view). The main agent renders as the root node card (click it to jump
+ * back to the main session), with its subagents hanging below it in clearly
+ * LAYERED levels: tree connector lines (first level included) and per-level
+ * indentation show the hierarchy, and the currently-open session is
+ * highlighted in place. Every branch is expanded automatically (lazy
+ * catalogs hydrate on demand and consume live membership while visible).
+ *
+ * Each node card carries live status (state dot, durable label, mode and
+ * activity); while a child RUNS, its card additionally shows the LAST text
+ * output and LAST tool call pulled from its history tail, auto-refreshing
+ * every few seconds while the page is visible. Clicking a card jumps
+ * straight into the child transcript (`openSubagent`); the page stays open
+ * and the topology remains rooted at the main session.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
+import { useSyncExternalStore } from 'react'
+import clsx from 'clsx'
+import {
+ IconRefreshOutline14, StateDot,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type {
+ Context,
+ SidebarSessionSummary,
+ SidebarSubagentAddress,
+ SidebarSubagentCatalog,
+ SidebarSubagentChildEntry,
+ SidebarSubagentDiagnosticEntry,
+} from '../context-types.ts'
+import {
+ collectBranchIds,
+ countSubagentDescendants,
+ rootAncestor,
+} from './subagent-detect.ts'
+import { lastActivity } from './subagent-activity.ts'
+import { t } from './locales.ts'
+import css from './SubagentView.module.css'
+
+/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
+const POLL_MS = 3000
+/** Preview cap of one tool-call argument line. */
+const ARGS_PREVIEW = 60
+
+/** The direct subagent children of one parent (durable `origin` rows). */
+function directChildren(
+ byId: Readonly>,
+ parentSessionId: string,
+): SidebarSessionSummary[] {
+ return Object.values(byId).filter(
+ summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
+ )
+}
+
+/** Human label of one catalog child: durable label, then summary title, then id. */
+function childLabel(
+ entry: SidebarSubagentChildEntry,
+ summary: SidebarSessionSummary | undefined,
+): string {
+ return entry.label ?? summary?.displayTitle ?? entry.id
+}
+
+function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
+ switch (entry.reason) {
+ case 'corrupt': return t('subagentDiagCorrupt')
+ case 'unsupported': return t('subagentDiagUnsupported')
+ case 'unavailable': return t('subagentDiagUnavailable')
+ }
+}
+
+/** The secondary line of one card: title · mode · activity (skips empty parts). */
+function cardSecondary(
+ summary: SidebarSessionSummary | undefined,
+ entry: SidebarSubagentChildEntry,
+): string {
+ return [
+ summary?.displayTitle,
+ entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
+ entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
+ ].filter(Boolean).join(' · ')
+}
+
+/** First `limit` characters with an ellipsis when truncated. */
+function preview(text: string, limit: number): string {
+ return text.length > limit ? `${text.slice(0, limit)}…` : text
+}
+
+/** Collapse whitespace for the single-paragraph live-text preview. */
+function flatten(text: string): string {
+ return text.replace(/\s+/g, ' ').trim()
+}
+
+/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
+function CatalogLoadingRows(props: {
+ parentSessionId: string
+ byId: Readonly>
+ level: number
+}) {
+ const { parentSessionId, byId, level } = props
+ const children = directChildren(byId, parentSessionId)
+ if (children.length === 0) {
+ return {t('loading')}
+ }
+ return (
+ <>
+ {children.map(summary => (
+
+
+
+ {t('loading')}
+
+
+ ))}
+ >
+ )
+}
+
+/**
+ * The live lines of one RUNNING subagent card: the last text output and the
+ * last tool call of the child's history tail, refreshed every few seconds
+ * while the page is visible. Idle cards render nothing (a quiet topology); a
+ * running child with neither output yet reads "thinking…".
+ */
+function SubagentLiveLines(props: {
+ ctx: Context
+ parentSessionId: string
+ childSessionId: string
+ mode: SidebarSubagentAddress['mode']
+ running: boolean
+ /** The page is visible (active tab + open panel): skip polling otherwise. */
+ active: boolean
+}) {
+ const { ctx, parentSessionId, childSessionId, mode, running, active } = props
+ const [live, setLive] = useState>({})
+ const controllerRef = useRef(undefined)
+ const address = useMemo(
+ () => ({ parentSessionId, childSessionId, mode }),
+ [parentSessionId, childSessionId, mode],
+ )
+
+ const load = useCallback(async (): Promise => {
+ controllerRef.current?.abort()
+ const controller = new AbortController()
+ controllerRef.current = controller
+ try {
+ const response = await ctx.connection.api.subagents.history(
+ { ...address, maxMessages: 12 },
+ controller.signal,
+ )
+ if (!response.result.ok) return
+ setLive(lastActivity(response.result.value.events))
+ } catch {
+ // Aborted by a newer pull or a wire failure: keep the last known lines.
+ }
+ }, [ctx, address])
+
+ useEffect(() => {
+ if (!active) return
+ void load()
+ if (!running) return
+ const timer = window.setInterval(() => { void load() }, POLL_MS)
+ return () => { window.clearInterval(timer) }
+ }, [load, running, active])
+
+ useEffect(() => () => { controllerRef.current?.abort() }, [])
+
+ if (!running) return null
+ if (live.text === undefined && live.tool === undefined) {
+ return {t('subagentThinking')}
+ }
+ return (
+ <>
+ {live.tool !== undefined && (
+
+ {live.tool.name}
+ {live.tool.args !== '' && (
+ {preview(live.tool.args, ARGS_PREVIEW)}
+ )}
+
+ )}
+ {live.text !== undefined && (
+ {flatten(live.text)}
+ )}
+ >
+ )
+}
+
+interface RowsProps {
+ parentSessionId: string
+ catalog: SidebarSubagentCatalog | undefined
+ catalogs: Readonly>
+ byId: Readonly>
+ level: number
+ /** The currently-open session id (highlighted in the topology). */
+ currentSessionId: string
+ /** The page is visible (active tab + open panel): live polling pauses otherwise. */
+ active: boolean
+ ctx: Context
+ openChild: (address: SidebarSubagentAddress) => void
+ refresh: (parentSessionId: string) => void
+}
+
+/** Render one topology level; branches are always expanded (lazy catalogs). */
+function CatalogRows({
+ parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
+ openChild, refresh,
+}: RowsProps) {
+ const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
+ return (
+ <>
+ {emptyLoading && (
+
+ )}
+ {catalog?.state === 'error' && (
+
+ {catalog.error?.message ?? t('error')}
+
+
+ )}
+ {(catalog?.entries ?? []).map((entry) => {
+ if (entry.kind === 'diagnostic') {
+ return (
+
+
+
+
+ {entry.id}
+ {diagnosticReason(entry)}
+
+
+
+ )
+ }
+
+ const childCatalog = catalogs[entry.id]
+ const knownLeaf = !entry.hasChildren
+ const summary = byId[entry.id]
+ const label = childLabel(entry, summary)
+ const secondary = cardSecondary(summary, entry)
+ const childLoading = childCatalog === undefined
+ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
+ const address: SidebarSubagentAddress = {
+ parentSessionId,
+ childSessionId: entry.id,
+ mode: entry.mode,
+ }
+ const current = entry.id === currentSessionId
+
+ return (
+
+
{ openChild(address) }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ event.stopPropagation()
+ openChild(address)
+ }
+ }}
+ >
+
+
+ {label}
+ {secondary}
+
+
+
+ {!knownLeaf && (
+
+ {childCatalog === undefined
+ ? (
+
+ )
+ : (
+
+ )}
+
+ )}
+
+ )
+ })}
+ >
+ )
+}
+
+/**
+ * The sidebar's Subagent topology page.
+ * @param props - current session id, whether the page is actually visible
+ * (active tab + open panel), the client context, and an optional
+ * jump-notify hook fired right before `openSubagent` (lets the sidebar
+ * shell re-open the Subagent page after the conversation switch lands on
+ * the child session).
+ * @returns the main agent's topology tree, or the empty/error/loading states.
+ */
+export function SubagentView(props: {
+ sessionId: string
+ active: boolean
+ ctx: Context
+ onOpenChild?: (address: SidebarSubagentAddress) => void
+}) {
+ const { sessionId, active, ctx, onOpenChild } = props
+ const sessions = ctx.sessions
+
+ // The same list feed the official catalog consumes (byId lineage + the
+ // lazy per-parent catalogs). Older DSH snapshots without the subagent seam
+ // simply leave these surfaces empty — the page degrades to the empty state.
+ const list = useSyncExternalStore(
+ useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
+ useCallback(() => sessions.list.getSnapshot(), [sessions]),
+ )
+ const byId = list.byId
+ const catalogs = list.subagentsByParent ?? {}
+
+ // The topology root: the main agent of the current session's tree.
+ const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
+ const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
+ const rootSummary = rootId === undefined ? undefined : byId[rootId]
+
+ /** Catalog owners currently consuming live membership updates. */
+ const observedRef = useRef(new Set())
+
+ const observe = useCallback((parentSessionId: string, open: boolean): void => {
+ sessions.setSubagentCatalogOpen?.(parentSessionId, open)
+ if (open) observedRef.current.add(parentSessionId)
+ else observedRef.current.delete(parentSessionId)
+ }, [sessions])
+
+ // While the page is visible the topology root consumes live membership; a
+ // root change (switching to another main agent's tree) or the page hiding
+ // (tab switched away / panel collapsed) releases everything observed.
+ useEffect(() => {
+ if (rootId === undefined || !active) return
+ observe(rootId, true)
+ return () => {
+ for (const parentSessionId of observedRef.current) {
+ sessions.setSubagentCatalogOpen?.(parentSessionId, false)
+ }
+ observedRef.current.clear()
+ }
+ }, [rootId, active, observe, sessions])
+
+ // Every branch of the always-expanded topology consumes live membership
+ // (add-only: a branch stays observed until the root changes or the page
+ // hides, which releases the whole set via the root effect's cleanup).
+ const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
+ useEffect(() => {
+ if (!active) return
+ for (const id of branches) {
+ if (!observedRef.current.has(id)) observe(id, true)
+ }
+ }, [branches, active, observe])
+
+ // Unobserve everything on unmount (the host stops refreshing unused catalogs).
+ useEffect(() => () => {
+ for (const parentSessionId of observedRef.current) {
+ sessions.setSubagentCatalogOpen?.(parentSessionId, false)
+ }
+ observedRef.current.clear()
+ }, [sessions])
+
+ const openChild = useCallback((address: SidebarSubagentAddress): void => {
+ // Notify the shell first: the jump switches the sidebar to the child
+ // session's own layout, and the shell re-opens the Subagent page on top
+ // of it (the topology stays rooted at the main agent with the child
+ // highlighted) — the README "page stays open" contract.
+ onOpenChild?.(address)
+ try {
+ sessions.openSubagent?.(address)
+ } catch (error) {
+ console.warn('[dsh-better-sidebar] openSubagent failed:', error)
+ }
+ }, [sessions, onOpenChild])
+
+ /** Jump back to the main agent (the topology root) from its node. */
+ const openMain = useCallback((): void => {
+ if (rootId === undefined) return
+ try {
+ sessions.open?.(rootId)
+ } catch (error) {
+ console.warn('[dsh-better-sidebar] open session failed:', error)
+ }
+ }, [sessions, rootId])
+
+ const refresh = useCallback((parentSessionId: string): void => {
+ void sessions.refreshSubagents?.(parentSessionId)
+ }, [sessions])
+
+ const totals = useMemo(
+ () => rootId === undefined
+ ? { count: 0, runningCount: 0 }
+ : countSubagentDescendants(byId, rootId),
+ [byId, rootId],
+ )
+ // Session summaries can announce membership before the descriptor-backed
+ // catalog catches up (or a catalog that just went ready is still empty).
+ const summaryBackedLoading = rootId !== undefined
+ && (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
+ && directChildren(byId, rootId).length > 0
+ const readyEmpty = rootCatalog?.state === 'ready'
+ && rootCatalog.entries.length === 0
+ && directChildren(byId, rootId ?? '').length === 0
+ const countLabel = totals.count === 0
+ ? undefined
+ : totals.runningCount > 0
+ ? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
+ : t('subagentCount', { count: totals.count })
+
+ /** Arrow-key tree navigation over the visible rows (official catalog recipe). */
+ const bodyRef = useRef(null)
+ const focusAt = useCallback((index: number): void => {
+ const items = bodyRef.current?.querySelectorAll(
+ '[role="treeitem"]:not([aria-disabled="true"])',
+ ) ?? []
+ if (items.length === 0) return
+ items[(index + items.length) % items.length]?.focus()
+ }, [])
+ const onTreeKeyDown = useCallback((event: KeyboardEvent): void => {
+ const items = bodyRef.current?.querySelectorAll(
+ '[role="treeitem"]:not([aria-disabled="true"])',
+ ) ?? []
+ const index = Array.prototype.indexOf.call(items, document.activeElement)
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ focusAt(index + 1)
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ focusAt(index < 0 ? items.length - 1 : index - 1)
+ } else if (event.key === 'Home') {
+ event.preventDefault()
+ focusAt(0)
+ } else if (event.key === 'End') {
+ event.preventDefault()
+ focusAt(items.length - 1)
+ }
+ }, [focusAt])
+
+ return (
+
+
+
+ {t('subagent')}
+ {rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
+ ? ` · ${rootSummary.displayTitle}`
+ : ''}
+
+ {countLabel !== undefined && {countLabel}}
+
+
+
+ {rootId !== undefined && rootSummary !== undefined && (
+
{
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ event.stopPropagation()
+ openMain()
+ }
+ }}
+ >
+
+
+
+ {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
+
+
+ {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
+
+
+
+ )}
+ {rootId !== undefined && (
+
+ {summaryBackedLoading && (
+
+ )}
+ {!summaryBackedLoading && (
+
+ )}
+
+ )}
+ {readyEmpty && (
+
+
{t('subagentEmpty')}
+
{t('subagentEmptyDesc')}
+
+ )}
+
+
+ )
+}
diff --git a/src/client/icons.tsx b/src/client/icons.tsx
index 73be23a..ff2cad9 100644
--- a/src/client/icons.tsx
+++ b/src/client/icons.tsx
@@ -5,7 +5,7 @@
*/
import type { ReactNode } from 'react'
import {
- IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
+ IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TabType } from './state.ts'
@@ -28,6 +28,7 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode {
case 'explorer': return
case 'git': return
case 'terminal': return
+ case 'subagent': return
case 'editor': return
}
}
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 1f0f335..b725752 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -22,7 +22,7 @@ import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime). */
-export const inject = ['slots', 'sessions']
+export const inject = ['slots', 'sessions', 'connection']
/**
* Error boundary over the sidebar tree: a render error must never blank the
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 03757d5..26a0844 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -88,6 +88,23 @@ const zh = {
nextSlide: '下一页',
zoom: '缩放',
zoomHint: 'Alt + 滚轮',
+ settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
+ settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
+ subagent: '子代理',
+ openSubagent: '子代理',
+ subagentMainAgent: '主代理',
+ subagentEmpty: '暂无子代理',
+ subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
+ subagentRunning: '运行中',
+ subagentInactive: '空闲',
+ subagentModeOneShot: '一次性',
+ subagentModeContinuable: '可续接',
+ subagentCount: '{count} 个子代理',
+ subagentCountRunning: '{count} 个子代理 · {running} 运行中',
+ subagentDiagCorrupt: '目录损坏',
+ subagentDiagUnsupported: '不支持的条目',
+ subagentDiagUnavailable: '不可用',
+ subagentThinking: '思考中…',
}
const en: Record = {
@@ -173,15 +190,39 @@ const en: Record = {
nextSlide: 'Next',
zoom: 'Zoom',
zoomHint: 'Alt + wheel',
+ settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
+ settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
+ subagent: 'Subagents',
+ openSubagent: 'Subagents',
+ subagentMainAgent: 'Main agent',
+ subagentEmpty: 'No subagents',
+ subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
+ subagentRunning: 'Running',
+ subagentInactive: 'Inactive',
+ subagentModeOneShot: 'One-shot',
+ subagentModeContinuable: 'Continuable',
+ subagentCount: '{count} subagents',
+ subagentCountRunning: '{count} subagents · {running} running',
+ subagentDiagCorrupt: 'Corrupt',
+ subagentDiagUnsupported: 'Unsupported',
+ subagentDiagUnavailable: 'Unavailable',
+ subagentThinking: 'Thinking…',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
export type CopyKey = keyof typeof zh
-export function t(key: CopyKey): string {
+/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
+export function t(key: CopyKey, params?: Record): string {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
- return dict[key]
+ let text = dict[key]
+ if (params !== undefined) {
+ for (const [name, value] of Object.entries(params)) {
+ text = text.replaceAll(`{${name}}`, String(value))
+ }
+ }
+ return text
}
/** Whether the browser language is Chinese (used for selectors). */
diff --git a/src/client/prefs.ts b/src/client/prefs.ts
index 2c2faf6..14a8746 100644
--- a/src/client/prefs.ts
+++ b/src/client/prefs.ts
@@ -38,6 +38,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
? clampWidthPercent(record.defaultWidthPercent)
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
+ autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
+ ? record.autoOpenSubagent
+ : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
}
}
diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx
index c22c930..15e67d7 100644
--- a/src/client/split-pane.tsx
+++ b/src/client/split-pane.tsx
@@ -122,7 +122,7 @@ function LeafView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
- renderTab: (tab: SidebarTab) => ReactNode
+ renderTab: (tab: SidebarTab, active: boolean) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const [dropZone, setDropZone] = useState(null)
@@ -196,7 +196,7 @@ function LeafView(props: {
key={tab.id}
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
>
- {renderTab(tab)}
+ {renderTab(tab, tab.id === activeTab?.id)}
))}
@@ -214,7 +214,7 @@ function NodeView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
- renderTab: (tab: SidebarTab) => ReactNode
+ renderTab: (tab: SidebarTab, active: boolean) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
if (node.kind === 'leaf') {
@@ -264,7 +264,7 @@ export function Workbench(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
- renderTab: (tab: SidebarTab) => ReactNode
+ renderTab: (tab: SidebarTab, active: boolean) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab } = props
return (
diff --git a/src/client/state.ts b/src/client/state.ts
index c8f42da..6bda316 100644
--- a/src/client/state.ts
+++ b/src/client/state.ts
@@ -11,7 +11,7 @@
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
-export type TabType = 'explorer' | 'git' | 'editor' | 'terminal'
+export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent'
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
export interface SidebarTab {
@@ -291,9 +291,9 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
}
-/** Whether a tab type is single-instance per session (explorer/git). */
+/** Whether a tab type is single-instance per session (explorer/git/subagent). */
function isSingle(type: TabType): boolean {
- return type === 'explorer' || type === 'git'
+ return type === 'explorer' || type === 'git' || type === 'subagent'
}
/**
@@ -506,6 +506,7 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map 0 ? parts.join('\n') : undefined
+}
+
+/** The live status of one subagent card (both fields optional). */
+export interface LastActivity {
+ /** The latest assembled assistant text output in the tail. */
+ text?: string
+ /** The latest tool call in the tail. */
+ tool?: { name: string; args: string }
+}
+
+/**
+ * Fold a history tail into the last text output + last tool call (each is
+ * the LAST occurrence in event order). Lifecycle events and raw
+ * `assistant/chunk` rows are ignored — the card shows what the subagent is
+ * doing right now, not its plumbing.
+ * @param entries - the tail page from `subagent.history` (oldest → newest).
+ * @returns the last text and/or tool call; an empty object when the tail has neither.
+ */
+export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
+ let text: string | undefined
+ let tool: { name: string; args: string } | undefined
+ for (const entry of entries) {
+ const { type, data } = entry.event
+ if (type === 'assistant/message') {
+ const message = data.message as { content?: unknown } | undefined
+ const extracted = contentText(message?.content)
+ if (extracted !== undefined) text = extracted
+ } else if (type === 'tool/call') {
+ tool = {
+ name: typeof data.name === 'string' ? data.name : 'tool',
+ args: typeof data.arguments === 'string' ? data.arguments : '',
+ }
+ }
+ }
+ if (text === undefined && tool === undefined) return {}
+ return {
+ ...(text === undefined ? {} : { text }),
+ ...(tool === undefined ? {} : { tool }),
+ }
+}
diff --git a/src/client/subagent-detect.ts b/src/client/subagent-detect.ts
new file mode 100644
index 0000000..801deb0
--- /dev/null
+++ b/src/client/subagent-detect.ts
@@ -0,0 +1,125 @@
+/**
+ * Pure subagent-membership helpers over the sessions list feed (structural
+ * mirror world — no runtime imports). Used by the sidebar's auto-activation
+ * effect and the Subagent page:
+ *
+ * - {@link directSubagentCount}: direct durable children of one session,
+ * - {@link detectNewDirectSubagent}: the 0 → N transition that means "a new
+ * subagent just spawned under the current session" (the auto-open trigger),
+ * - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
+ * totals (mirror of the official `indexSubagentDescendants` over the
+ * plugin's own summary rows).
+ */
+import type {
+ SidebarSessionList,
+ SidebarSessionSummary,
+ SidebarSubagentCatalog,
+} from '../context-types.ts'
+
+/** Count the direct subagent children of one session (durable `origin` rows). */
+export function directSubagentCount(
+ byId: SidebarSessionList['byId'],
+ sessionId: string,
+): number {
+ let count = 0
+ for (const summary of Object.values(byId)) {
+ if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
+ }
+ return count
+}
+
+/**
+ * The main agent of the current session's tree: walk the durable parent
+ * chain upward until the first non-subagent session. The Subagent page shows
+ * THIS root's full topology regardless of how deep the current selection is
+ * (a session whose row is still hydrating, or a broken chain, degrades to
+ * the session itself).
+ */
+export function rootAncestor(
+ byId: SidebarSessionList['byId'],
+ sessionId: string | undefined,
+): string | undefined {
+ if (sessionId === undefined) return undefined
+ const seen = new Set()
+ let current: SidebarSessionSummary | undefined = byId[sessionId]
+ while (current !== undefined && current.origin === 'subagent'
+ && current.parentId !== undefined && !seen.has(current.id)) {
+ seen.add(current.id)
+ current = byId[current.parentId]
+ }
+ return current?.id ?? sessionId
+}
+
+/**
+ * Collect every catalog branch (an entry with `hasChildren`) reachable from
+ * the root — the set of catalogs the always-expanded topology consumes.
+ * Cycles fail soft.
+ */
+export function collectBranchIds(
+ catalogs: Readonly>,
+ rootId: string | undefined,
+): string[] {
+ const out: string[] = []
+ const seen = new Set()
+ const visit = (parentId: string): void => {
+ if (seen.has(parentId)) return
+ seen.add(parentId)
+ for (const entry of catalogs[parentId]?.entries ?? []) {
+ if (entry.kind === 'child' && entry.hasChildren) {
+ out.push(entry.id)
+ visit(entry.id)
+ }
+ }
+ }
+ if (rootId !== undefined) visit(rootId)
+ return out
+}
+
+/**
+ * Whether a new direct subagent appeared under `sessionId` between two
+ * consecutive list snapshots (the count crossed 0 → >0). Switching to a
+ * session that already has subagents yields `false` (its baseline starts at
+ * the current count), so the auto-open never fights an existing layout.
+ */
+export function detectNewDirectSubagent(
+ prev: SidebarSessionList,
+ next: SidebarSessionList,
+ sessionId: string,
+): boolean {
+ return directSubagentCount(prev.byId, sessionId) === 0
+ && directSubagentCount(next.byId, sessionId) > 0
+}
+
+/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
+export interface SubagentDescendantTotals {
+ count: number
+ runningCount: number
+}
+
+/**
+ * Index every subagent descendant under each ancestor it reaches through an
+ * uninterrupted subagent-origin chain (same semantics as the official
+ * `indexSubagentDescendants`; cycles fail soft).
+ */
+export function countSubagentDescendants(
+ byId: SidebarSessionList['byId'],
+ sessionId: string,
+): SubagentDescendantTotals {
+ const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
+ for (const descendant of Object.values(byId)) {
+ if (descendant.origin !== 'subagent') continue
+ const seen = new Set()
+ let current: SidebarSessionSummary | undefined = descendant
+ while (current?.origin === 'subagent' && current.parentId !== undefined
+ && !seen.has(current.id)) {
+ seen.add(current.id)
+ if (current.parentId === sessionId) {
+ totals.count += 1
+ if (descendant.running === true) totals.runningCount += 1
+ break
+ }
+ current = byId[current.parentId]
+ }
+ }
+ return totals
+}
diff --git a/src/config.ts b/src/config.ts
index ebd1e4a..bc9c107 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -78,4 +78,5 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
export const PrefsSchema: z = z.object({
openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
+ autoOpenSubagent: z.boolean().default(true),
})
diff --git a/src/context-types.ts b/src/context-types.ts
index b37f76a..67c0418 100644
--- a/src/context-types.ts
+++ b/src/context-types.ts
@@ -90,14 +90,92 @@ export interface SidebarSlotsService {
/** The client session list row the sidebar reads (cwd for the explorer). */
export interface SidebarSessionSummary {
+ id: string
cwd?: string
displayTitle: string
+ /** Coarse durable origin for navigation filtering (subagent children). */
+ origin?: 'subagent'
+ /** Durable direct parent session id (present on subagent children). */
+ parentId?: string
+ /** Whether the session's agent is currently running. */
+ running?: boolean
+}
+
+/** One healthy subagent catalog child row (structural mirror of the runtime). */
+export interface SidebarSubagentChildEntry {
+ kind: 'child'
+ id: string
+ /** Whether the child Agent driver is running at the Host sampling boundary. */
+ activity: 'running' | 'inactive'
+ /** Whether a direct descendant has durable `origin: 'subagent'`. */
+ hasChildren: boolean
+ mode: 'one-shot' | 'continuable'
+ label?: string
+}
+
+/** One unreadable catalog row (corrupt / unsupported / unavailable). */
+export interface SidebarSubagentDiagnosticEntry {
+ kind: 'diagnostic'
+ id: string
+ reason: 'corrupt' | 'unsupported' | 'unavailable'
+}
+
+/** The per-parent lazy catalog delivered through the sessions list feed. */
+export interface SidebarSubagentCatalog {
+ entries: Array
+ parentAvailable: boolean
+ state: 'loading' | 'ready' | 'error'
+ error: { code?: string; message?: string } | null
+}
+
+/** Durable parent/child address that selects subagent transport in the client. */
+export interface SidebarSubagentAddress {
+ parentSessionId: string
+ childSessionId: string
+ mode: 'one-shot' | 'continuable'
+}
+
+/** Minimal structural mirror of one session event (the subagent history tail). */
+export interface SidebarSessionEvent {
+ type: string
+ seq: number
+ time: number
+ data: Record
+}
+
+/** One history row: the durable event plus an optional tool presentation view. */
+export interface SidebarHistoryEntry {
+ event: SidebarSessionEvent
+ view?: unknown
+}
+
+/** RPC result slot mirror (`RpcResult` on the wire). */
+export type SidebarRpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
+
+/** Unary response mirror (`RpcResponse` on the wire). */
+export interface SidebarRpcResponse {
+ rpcId: unknown
+ result: SidebarRpcResult
+}
+
+/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
+export interface SidebarConnectionHandle {
+ api: {
+ subagents: {
+ history(
+ payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
+ signal?: AbortSignal,
+ ): Promise>
+ }
+ }
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
byId: Record
+ /** Direct durable catalogs keyed by their selected parent address. */
+ subagentsByParent?: Readonly>
}
/** The client sessions service face (only the list feed is needed). */
@@ -106,12 +184,34 @@ export interface SidebarSessionsService {
getSnapshot(): SidebarSessionList
subscribe(fn: () => void): () => void
}
+ /**
+ * Select a listed session as current (mirror of the runtime ISessions.open)
+ * — used to jump back to the main agent from the topology root node.
+ */
+ open?(id: string): void
/**
* Resolve an Agent-scoped context view for one session (mirror of the
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
* requires to reach that session's composer.
*/
scope(id: string): Context | undefined
+ /**
+ * Open a healthy catalog child through its exact direct-parent address
+ * (mirror of the runtime ISessions.openSubagent).
+ */
+ openSubagent?(address: SidebarSubagentAddress): void
+ /**
+ * Resolve an already discovered direct-parent address without opening it.
+ */
+ subagentAddress?(id: string): SidebarSubagentAddress | undefined
+ /**
+ * Mark whether a catalog surface is consuming live membership updates.
+ */
+ setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
+ /**
+ * Refresh one direct-child catalog.
+ */
+ refreshSubagents?(parentSessionId: string): Promise
}
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
@@ -178,6 +278,7 @@ declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
sessions: SidebarSessionStore & SidebarSessionsService
+ connection: SidebarConnectionHandle
loader: SidebarLoader
slots: SidebarSlotsService
settings: SidebarSettingsService
diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts
index e6dd644..b4012c8 100644
--- a/src/prefs-shared.ts
+++ b/src/prefs-shared.ts
@@ -15,6 +15,11 @@ export interface SidebarPrefs {
openByDefault: boolean
/** Default panel width as a percent of the window width (20–60). */
defaultWidthPercent: number
+ /**
+ * Whether the sidebar auto-activates (opens the panel) and expands the
+ * Subagent page when the current conversation spawns a new subagent.
+ */
+ autoOpenSubagent: boolean
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -26,6 +31,7 @@ export const WIDTH_PERCENT_DEFAULT = 30
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
+ autoOpenSubagent: true,
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts
index 6b165c0..4fde843 100644
--- a/tests/plugin-shape.spec.ts
+++ b/tests/plugin-shape.spec.ts
@@ -44,11 +44,13 @@ describe('dsh-better-sidebar plugin export shape', () => {
})(undefined)
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
+ expect(resolved.autoOpenSubagent).toBe(true)
// A stored overridden value resolves through (the range contract is
- // enforced by the settings service on write).
+ // enforced by the settings service on write); the new pref keeps its
+ // default when the stored document predates it.
const overridden = (PrefsSchema as unknown as {
(input: Record | undefined): Record
})({ openByDefault: false, defaultWidthPercent: 45 })
- expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
+ expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
})
})
diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts
index 8ffc0fc..68085bd 100644
--- a/tests/smoke.spec.ts
+++ b/tests/smoke.spec.ts
@@ -338,7 +338,10 @@ describe('side card settings routes', () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
- expect(read.value).toEqual({ value: { openByDefault: true, defaultWidthPercent: 30 }, revision: 0 })
+ expect(read.value).toEqual({
+ value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true },
+ revision: 0,
+ })
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
expect(written.ok).toBe(true)
diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts
index e35f67c..e9e2ab1 100644
--- a/tests/unit.spec.ts
+++ b/tests/unit.spec.ts
@@ -15,6 +15,11 @@ import { isImageExt } from '../src/client/image-types.ts'
import { relativeTo } from '../src/client/paths.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
+import {
+ collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
+} from '../src/client/subagent-detect.ts'
+import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
+import type { SidebarHistoryEntry, SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
@@ -114,6 +119,17 @@ describe('sidebar state', () => {
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
})
+ it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
+ let s = state()
+ s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
+ expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
+ // Reopening (e.g. the auto-activation effect) focuses the existing tab.
+ const after = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
+ expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
+ const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
+ expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
+ })
+
it('splits panes and moves tabs between them', () => {
let s = state()
s = splitPane(s, 'row')
@@ -329,6 +345,16 @@ describe('persisted state sanitization', () => {
expect(clean).toEqual(state)
})
+ it('accepts a subagent tab as a known type', () => {
+ const raw = JSON.parse(JSON.stringify(makeDefaultState(400)))
+ raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' })
+ raw.splits.active = 'tab:9'
+ const clean = sanitizeState(raw)
+ expect(clean).toBeDefined()
+ const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs
+ expect(tabs.some(tab => tab.type === 'subagent')).toBe(true)
+ })
+
it('clamps undersized widths to the panel minimum', () => {
const state = { ...makeDefaultState(400), width: 10 }
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
@@ -520,22 +546,29 @@ describe('side card preferences', () => {
})
it('parses a valid value and clamps the percent into the contract range', async () => {
- expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80 })))
- .toEqual({ openByDefault: false, defaultWidthPercent: 60 })
+ expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false })))
+ .toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false })
})
it('falls back per-field when a stored field is malformed', async () => {
- expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33 })))
- .toEqual({ openByDefault: true, defaultWidthPercent: 33 })
+ expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' })))
+ .toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true })
+ })
+
+ it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => {
+ expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
+ .toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true })
+ expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
+ .toBe(true)
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
- store.setPrefs({ openByDefault: false, defaultWidthPercent: 45 })
+ store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
store.setSession('fresh-session')
- expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
+ expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -558,3 +591,173 @@ describe('side card preferences', () => {
expect(makeDefaultState(400, false).width).toBe(400)
})
})
+
+describe('subagent detection over the sessions list feed', () => {
+ /** A list snapshot carrying the given direct subagent children of `parent`. */
+ const list = (
+ parent: string,
+ childIds: string[],
+ running: string[] = [],
+ ): SidebarSessionList => {
+ const byId: SidebarSessionList['byId'] = { [parent]: { id: parent, displayTitle: 'Parent' } }
+ for (const id of childIds) {
+ byId[id] = {
+ id,
+ displayTitle: `Child ${id}`,
+ origin: 'subagent',
+ parentId: parent,
+ running: running.includes(id),
+ }
+ }
+ return { current: parent, byId }
+ }
+
+ it('counts only the direct subagent children of the given session', () => {
+ const snapshot = list('p1', ['c1', 'c2'])
+ snapshot.byId['other'] = { id: 'other', displayTitle: 'Other', origin: 'subagent', parentId: 'p2' }
+ expect(directSubagentCount(snapshot.byId, 'p1')).toBe(2)
+ expect(directSubagentCount(snapshot.byId, 'p2')).toBe(1)
+ expect(directSubagentCount(snapshot.byId, 'p1-nobody')).toBe(0)
+ })
+
+ it('fires only on the 0 → N transition of the current session', () => {
+ const empty = list('p1', [])
+ const one = list('p1', ['c1'])
+ const two = list('p1', ['c1', 'c2'])
+ expect(detectNewDirectSubagent(empty, empty, 'p1')).toBe(false)
+ expect(detectNewDirectSubagent(empty, one, 'p1')).toBe(true)
+ // Already-present children never re-trigger (session switch, reload).
+ expect(detectNewDirectSubagent(one, two, 'p1')).toBe(false)
+ expect(detectNewDirectSubagent(two, one, 'p1')).toBe(false)
+ // A child arriving under ANOTHER session does not trigger this one.
+ expect(detectNewDirectSubagent(empty, list('p2', ['x']), 'p1')).toBe(false)
+ })
+
+ it('indexes descendants through uninterrupted subagent lineage', () => {
+ // p1 → c1 (subagent) → g1 (subagent child of c1).
+ const byId: SidebarSessionList['byId'] = {
+ p1: { id: 'p1', displayTitle: 'P1' },
+ c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1', running: true },
+ g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1' },
+ }
+ expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 2, runningCount: 1 })
+ expect(countSubagentDescendants(byId, 'c1')).toEqual({ count: 1, runningCount: 0 })
+ expect(countSubagentDescendants(byId, 'g1')).toEqual({ count: 0, runningCount: 0 })
+ // An ordinary fork between the parent and the subagent cuts that lineage:
+ // c2 is c1's sibling in origin but its parent `fork` is not a subagent,
+ // so it never reaches p1 (only c1 and g1 count under p1).
+ const forked: SidebarSessionList['byId'] = {
+ ...byId,
+ fork: { id: 'fork', displayTitle: 'Fork', parentId: 'p1' },
+ c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'fork' },
+ }
+ expect(countSubagentDescendants(forked, 'p1')).toEqual({ count: 2, runningCount: 1 })
+ expect(countSubagentDescendants(forked, 'fork')).toEqual({ count: 1, runningCount: 0 })
+ // Cycles terminate (fail soft, never hang); both rows in a 2-cycle reach
+ // the queried node once each.
+ const cyclic: SidebarSessionList['byId'] = {
+ a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b' },
+ b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' },
+ }
+ expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
+ })
+
+ it('resolves the main-agent root of the current session tree', () => {
+ const byId: SidebarSessionList['byId'] = {
+ main: { id: 'main', displayTitle: 'Main' },
+ child: { id: 'child', displayTitle: 'Child', origin: 'subagent', parentId: 'main' },
+ grand: { id: 'grand', displayTitle: 'Grand', origin: 'subagent', parentId: 'child' },
+ }
+ // An ordinary session is its own root; a deep subagent walks up to the main agent.
+ expect(rootAncestor(byId, 'main')).toBe('main')
+ expect(rootAncestor(byId, 'child')).toBe('main')
+ expect(rootAncestor(byId, 'grand')).toBe('main')
+ // A broken chain (parent not in the mirror) degrades to the session itself.
+ expect(rootAncestor(byId, 'orphan')).toBe('orphan')
+ // A hydrating session row degrades to the session itself.
+ expect(rootAncestor(byId, 'not-listed')).toBe('not-listed')
+ expect(rootAncestor(byId, undefined)).toBeUndefined()
+ })
+
+ it('collects every catalog branch of the topology, cycle-safe', () => {
+ const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
+ kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
+ })
+ const catalogs: Record = {
+ root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
+ a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
+ }
+ expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
+ expect(collectBranchIds(catalogs, undefined)).toEqual([])
+ // A cycle terminates (each branch id collected at most once, no hang).
+ const cyclic: Record = {
+ root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
+ a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
+ }
+ expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
+ })
+})
+
+describe('subagent activity summary parser', () => {
+ /** One history entry from raw event fields. */
+ const entry = (type: string, data: Record): SidebarHistoryEntry => ({
+ event: { type, seq: 0, time: 0, data },
+ })
+
+ it('extracts text blocks and skips non-text content', () => {
+ // Text blocks join as paragraphs (newline-separated).
+ expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
+ expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
+ expect(contentText(undefined)).toBeUndefined()
+ expect(contentText('nope')).toBeUndefined()
+ })
+
+ it('lastActivity returns the LAST text output and the LAST tool call', () => {
+ const live = lastActivity([
+ entry('turn/start', { turn: 1 }),
+ entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
+ entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
+ entry('assistant/message', {
+ turn: 1, step: 1,
+ message: { content: [{ type: 'text', text: '检查完毕' }] },
+ }),
+ entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
+ entry('assistant/message', {
+ turn: 1, step: 2,
+ message: { content: [{ type: 'text', text: '再看一眼' }] },
+ }),
+ ])
+ expect(live).toEqual({
+ text: '再看一眼',
+ tool: { name: 'read', args: '{"path":"a.ts"}' },
+ })
+ })
+
+ it('lastActivity keeps only the fields the tail actually has', () => {
+ expect(lastActivity([
+ entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
+ ])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
+ expect(lastActivity([
+ entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
+ ])).toEqual({ text: 'ok' })
+ })
+
+ it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
+ const live = lastActivity([
+ entry('turn/end', { turn: 1, reason: 'success' }),
+ entry('step/start', { turn: 1, step: 1 }),
+ entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
+ entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
+ ])
+ expect(live).toEqual({})
+ expect(lastActivity([])).toEqual({})
+ })
+
+ it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
+ const live = lastActivity([
+ entry('tool/call', { callId: 'c1' }),
+ entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
+ ])
+ expect(live.tool).toEqual({ name: 'web', args: '' })
+ })
+})