From 61b18cbefe68c94b57328f2300ea0562798905a1 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 10 Aug 2026 17:21:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Subagent=20=E6=8B=93=E6=89=91=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=88=E4=B8=BB=E4=BB=A3=E7=90=86=E6=A0=B9=E8=8A=82?= =?UTF-8?q?=E7=82=B9=20+=20=E5=88=86=E5=B1=82=E6=A0=91=20+=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=20+=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=BF=80=E6=B4=BB=E8=AE=BE=E7=BD=AE=EF=BC=89=EF=BC=8C?= =?UTF-8?q?v0.1.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- dsh.plugin.json | 2 +- package.json | 2 +- pnpm-workspace.yaml | 1 + src/client/SideCardSection.tsx | 28 +- src/client/Sidebar.tsx | 79 +++- src/client/SubagentView.module.css | 275 ++++++++++++++ src/client/SubagentView.tsx | 584 +++++++++++++++++++++++++++++ src/client/icons.tsx | 3 +- src/client/index.tsx | 2 +- src/client/locales.ts | 45 ++- src/client/prefs.ts | 3 + src/client/split-pane.tsx | 8 +- src/client/state.ts | 7 +- src/client/subagent-activity.ts | 67 ++++ src/client/subagent-detect.ts | 125 ++++++ src/config.ts | 1 + src/context-types.ts | 101 +++++ src/prefs-shared.ts | 6 + tests/plugin-shape.spec.ts | 6 +- tests/smoke.spec.ts | 5 +- tests/unit.spec.ts | 215 ++++++++++- 22 files changed, 1547 insertions(+), 30 deletions(-) create mode 100644 src/client/SubagentView.module.css create mode 100644 src/client/SubagentView.tsx create mode 100644 src/client/subagent-activity.ts create mode 100644 src/client/subagent-detect.ts 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 页面,一个面板全部搞定。 image image @@ -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: '' }) + }) +})