feat: Subagent 拓扑页面(主代理根节点 + 分层树 + 实时工具调用 + 自动激活设置),v0.1.9

This commit is contained in:
Menghuan1918
2026-08-10 17:24:42 +08:00
parent 1064ce954f
commit 61b18cbefe
22 changed files with 1547 additions and 30 deletions
+10 -2
View File
@@ -1,6 +1,6 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板,一个面板全部搞定。
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
@@ -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 页面
## ⌨️ 快捷键速查
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1
View File
@@ -6,3 +6,4 @@ packages:
# build scripts unless approved.
allowBuilds:
node-pty: true
protobufjs: true
+25 -3
View File
@@ -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) {
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsSubagentTitle')}</span>
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.autoOpenSubagent}
aria-label={t('settingsSubagentTitle')}
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
/>
</label>
{error !== null && (
<div className={css.error} role="alert">
{error}
+76 -3
View File
@@ -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 <TerminalView scope={scope} tabId={tab.id} store={store} />
case 'editor':
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
case 'subagent':
return (
<SubagentView
sessionId={sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { 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<SidebarSessionList | undefined>(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<string | undefined>(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) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -261,6 +332,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
/>
)
+275
View File
@@ -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);
}
+584
View File
@@ -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<Record<string, SidebarSessionSummary>>,
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<Record<string, SidebarSessionSummary>>
level: number
}) {
const { parentSessionId, byId, level } = props
const children = directChildren(byId, parentSessionId)
if (children.length === 0) {
return <div className={css.subagentEmpty}>{t('loading')}</div>
}
return (
<>
{children.map(summary => (
<div
key={summary.id}
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label={t('loading')}
className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
>
<StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{t('loading')}</span>
</span>
</div>
))}
</>
)
}
/**
* 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<ReturnType<typeof lastActivity>>({})
const controllerRef = useRef<AbortController | undefined>(undefined)
const address = useMemo(
() => ({ parentSessionId, childSessionId, mode }),
[parentSessionId, childSessionId, mode],
)
const load = useCallback(async (): Promise<void> => {
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 <span className={css.subagentLive}>{t('subagentThinking')}</span>
}
return (
<>
{live.tool !== undefined && (
<span className={css.subagentLive}>
<span className={css.subagentLiveTool}>{live.tool.name}</span>
{live.tool.args !== '' && (
<span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
)}
</span>
)}
{live.text !== undefined && (
<span className={css.subagentLiveText}>{flatten(live.text)}</span>
)}
</>
)
}
interface RowsProps {
parentSessionId: string
catalog: SidebarSubagentCatalog | undefined
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
byId: Readonly<Record<string, SidebarSessionSummary>>
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 && (
<CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
)}
{catalog?.state === 'error' && (
<div className={css.subagentError}>
<span>{catalog.error?.message ?? t('error')}</span>
<button
type="button"
className={css.subagentErrorRetry}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
{t('retry')}
</button>
</div>
)}
{(catalog?.entries ?? []).map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
title={diagnosticReason(entry)}
>
<StateDot state="error" className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{entry.id}</span>
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
</span>
</div>
</div>
)
}
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 (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
tabIndex={0}
aria-level={level}
aria-label={`${label} ${secondary}`}
aria-current={current ? 'true' : undefined}
{...knownLeaf ? {} : { 'aria-expanded': true }}
className={clsx(css.subagentRow, current && css.subagentRowActive)}
onClick={() => { openChild(address) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openChild(address)
}
}}
>
<StateDot
state={entry.activity === 'running' ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
<SubagentLiveLines
ctx={ctx}
parentSessionId={parentSessionId}
childSessionId={entry.id}
mode={entry.mode}
running={entry.activity === 'running'}
active={active}
/>
</span>
</div>
{!knownLeaf && (
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
{childCatalog === undefined
? (
<CatalogLoadingRows
parentSessionId={entry.id}
byId={byId}
level={level + 1}
/>
)
: (
<CatalogRows
parentSessionId={entry.id}
catalog={childCatalog}
catalogs={catalogs}
byId={byId}
level={level + 1}
currentSessionId={currentSessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
</div>
)
})}
</>
)
}
/**
* 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<string>())
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<HTMLDivElement>(null)
const focusAt = useCallback((index: number): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
'[role="treeitem"]:not([aria-disabled="true"])',
) ?? []
if (items.length === 0) return
items[(index + items.length) % items.length]?.focus()
}, [])
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
'[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 (
<div className={css.subagent}>
<div className={css.subagentHeader}>
<span className={css.subagentTitle}>
{t('subagent')}
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? ` · ${rootSummary.displayTitle}`
: ''}
</span>
{countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
<button
type="button"
className={css.subagentRefresh}
aria-label={t('refresh')}
title={t('refresh')}
disabled={rootId === undefined}
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
>
<IconRefreshOutline14 />
</button>
</div>
<div
ref={bodyRef}
className={css.subagentBody}
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
onKeyDown={onTreeKeyDown}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
</div>
</div>
)
}
+2 -1
View File
@@ -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 <IconFolderOpen16 size={size} />
case 'git': return <IconBranchOutline16 size={size} />
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'subagent': return <IconThinkOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
}
}
+1 -1
View File
@@ -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
+43 -2
View File
@@ -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<keyof typeof zh, string> = {
@@ -173,15 +190,39 @@ const en: Record<keyof typeof zh, string> = {
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, string | number>): 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). */
+3
View File
@@ -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,
}
}
+4 -4
View File
@@ -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<DropZone | null>(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)}
</div>
))}
</div>
@@ -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 (
+4 -3
View File
@@ -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<string>, reid: Map<string, string
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
&& candidate.type !== 'subagent'
) {
return undefined
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Pure derivation of the compact LIVE line shown on a running subagent card:
* the last text output and the last tool call of the child's history tail
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
* SubagentLiveLine component turns this into the card's status lines.
* Kept framework-free so the parser is unit-testable in the node environment.
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
/**
* Extract the concatenated plain text of a content-block list (the durable
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
* `text`; anything else — tool_use, image, … — contributes nothing).
* @param content - the raw `content` field of a message event.
* @returns the joined text, or undefined when the message carries no text.
*/
export function contentText(content: unknown): string | undefined {
if (!Array.isArray(content)) return undefined
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
return parts.length > 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 }),
}
}
+125
View File
@@ -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<string>()
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<Record<string, SidebarSubagentCatalog>>,
rootId: string | undefined,
): string[] {
const out: string[] = []
const seen = new Set<string>()
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<string>()
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
}
+1
View File
@@ -78,4 +78,5 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
export const PrefsSchema: z<SidebarPrefs> = 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),
})
+101
View File
@@ -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<SidebarSubagentChildEntry | SidebarSubagentDiagnosticEntry>
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<string, unknown>
}
/** One history row: the durable event plus an optional tool presentation view. */
export interface SidebarHistoryEntry {
event: SidebarSessionEvent
view?: unknown
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
/** Unary response mirror (`RpcResponse<T>` on the wire). */
export interface SidebarRpcResponse<T> {
rpcId: unknown
result: SidebarRpcResult<T>
}
/** 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<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
}
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
byId: Record<string, SidebarSessionSummary>
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
}
/** 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<void>
}
/** 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
+6
View File
@@ -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). */
+4 -2
View File
@@ -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<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
})
})
+4 -1
View File
@@ -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)
+209 -6
View File
@@ -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<string, SidebarSubagentCatalog> = {
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<string, SidebarSubagentCatalog> = {
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<string, unknown>): 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: '' })
})
})