feat: Subagent 拓扑页面(主代理根节点 + 分层树 + 实时工具调用 + 自动激活设置),v0.1.9
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -6,3 +6,4 @@ packages:
|
||||
# build scripts unless approved.
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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
@@ -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: '' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user