From 66ee3953c946419afad0f71ea9b09fc480c342e0 Mon Sep 17 00:00:00 2001 From: figmar Date: Sun, 11 Oct 2026 21:18:08 +0800 Subject: [PATCH 1/7] feat(apes): revive the task/message tabs in the info layer C6 removed apes:tasks as a duplicate of the host session panel; in practice the two answer different questions (host panel = switch workspaces, plugin tab = who is running right now), so the tab is restored and the info layer goes back to five types. tasks: live session list (current / running / subagent markers) + showInactive toggle via pluginSettings['apes:tasks']; messages: placeholder that states the host has no message feed yet; both get badge counters, and the whole layer now honours the visible gate (auto-collapse) so hidden tabs stop waking on session events. zh/en/ja dictionaries in sync (AGENTS 5); wiki 02/03/07 reverted from three to five tabs; decision record in docs/plans/2026-10-11-apes-tab-revival.md. --- docs/plans/2026-10-11-apes-tab-revival.md | 40 ++++ docs/wiki/02-系统架构.md | 2 +- docs/wiki/03-功能说明.md | 17 +- docs/wiki/07-开发与测试.md | 2 +- src/client/apes/icons.tsx | 20 ++ src/client/apes/index.ts | 15 +- src/client/apes/locales.ts | 61 ++++- src/client/apes/tabs.tsx | 240 +++++++++++++++++--- src/client/index.tsx | 8 +- tests/apes-tabs.spec.ts | 259 ++++++++++++++++++++-- 10 files changed, 597 insertions(+), 67 deletions(-) create mode 100644 docs/plans/2026-10-11-apes-tab-revival.md diff --git a/docs/plans/2026-10-11-apes-tab-revival.md b/docs/plans/2026-10-11-apes-tab-revival.md new file mode 100644 index 0000000..ad8886c --- /dev/null +++ b/docs/plans/2026-10-11-apes-tab-revival.md @@ -0,0 +1,40 @@ +# apes 信息层 tab 复活(C6 → C7 反演)· 2026-10-11 + +## 为什么 + +C6 那次收紧把 `apes:tasks` 从信息层删掉了,理由是与宿主自带的会话面板「职责重叠」。 +实际用下来这个判断过于保守:**宿主的会话面板按工作区分组、服务于「换项目」,插件这个 tab +服务于「此刻谁在跑」**——它把当前会话、运行中会话、子智能体三种状态压进一屏,是切会话的入口, +不是另一份项目列表。删掉之后,「我现在有几个会话在跑」这件事在界面上就没有观测点了。 + +因此本轮按 C-2 的口径**照字面复活**:把 from-scratch 线(`apes-sidebar@2.0.0`)当年的五个 +tab 完整搬回 `dsh-apes-sidebar`,并顺手把它当年没做完的两件事补上(角标计数、隐藏门控)。 + +## 变成什么 + +| tab | order | 作用 | 相对 v2.0.0 | +| --- | --- | --- | --- | +| `apes:tasks` | 55 | 会话列表:当前 / 运行中 / 子智能体标记,点击切换会话 | 复活 | +| `apes:agents` | 60 | 主代理 + 直系子代理拓扑 | 复活(沿用 1.2.0 已有的拓扑实现) | +| `apes:apps` | 65 | 当前会话所属工作区 | 复活(宿主无应用 feed,占位) | +| `apes:messages` | 67 | 消息流占位 | 复活(宿主无消息流 feed) | +| `apes:profile` | 70 | 当前会话摘要(标题 / cwd / 运行态) | 复活 | + +新增的两条行为: + +- **角标**:`apes:tasks` 显示运行中会话数,`apes:agents` 显示直系子代理数;计数为 0 时不发角标。 +- **门控**:tab 不可见时不订阅会话 feed(不吃会话事件);`apes:tasks` 带 `showInactive` 开关, + 落在 `prefs.pluginSettings['apes:tasks']`,缺省显示全部。 + +## 与 C6 的关系(如何避免重复搬运) + +C6 删的是「与宿主会话面板重复的那部分」;本轮复活的是「宿主没有的那部分」。判断标准不是 +「有没有相似的 UI」,而是**这件事在宿主里有没有观测点**:会话的运行态、子智能体的直系拓扑, +宿主的两处面板都不给,插件给。据此定下取舍线——**宿主给了 feed 就不自造(apps / messages 明确 +写空态),宿主没给才自己接(tasks / agents)**。 + +## 相关 + +- 代码:`src/client/apes/tabs.tsx`、`locales.ts`(zh/en/ja)、`icons.tsx`、`src/client/index.tsx` +- 契约:`docs/wiki/03-功能说明.md` §一 总表 / §二 信息层五 tab(行为契约) +- 守护:`tests/apes-tabs.spec.ts`(五 tab 注册、角标、门控、过滤与排序、三语词条齐备) diff --git a/docs/wiki/02-系统架构.md b/docs/wiki/02-系统架构.md index 29fd976..976655f 100644 --- a/docs/wiki/02-系统架构.md +++ b/docs/wiki/02-系统架构.md @@ -29,7 +29,7 @@ │ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │ │ 终端 / 浏览器 + 文件预览器 │ │ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │ -│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │ +│ · src/client/apes/ 信息层五 tab(本 fork 的增量) │ │ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │ └──────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/wiki/03-功能说明.md b/docs/wiki/03-功能说明.md index 7ce4ad3..7b74f00 100644 --- a/docs/wiki/03-功能说明.md +++ b/docs/wiki/03-功能说明.md @@ -15,23 +15,32 @@ | `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) | | `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 | | `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff | -| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 | +| **`apes:tasks`** | **任务** | **55** | **本 fork** | 会话列表(当前 / 运行中 / 子智能体标记,`showInactive` 开关;角标 = 运行中会话数) | +| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑(角标 = 直系子代理数) | | **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) | +| **`apes:messages`** | **消息** | **67** | **本 fork** | 消息流占位(宿主暂无消息流 feed) | | **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) | -> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。 +> order 10–50 是上游/宿主既定带宽,apes 信息层取 55/60/65/67/70 留出余量,避免与原生面板抢占位置。 -## 二、信息层三 tab(本 fork 的增量) +## 二、信息层五 tab(本 fork 的增量) -三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册, +五个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册, 因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。 | tab | 展示内容 | 数据来源 | 空态 | | --- | --- | --- | --- | +| `apes:tasks` | 会话行;当前会话排首位,其次运行中、再次空闲;行上带「当前 / 运行中 / 子智能体」标记;点击切换会话 | `ctx.sessions.list` 快照(`byId` + `current`) | 「当前没有会话」提示 | | `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 | | `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 | +| `apes:messages` | 仅标题与说明(同 `apes:apps` 的占位策略) | —— | 明确写出「宿主今日无消息流 feed」 | | `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 | +**行为契约**:`apes:tasks` / `apes:agents` 声明 `badge`(运行中会话数 / 直系子代理数,为 0 时不渲染角标), +其余三个 tab 不发角标;`apes:tasks` 额外声明 `showInactive` 开关,读写落在 `prefs.pluginSettings['apes:tasks']` +(缺省显示全部,值非布尔时按缺省处理)。隐藏的 tab 不订阅会话 feed(`visible` 门控), +因此后台 tab 不会被会话事件唤醒。 + **实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**, 于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed 迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。 diff --git a/docs/wiki/07-开发与测试.md b/docs/wiki/07-开发与测试.md index b8c7029..969ec6e 100644 --- a/docs/wiki/07-开发与测试.md +++ b/docs/wiki/07-开发与测试.md @@ -33,7 +33,7 @@ src/ native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器 sidebar/ 自绘底部工作台与开合控件 - apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts) + apes/ 信息层五 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts) changes/ diff/ 文件变动与 diff 视图 chunks/ 懒加载 chunk:editor / locale / mermaid / terminal locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件) diff --git a/src/client/apes/icons.tsx b/src/client/apes/icons.tsx index 5b89d43..ede9c85 100644 --- a/src/client/apes/icons.tsx +++ b/src/client/apes/icons.tsx @@ -48,3 +48,23 @@ export function IconUserOutline16({ size = 16, className }: { size?: number; cla ) } + +/** Tasks glyph: a rounded card carrying a check mark. */ +export function IconTasksOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode { + return ( + + + + + ) +} + +/** Messages glyph: a speech bubble with a lower-left tail. */ +export function IconMessagesOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode { + return ( + + + + + ) +} diff --git a/src/client/apes/index.ts b/src/client/apes/index.ts index d79ab28..d740bcf 100644 --- a/src/client/apes/index.ts +++ b/src/client/apes/index.ts @@ -1,9 +1,14 @@ /** * apes:* information-layer tabs — the fork's own contribution on top of the - * upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile); - * `apes:tasks` was removed (C6) because it overlaps the official DSH session - * tree in the native sidebar, and `messages` is deferred (no DSH - * message-stream API yet — tracked separately). + * upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages / + * apps / profile). + * + * History (C7): `apes:tasks` was removed under the old C6 decision (it looked + * redundant next to the official DSH session tree). The `v2-revive` line + * restores it — the native tree is a *navigator* (one focused lineage), this + * panel is a *list* (every session, with an idle filter and a running badge), + * so the two answer different questions. `messages` is still an honest + * placeholder: no DSH message-stream feed exists yet. * * Isolated under `src/client/apes/` so the upstream `builtins/` tree stays * untouched: cherry-picking upstream releases only has to keep the single @@ -20,7 +25,7 @@ import { apesTabs } from './tabs.tsx' // Re-exported so index.tsx can register the apes dictionaries + attach the // locale service in one import (keeps the apes module self-contained). -export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts' +export { APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './locales.ts' /** * Register the apes:* tabs with the sidebar service. Returns a disposer that diff --git a/src/client/apes/locales.ts b/src/client/apes/locales.ts index ba2f46d..e4fc5e0 100644 --- a/src/client/apes/locales.ts +++ b/src/client/apes/locales.ts @@ -17,9 +17,19 @@ export const APES_LOCALE_NS = 'apes' /** The zh dictionary (source of truth). */ export const apesZh = { // View titles + apesTasksTitle: '任务', + apesMessagesTitle: '消息', apesAgentsTitle: '智能体', apesAppsTitle: '应用', apesProfileTitle: '画像', + // Tasks view + apesTasksShowInactive: '显示空闲会话', + apesTasksEmpty: '当前没有会话', + apesTasksCurrent: '当前', + apesTasksRunning: '运行中', + apesTasksSubagent: '子智能体', + // Messages view + apesMessagesNoFeed: '当前 DSH 版本未提供消息流数据源', // Agents view apesAgentsEmpty: '当前会话没有子智能体', apesAgentsRoot: '主智能体', @@ -40,9 +50,17 @@ export const apesZh = { /** The en dictionary (checked against zh — every zh key must appear here). */ export const apesEn: Record = { + apesTasksTitle: 'Tasks', + apesMessagesTitle: 'Messages', apesAgentsTitle: 'Agents', apesAppsTitle: 'Apps', apesProfileTitle: 'Profile', + apesTasksShowInactive: 'Show idle sessions', + apesTasksEmpty: 'No sessions right now', + apesTasksCurrent: 'current', + apesTasksRunning: 'running', + apesTasksSubagent: 'subagent', + apesMessagesNoFeed: 'This DSH version exposes no message stream feed', apesAgentsEmpty: 'No subagents in this session', apesAgentsRoot: 'Main agent', apesAgentRunning: 'running', @@ -60,7 +78,39 @@ export const apesEn: Record = { export type ApesCopyKey = keyof typeof apesZh -/** Resolve the currently active DSH locale ('zh' | 'en' today). */ +/** + * The ja dictionary (AGENTS §5: a new zh key must land in ja in the same + * change, otherwise ja readers silently get the en text). Japanese is not a + * separate source of truth — it mirrors the en wording, never the zh one. + */ +export const apesJa: Record = { + apesTasksTitle: 'タスク', + apesMessagesTitle: 'メッセージ', + apesAgentsTitle: 'エージェント', + apesAppsTitle: 'アプリ', + apesProfileTitle: 'プロフィール', + apesTasksShowInactive: 'アイドル状態のセッションを表示', + apesTasksEmpty: 'セッションがありません', + apesTasksCurrent: '現在', + apesTasksRunning: '実行中', + apesTasksSubagent: 'サブエージェント', + apesMessagesNoFeed: 'この DSH バージョンはメッセージストリームの供給元を提供していません', + apesAgentsEmpty: 'このセッションにサブエージェントはいません', + apesAgentsRoot: 'メインエージェント', + apesAgentRunning: '実行中', + apesAgentInactive: 'アイドル', + apesAgentOneShot: '単発', + apesAgentContinuable: '継続可', + apesProfileCurrent: '現在のセッション', + apesProfileCwd: '作業ディレクトリ', + apesProfileNoCwd: '不明', + apesProfileRunning: '実行中', + apesProfileIdle: 'アイドル', + apesAppsNoFeed: 'この DSH バージョンはワークスペースアプリのデータ源を提供していません', + apesAppsWorkspace: 'ワークスペース', +} + +/** Resolve the currently active DSH locale ('zh' | 'ja' | 'en' today). */ export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void { apesLocaleService = service } @@ -69,13 +119,14 @@ let apesLocaleService: { getSnapshot(): { active: string } } | undefined /** * Translate an apes key. Resolution order: active-locale chain (zh dict when - * the DSH preference is zh, en dict otherwise) → key fallback (shows the key - * itself rather than `undefined`). Mirrors the upstream t() minus the - * better-locale override (Phase 2). + * the DSH preference is zh*, ja dict when it is ja*, en dict otherwise) → key + * fallback (shows the key itself rather than `undefined`). Mirrors the + * upstream t() minus the better-locale override (Phase 2). */ export function t(key: ApesCopyKey, params?: Record): string { const active = apesLocaleService?.getSnapshot().active ?? 'zh' - const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn + const lang = active.toLowerCase() + const dict = lang.startsWith('zh') ? apesZh : lang.startsWith('ja') ? apesJa : apesEn let text: string | undefined = dict[key] if (text === undefined) text = key if (params !== undefined) { diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx index 4c10c80..d4f0a12 100644 --- a/src/client/apes/tabs.tsx +++ b/src/client/apes/tabs.tsx @@ -1,17 +1,27 @@ /** * apes:* tab views (D2 = data wiring). Each tab reads live data from the * sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and - * reuses the fork's PURE lineage/job helpers — these are framework-free and + * reuses the fork's PURE lineage helpers — these are framework-free and * live outside `builtins/`, so importing them does not couple the apes module * to upstream UI and survives cherry-picks. * - * Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the - * official DSH session tree in the native sidebar): - * - apes:agents → the subagent topology rooted at the main agent of the - * current session (rootAncestor + per-parent catalogs). - * - apes:apps → no DSH host feed today: shows the workspace (cwd) of the - * current session, otherwise an honest "no feed" note. - * - apes:profile → the current session's summary row (title, cwd, running). + * Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason + * the earlier C6 removal no longer holds: the native DSH session tree renders + * ONE focused lineage, while this panel is a *flat, whole-workspace* list with + * a running badge and an idle filter — complementary, not redundant. + * - apes:tasks → every session (running / current / subagent marks) with + * an idle filter; a running count badges the tab. + * - apes:agents → the subagent topology rooted at the main agent of the + * current session (rootAncestor + per-parent catalogs). + * - apes:messages → honest placeholder: DSH exposes no message-stream feed. + * - apes:apps → no DSH host feed today: shows the workspace (cwd) of the + * current session, otherwise an honest "no feed" note. + * - apes:profile → the current session's summary row (title, cwd, running). + * + * Live-data discipline (C4): list-reading views subscribe only while the tab + * is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel + * stops re-rendering; tab-strip counts ride the cheap `badge` contract instead + * of a live subscription. * * Visual unity (C7): colors use the DSW design-token aliases * (`--dsw-alias-*`) so the apes panels match the native DSH sidebar @@ -24,14 +34,19 @@ * `subscribe(cb)` is uSES-safe. * - `scope.sessionId` = the currently-open session. */ -import { useCallback, useMemo, useState, type CSSProperties } from 'react' +import { useCallback, useMemo, type CSSProperties } from 'react' import { useSyncExternalStore } from 'react' -import { IconAgentOutline16, IconAppsOutline16, IconUserOutline16 } from './icons.tsx' +import { + IconAgentOutline16, + IconAppsOutline16, + IconMessagesOutline16, + IconTasksOutline16, + IconUserOutline16, +} from './icons.tsx' import type { TabComponentProps, TabDescriptor } from '../service.ts' import type { SidebarSessionList, SidebarSessionSummary, - SidebarSubagentCatalog, SidebarSubagentChildEntry, } from '../../context-types.ts' import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts' @@ -66,8 +81,16 @@ const S: Record = { cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary }, cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 }, + chip: { fontSize: 10, lineHeight: '15px', padding: '0 6px', borderRadius: 999, border: `1px solid ${DSW.borderL2}`, color: DSW.labelSecondary, flexShrink: 0, whiteSpace: 'nowrap' }, + taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' }, } +/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */ +export const TASKS_TAB_ID = 'apes:tasks' + +/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */ +export const TASKS_SHOW_INACTIVE_KEY = 'showInactive' + /** A small colored status dot (self-contained, not the builtin StateDot). */ function Dot(props: { color: string }) { const style: CSSProperties = { @@ -76,15 +99,91 @@ function Dot(props: { color: string }) { return } -/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */ -function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList { - const sessions = ctx.sessions +/** A tiny status pill (current / running / subagent marks). */ +function Chip(props: { label: string; color?: string }) { + return {props.label} +} + +/** Stable no-op subscription: `visible === false` pauses the feed. */ +export const NOOP_SUBSCRIBE = (): (() => void) => () => {} + +/** + * The subscription a view hands to uSES (C4 pause contract). Exported so the + * "hidden tab stops waking on session events" rule is unit-testable without a + * DOM: with `visible = false` the feed is never touched. + */ +export function feedSubscribe( + ctx: TabComponentProps['ctx'], + visible: boolean, +): (callback: () => void) => () => void { + if (!visible) return NOOP_SUBSCRIBE + return callback => ctx.sessions.list.subscribe(callback) +} + +/** + * Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. + * Pass `visible = false` to detach — a hidden tab keeps rendering from the + * last snapshot without waking on every session event. + */ +function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarSessionList { + const subscribe = useMemo(() => feedSubscribe(ctx, visible), [ctx, visible]) return useSyncExternalStore( - useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]), - useCallback(() => sessions.list.getSnapshot(), [sessions]), + subscribe, + useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]), ) } +/** + * The `showInactive` filter state. The declarative settings row writes it into + * the sidebar prefs document under `pluginSettings['apes:tasks']` (service + * contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of + * the public `SidebarPrefs` type — hence the narrow local cast. Default is + * true: the panel is a task list, so showing idle sessions is the useful + * default and the switch lets the reader trim it down. + */ +export function tasksShowInactive(store: TabComponentProps['store']): boolean { + try { + const prefs = store.getPrefs() as unknown as { pluginSettings?: Record> } + const blob = prefs.pluginSettings?.[TASKS_TAB_ID] + const value = blob?.[TASKS_SHOW_INACTIVE_KEY] + return typeof value === 'boolean' ? value : true + } catch { + return true + } +} + +/** + * apes:tasks rows: every non-side-chat session, running ones first (then the + * current one), idle ones only when `showInactive`. PURE — unit-tested. + */ +export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] { + const rows = Object.values(list.byId).filter(s => !isSideThreadSummary(s) && (showInactive || s.running === true)) + const rank = (s: SidebarSessionSummary): number => (s.id === list.current ? 0 : s.running === true ? 1 : 2) + return rows.sort( + (a, b) => rank(a) - rank(b) || (a.displayTitle ?? a.id).localeCompare(b.displayTitle ?? b.id), + ) +} + +/** Running sessions (the tasks tab badge). Cheap: one pass, no allocation. */ +export function runningSessionCount(list: SidebarSessionList): number { + let count = 0 + for (const s of Object.values(list.byId)) { + if (s.running === true && !isSideThreadSummary(s)) count += 1 + } + return count +} + +/** Subagents in the given session's tree (the agents tab badge). */ +export function subagentCount(list: SidebarSessionList, sessionId: string): number { + const root = rootAncestor(list.byId, sessionId) + let count = 0 + for (const s of Object.values(list.byId)) { + if (s.origin !== 'subagent' || isSideThreadSummary(s)) continue + if (rootAncestor(list.byId, s.id) === root) count += 1 + } + return count +} + /** One level of the subagent topology (rooted at the main agent). */ function agentChildren( byId: Readonly>, @@ -95,9 +194,69 @@ function agentChildren( ) } +/** apes:tasks — the flat, whole-workspace session list. */ +export function ApesTasksView(props: TabComponentProps) { + const list = useSessionList(props.ctx, props.visible) + const store = props.store + const showInactive = useSyncExternalStore( + useMemo(() => (callback: () => void) => store.subscribe(callback), [store]), + useCallback(() => tasksShowInactive(store), [store]), + ) + const rows = useMemo(() => tasksRows(list, showInactive), [list, showInactive]) + const open = props.ctx.sessions.open + + return ( +
+

{t('apesTasksTitle')}

+ {rows.length === 0 + ?

{t('apesTasksEmpty')}

+ : ( +
+ {rows.map(row => { + const running = row.running === true + return ( + + ) + })} +
+ )} +
+ ) +} + +/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */ +export function ApesMessagesView(_props: TabComponentProps) { + return ( +
+

{t('apesMessagesTitle')}

+

{t('apesMessagesNoFeed')}

+
+ ) +} + /** apes:agents — the subagent topology of the current session's tree. */ function ApesAgentsView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const { byId, subagentsByParent } = list const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId]) const rootSummary = rootId === undefined ? undefined : byId[rootId] @@ -165,7 +324,7 @@ function ApesAgentsView(props: TabComponentProps) { /** apes:profile — the current session's identity (title, cwd, running state). */ export function ApesProfileView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const current = list.current const summary = current === undefined ? undefined : list.byId[current] const cwd = summary?.cwd ?? props.scope.cwd @@ -202,7 +361,7 @@ export function ApesProfileView(props: TabComponentProps) { /** apes:apps — no DSH host feed today; shows the current workspace (cwd). */ function ApesAppsView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const current = list.current const summary = current === undefined ? undefined : list.byId[current] const cwd = summary?.cwd ?? props.scope.cwd @@ -224,14 +383,45 @@ function ApesAppsView(props: TabComponentProps) { } /** - * The three apes:* tab descriptors (C6: `apes:tasks` removed — overlaps the - * official DSH session tree). Order 901-903: agents/apps/profile. Each is - * already shaped as `TabDescriptor` (component + single-instance + icon - * element + i18n title function) so `registerApesTabs` can hand them straight - * to `service.registerTab`. Information-layer tabs are single-instance. + * The five apes:* tab descriptors (C7: `apes:tasks` restored — the native + * session tree focuses one lineage, this list covers the whole workspace). + * Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in + * reading order: tasks / agents / apps / messages / profile. Each is already + * shaped as `TabDescriptor` (component + single-instance + icon element + + * i18n title function) so `registerApesTabs` can hand them straight to + * `service.registerTab`. Information-layer tabs are single-instance. */ export const apesTabs: TabDescriptor[] = [ - { id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => , order: 60, single: true, component: ApesAgentsView }, + { + id: TASKS_TAB_ID, + title: () => t('apesTasksTitle'), + icon: (size: number) => , + order: 55, + single: true, + component: ApesTasksView, + badge: ctx => { + const count = runningSessionCount(ctx.sessions.list.getSnapshot()) + return count > 0 ? count : null + }, + settings: { + pluginToggles: [ + { key: TASKS_SHOW_INACTIVE_KEY, title: () => t('apesTasksShowInactive'), type: 'switch' }, + ], + }, + }, + { + id: 'apes:agents', + title: () => t('apesAgentsTitle'), + icon: (size: number) => , + order: 60, + single: true, + component: ApesAgentsView, + badge: (ctx, scope) => { + const count = subagentCount(ctx.sessions.list.getSnapshot(), scope.sessionId) + return count > 0 ? count : null + }, + }, { id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => , order: 65, single: true, component: ApesAppsView }, + { id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => , order: 67, single: true, component: ApesMessagesView }, { id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => , order: 70, single: true, component: ApesProfileView }, ] diff --git a/src/client/index.tsx b/src/client/index.tsx index 8cf08ed..6a5c8fb 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -15,7 +15,7 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts' import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' -import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts' +import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './apes/index.ts' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' import { registerTurnTailInterception } from './intercept.tsx' @@ -65,14 +65,16 @@ export function apply(ctx: Context): void { attachLocale(ctx.locale) // The apes module has its own locale namespace (isolated from the upstream // betterSidebar dicts): attach it so apes t() tracks the DSH active-locale - // preference, and register its zh/en dicts under `apes`. + // preference, and register its zh/ja/en dicts under `apes` (AGENTS §5: a ja + // dict must ship with every new zh key, else ja readers fall back to en). attachApesLocale(ctx.locale) ctx.effect(() => { const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh) const offEn = ctx.locale.register(LOCALE_NS, 'en', en) const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record) + const offApesJa = ctx.locale.register(APES_LOCALE_NS, 'ja', apesJa as Record) const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record) - return () => { offZh(); offEn(); offApesZh(); offApesEn() } + return () => { offZh(); offEn(); offApesZh(); offApesJa(); offApesEn() } }, 'dsh-apes-sidebar: dictionaries') // Opt-in third-language support through @huanlin/dsh-plugin-better-locale. diff --git a/tests/apes-tabs.spec.ts b/tests/apes-tabs.spec.ts index 547cc5a..b1c32cd 100644 --- a/tests/apes-tabs.spec.ts +++ b/tests/apes-tabs.spec.ts @@ -1,25 +1,44 @@ /** - * apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers - * its three information-layer tabs (agents / apps / profile) through the same - * BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was - * removed in C6 — it overlaps the official DSH session tree in the native - * sidebar. The spec pins the registration contract (id / single / order / - * component presence) and the isolated i18n contract (the apes namespace - * resolves zh vs en through the attached locale service, and every apesZh - * key has an apesEn entry). + * apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers + * its information-layer tabs (tasks / agents / apps / messages / profile) + * through the same BetterSidebarService the upstream builtins use (dogfood). + * `apes:tasks` is back (C7 reversal of C6): the native DSH session tree focuses + * ONE lineage, while this panel is a flat, whole-workspace list with a running + * badge and an idle filter — the overlap is complementary. + * + * The spec pins: the registration contract (id / single / order / component / + * icon), the two Phase-2 increments (running-count + subagent-count `badge` + * functions, and the C4 `visible` pause on the sessions-list feed), the + * `showInactive` plugin-toggle channel, the PURE row helpers, and the isolated + * i18n contract (zh / ja / en parity + active-locale resolution). * * Runs in the node vitest environment like builtins.spec.ts — no DOM, no - * real DSH host (a live cordis scratch mount is Phase 2+). + * real DSH host (a live cordis scratch mount is covered by the mount e2e). */ import { describe, expect, it } from 'vitest' // Browser globals before the client graph loads (mirror of builtins.spec.ts). import './browser-globals.ts' import type { Context } from '../src/context-types.ts' +import type { SidebarSessionList, SidebarSessionSummary } from '../src/context-types.ts' import { createBetterSidebarService } from '../src/client/service.ts' +import type { SessionScope, SidebarState, TabComponentProps } from '../src/client/service.ts' import { createSidebarStore } from '../src/client/state.ts' import { registerApesTabs } from '../src/client/apes/index.ts' -import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts' +import { apesZh, apesEn, apesJa, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts' +import { + NOOP_SUBSCRIBE, + TASKS_SHOW_INACTIVE_KEY, + TASKS_TAB_ID, + apesTabs, + feedSubscribe, + runningSessionCount, + subagentCount, + tasksRows, + tasksShowInactive, +} from '../src/client/apes/tabs.tsx' + +const APES_TAB_IDS = ['apes:agents', 'apes:apps', 'apes:messages', 'apes:profile', 'apes:tasks'] function setup(): { service: ReturnType @@ -32,13 +51,47 @@ function setup(): { return { service, store, dispose } } +/** A durable session row fixture. */ +function row(id: string, over: Partial = {}): SidebarSessionSummary { + return { id, displayTitle: id, ...over } +} + +/** A session-list snapshot fixture. */ +function snap(current: string | undefined, rows: readonly SidebarSessionSummary[]): SidebarSessionList { + return { current, byId: Object.fromEntries(rows.map(r => [r.id, r])) } +} + +/** A minimal ctx carrying only the sessions-list feed the apes views read. */ +function fakeCtx(list: SidebarSessionList, onSubscribe?: () => void): Context { + return { + sessions: { + list: { + getSnapshot: () => list, + subscribe: () => { + onSubscribe?.() + return () => {} + }, + }, + }, + } as unknown as Context +} + +/** Invoke a descriptor's badge with a fixture snapshot. */ +function badgeOf(id: string, list: SidebarSessionList, sessionId = 'main'): string | number | null | undefined { + const descriptor = apesTabs.find(d => d.id === id) + expect(descriptor, `${id} descriptor`).toBeDefined() + return descriptor?.badge?.( + fakeCtx(list), + { sessionId } as SessionScope, + {} as SidebarState, + ) +} + describe('apes:* tab registrations', () => { - it('registers the 3 apes tabs with the apes: namespace', () => { + it('registers the 5 apes tabs with the apes: namespace', () => { const { service, dispose } = setup() try { - expect(service.getTabs().map(t => t.id).sort()).toEqual( - ['apes:agents', 'apes:apps', 'apes:profile'], - ) + expect(service.getTabs().map(t => t.id).sort()).toEqual(APES_TAB_IDS) } finally { dispose() } @@ -47,35 +100,40 @@ describe('apes:* tab registrations', () => { it('every apes tab is single-instance, visible, and component-carrying', () => { const { service, dispose } = setup() try { - for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) { + for (const id of APES_TAB_IDS) { const tab = service.getTab(id) expect(tab, `${id} registered`).toBeDefined() expect(tab?.single, `${id} single`).toBe(true) expect(tab?.hidden, `${id} hidden`).not.toBe(true) expect(tab?.component, `${id} component`).toBeDefined() expect(tab?.icon, `${id} icon`).toBeDefined() + expect(typeof tab?.title === 'function' ? tab.title() : tab?.title, `${id} title`).toBeTruthy() } } finally { dispose() } }) - it('apes tabs sort after the upstream builtins (order 40+)', () => { + it('apes tabs sort after the upstream builtins (order 40+) in reading order', () => { const { service, dispose } = setup() try { const orders = { + tasks: service.getTab(TASKS_TAB_ID)?.order, agents: service.getTab('apes:agents')?.order, apps: service.getTab('apes:apps')?.order, + messages: service.getTab('apes:messages')?.order, profile: service.getTab('apes:profile')?.order, } // C8: order 40-50 places apes tabs immediately after the native builtins // (10-35), not in a far-away 900+ band — visual unity in the tab strip. - expect(orders.agents).toBeGreaterThanOrEqual(40) - expect(orders.apps).toBeGreaterThanOrEqual(40) - expect(orders.profile).toBeGreaterThanOrEqual(40) - // Strictly ordered: agents < apps < profile. + for (const [id, order] of Object.entries(orders)) { + expect(order, `${id} order`).toBeGreaterThanOrEqual(40) + } + // Strictly ordered: tasks < agents < apps < messages < profile. + expect(orders.tasks!).toBeLessThan(orders.agents!) expect(orders.agents!).toBeLessThan(orders.apps!) - expect(orders.apps!).toBeLessThan(orders.profile!) + expect(orders.apps!).toBeLessThan(orders.messages!) + expect(orders.messages!).toBeLessThan(orders.profile!) } finally { dispose() } @@ -85,14 +143,146 @@ describe('apes:* tab registrations', () => { const store = createSidebarStore() const service = createBetterSidebarService(store) const dispose = registerApesTabs({} as Context, service) - expect(service.getTabs()).toHaveLength(3) + expect(service.getTabs()).toHaveLength(5) dispose() expect(service.getTabs()).toHaveLength(0) }) }) +describe('apes:tasks plugin toggles', () => { + it('declares the showInactive switch under the tasks descriptor', () => { + const descriptor = apesTabs.find(d => d.id === TASKS_TAB_ID) + const toggles = descriptor?.settings?.pluginToggles ?? [] + expect(toggles).toHaveLength(1) + const toggle = toggles[0] + expect(toggle?.key).toBe(TASKS_SHOW_INACTIVE_KEY) + expect(toggle?.key).toBe('showInactive') + expect(toggle?.type).toBe('switch') + expect(typeof toggle?.title === 'function' ? toggle.title() : toggle?.title).toBeTruthy() + }) + + it('defaults to showing every session when no pref is stored', () => { + const store = { getPrefs: () => ({}) } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(store)).toBe(true) + }) + + it('reads the boolean from pluginSettings[apes:tasks]', () => { + const store = { + getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }), + } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(store)).toBe(false) + }) + + it('ignores non-boolean values and failing stores (fail soft)', () => { + const odd = { + getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: 'nope' } } }), + } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(odd)).toBe(true) + const broken = { getPrefs: () => { throw new Error('no prefs') } } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(broken)).toBe(true) + }) +}) + +describe('apes:tasks row selection (PURE)', () => { + it('ranks current first, then running, then idle', () => { + const list = snap('idle-b', [ + row('idle-a'), + row('run-2', { running: true }), + row('idle-b'), + row('run-1', { running: true }), + ]) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['idle-b', 'run-1', 'run-2', 'idle-a']) + }) + + it('hides idle sessions when showInactive is false', () => { + const list = snap('main', [row('main'), row('work', { running: true }), row('sleeping')]) + // C-2 fidelity: v2.0.0 filters on `running` alone, so an idle *current* + // session is dropped too — the toggle means "idle sessions disappear", + // not "always keep the one I am looking at". + expect(tasksRows(list, false).map(r => r.id)).toEqual(['work']) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['main', 'work', 'sleeping']) + }) + + it('never lists side-chat threads (they are not topology)', () => { + const list = snap('main', [row('main'), row('side', { origin: 'subagent', displayTitle: 'Side: main' })]) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['main']) + }) + + it('counts running sessions for the badge', () => { + const list = snap('main', [ + row('main', { running: true }), + row('child', { running: true, origin: 'subagent', parentId: 'main' }), + row('idle'), + row('side', { origin: 'subagent', displayTitle: 'Side: main' }), + ]) + expect(runningSessionCount(list)).toBe(2) + }) +}) + +describe('apes:agents badge + topology count', () => { + it('counts the subagents of the current session tree only', () => { + const list = snap('main', [ + row('main'), + row('c1', { origin: 'subagent', parentId: 'main' }), + row('c2', { origin: 'subagent', parentId: 'c1' }), + row('other'), + row('o1', { origin: 'subagent', parentId: 'other' }), + ]) + expect(subagentCount(list, 'main')).toBe(2) + expect(subagentCount(list, 'c1')).toBe(2) + expect(subagentCount(list, 'other')).toBe(1) + expect(subagentCount(list, 'unknown')).toBe(0) + }) + + it('excludes side-chat threads from the count', () => { + const list = snap('main', [ + row('main'), + row('side', { origin: 'subagent', parentId: 'main', displayTitle: 'Side: main' }), + row('c1', { origin: 'subagent', parentId: 'main' }), + ]) + expect(subagentCount(list, 'main')).toBe(1) + }) +}) + +describe('apes badge contract', () => { + it('badges the tasks tab with the running count, null when idle', () => { + expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a', { running: true }), row('b')]))).toBe(1) + expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a'), row('b')]))).toBeNull() + }) + + it('badges the agents tab with the subagent count, null when childless', () => { + expect(badgeOf('apes:agents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1) + expect(badgeOf('apes:agents', snap('main', [row('main')]), 'main')).toBeNull() + }) + + it('never badges the placeholder/static tabs', () => { + for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) { + expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined() + } + }) +}) + +describe('apes C4 visible pause', () => { + it('does not touch the sessions feed while the tab is hidden', () => { + let subscribed = 0 + const ctx = fakeCtx(snap('main', [row('main')]), () => { subscribed += 1 }) + feedSubscribe(ctx, false) + expect(subscribed).toBe(0) + const stop = feedSubscribe(ctx, true) (() => {}) + expect(subscribed).toBe(1) + expect(typeof stop).toBe('function') + stop() + }) + + it('exposes a stable no-op subscription', () => { + const stop = NOOP_SUBSCRIBE() + expect(typeof stop).toBe('function') + expect(() => stop()).not.toThrow() + }) +}) + describe('apes i18n contract', () => { - it('every apesZh key has an apesEn entry (no missing translations)', () => { + it('every apesZh key has an en entry (no missing translations)', () => { const zhKeys = Object.keys(apesZh) as ApesCopyKey[] for (const key of zhKeys) { expect(apesEn[key], `en entry for ${key}`).toBeDefined() @@ -101,6 +291,15 @@ describe('apes i18n contract', () => { } }) + it('every apesZh key has a ja entry (AGENTS §5)', () => { + const zhKeys = Object.keys(apesZh) as ApesCopyKey[] + for (const key of zhKeys) { + expect(apesJa[key], `ja entry for ${key}`).toBeDefined() + expect(typeof apesJa[key]).toBe('string') + expect((apesJa[key] ?? '').length).toBeGreaterThan(0) + } + }) + it('resolves zh when the DSH preference is zh', () => { attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) }) expect(t('apesAgentsTitle')).toBe('智能体') @@ -108,11 +307,25 @@ describe('apes i18n contract', () => { expect(t('apesProfileTitle')).toBe('画像') }) + it('resolves the new tasks/messages titles in zh', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) }) + expect(t('apesTasksTitle')).toBe('任务') + expect(t('apesMessagesTitle')).toBe('消息') + }) + it('resolves en when the DSH preference is en', () => { attachApesLocale({ getSnapshot: () => ({ active: 'en' }) }) expect(t('apesAgentsTitle')).toBe('Agents') expect(t('apesAppsTitle')).toBe('Apps') expect(t('apesProfileTitle')).toBe('Profile') + expect(t('apesTasksTitle')).toBe('Tasks') + }) + + it('resolves ja when the DSH preference is ja', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'ja-JP' }) }) + expect(t('apesAgentsTitle')).toBe('エージェント') + expect(t('apesTasksTitle')).toBe('タスク') + expect(t('apesMessagesTitle')).toBe('メッセージ') }) it('falls back to zh when no locale service is attached', () => { -- 2.54.0 From 99bef0f877c17e69cfa5573a7ba21e2355c9864b Mon Sep 17 00:00:00 2001 From: figmar Date: Sun, 11 Oct 2026 21:18:08 +0800 Subject: [PATCH 2/7] chore(lint): drop unused imports flagged at HEAD eslint was red at HEAD (5b715ac) in files untouched by this branch: unused statSync/dirname (check-plugin-api.mjs), unused PrefsSchema (src/index.ts, tests/smoke.spec.ts) and two Function types (tests/smoke.spec.ts). No behaviour change. --- scripts/check-plugin-api.mjs | 4 ++-- src/index.ts | 1 - tests/smoke.spec.ts | 6 ++++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/check-plugin-api.mjs b/scripts/check-plugin-api.mjs index 14f227a..350a815 100644 --- a/scripts/check-plugin-api.mjs +++ b/scripts/check-plugin-api.mjs @@ -16,8 +16,8 @@ * node check-plugin-api.mjs [repoDir] [--app "/Applications/DSH Desktop.app"] * 退出码:0 = 通过;1 = 发现问题(可接进 CI / build 前置) */ -import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' -import { join, relative, dirname } from 'node:path' +import { readFileSync, readdirSync, existsSync } from 'node:fs' +import { join, relative } from 'node:path' const args = process.argv.slice(2) const repoArg = args.find(a => !a.startsWith('--')) diff --git a/src/index.ts b/src/index.ts index 150e6d8..41a5a8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,6 @@ import { WebSocket, WebSocketServer } from 'ws' import type { Context, SidebarHttpRequest, SidebarSessionEvent } from './context-types.ts' import { Config, - PrefsSchema, resolveSidebarConfig, SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS, diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index af0ad14..4760417 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -11,7 +11,6 @@ import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts' import { SidebarError } from '../src/wire.ts' -import { PrefsSchema } from '../src/config.ts' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' import { encodeHtmlUrl } from '../src/html-route.ts' import * as git from '../src/git.ts' @@ -892,7 +891,10 @@ describe('side card settings routes', () => { effect: (fn: () => void | (() => void)) => { fn() }, inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => { if (deps.includes('settings') && settings !== undefined) { - const s = settings as { update: Function; describe: Function } + const s = settings as { + update: (ns: string, ...rest: unknown[]) => unknown + describe: (ns: string, ...rest: unknown[]) => unknown + } // Wrap update to emit `settings/document-updated` after each write, // mirroring the real SettingsForms service behavior. const origUpdate = s.update.bind(s) -- 2.54.0 From 043340eb0203617e6a69a4d5a6215b215e5f927c Mon Sep 17 00:00:00 2001 From: figmar Date: Tue, 13 Oct 2026 09:28:47 +0800 Subject: [PATCH 3/7] fix(subagent): degrade to summary rows when root catalog never hydrates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Subagent/任务管理 page pinned on a permanent '加载中…' spinner whenever the host's per-parent subagent catalog (subagentsByParent) never hydrated for the root session — rootCatalog stayed undefined while the session mirror already listed the children, so summaryBackedLoading was true forever (SubagentView.tsx summaryBacked gate). Root cause: a host-side hydrate gap (e.g. the agent/disposed listener TypeError breaking the refresh chain) leaves setSubagentCatalogOpen unanswered, and the UI had no escape. Add a 5s hydrate timeout: once setSubagentCatalogOpen has had a chance to land and the root catalog is still un-hydrated, render the known children from the session mirror as CLICKABLE summary rows (SummaryFallbackRows) instead of the infinite spinner. The timer resets on root switch / catalog hydrate / page hidden. mode is inferred from the summary's running flag (best-effort transport hint). Verified: lint 0 / typecheck 0 / test 131 files·1385 passed / build 0. Rollback: git revert or reset SubagentView.tsx to 99bef0f. --- src/client/SubagentView.tsx | 114 +++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index ae92dc6..c2e410c 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -66,6 +66,15 @@ const ARGS_PREVIEW = 60 const JOB_POLL_MS = 2000 /** How long the kill button stays armed before it needs re-confirming. */ const JOB_KILL_ARM_MS = 3000 +/** + * How long the root catalog may stay un-hydrated before the page stops + * blocking on "loading…" and degrades to the summary-mirror rows. A catalog + * that never hydrates (host seam gap / a failed `agent/disposed` listener + * breaking the refresh chain) must not pin the Subagent page on a permanent + * loading spinner — the session summaries are already in the mirror, so the + * tree degrades to clickable summary rows instead. + */ +const CATALOG_HYDRATE_TIMEOUT_MS = 5000 /** The direct subagent children of one parent (durable `origin` rows; * Side Chat threads ride the same origin but are tab-strip conversations, @@ -150,6 +159,75 @@ function CatalogLoadingRows(props: { ) } +/** + * Degrade rows rendered when the root catalog never hydrates within + * {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the + * mirror (`byId`), so instead of pinning the page on a permanent "loading…" + * spinner we render the known children as CLICKABLE rows that jump to the + * child session. The address `mode` is inferred from the summary's `running` + * flag (a running agent is continuable; a settled one is one-shot) — a + * best-effort transport hint, since the durable catalog `mode` is unavailable + * once hydration has given up. + */ +function SummaryFallbackRows(props: { + parentSessionId: string + byId: Readonly> + level: number + currentSessionId: string + openChild: (address: SidebarSubagentAddress) => void +}) { + const { parentSessionId, byId, level, currentSessionId, openChild } = props + const children = directChildren(byId, parentSessionId) + if (children.length === 0) { + return
{t('loading')}
+ } + return ( + <> + {children.map(summary => { + const address: SidebarSubagentAddress = { + parentSessionId, + childSessionId: summary.id, + mode: summary.running === true ? 'continuable' : 'one-shot', + } + const current = summary.id === currentSessionId + const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id + const secondary = [ + summary.displayTitle, + summary.running === true ? t('subagentRunning') : t('subagentInactive'), + ].filter(Boolean).join(' · ') + return ( +
{ openChild(address) }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + openChild(address) + } + }} + > + + + {label} + {secondary} + +
+ ) + })} + + ) +} + /** * The live lines of one RUNNING subagent card: a pure presentation of the * batch `subagents.live` activity. The polling lives in one place (the @@ -733,6 +811,27 @@ export function SubagentView(props: { const summaryBackedLoading = rootId !== undefined && (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0)) && directChildren(byId, rootId).length > 0 + // Hydrate timeout: once `setSubagentCatalogOpen(rootId, true)` has had a + // chance to land and the root catalog is STILL un-hydrated (or an empty + // ready), stop blocking on "loading…" and degrade to the summary-mirror + // rows. The timer resets when the root switches (a new tree) or when the + // catalog actually hydrates (`summaryBackedLoading` goes false), and only + // fires while the page is visible (a hidden page is not "loading"). + const [catalogDegrade, setCatalogDegrade] = useState(false) + useEffect(() => { + setCatalogDegrade(false) + }, [rootId]) + useEffect(() => { + if (!summaryBackedLoading || !active) { + setCatalogDegrade(false) + return + } + const timer = window.setTimeout(() => { setCatalogDegrade(true) }, CATALOG_HYDRATE_TIMEOUT_MS) + return () => { window.clearTimeout(timer) } + }, [summaryBackedLoading, active]) + // `degraded` is the gate: summary-mirror rows show instead of the + // permanent "loading…" spinner once the hydrate timeout has fired. + const degraded = summaryBackedLoading && catalogDegrade const readyEmpty = rootCatalog?.state === 'ready' && rootCatalog.entries.length === 0 && directChildren(byId, rootId ?? '').length === 0 @@ -800,7 +899,7 @@ export function SubagentView(props: {
{rootId !== undefined && rootSummary !== undefined && (
)} {rootId !== undefined && ( -
- {summaryBackedLoading && ( +
+ {summaryBackedLoading && !degraded && ( )} + {summaryBackedLoading && degraded && ( + + )} {!summaryBackedLoading && ( Date: Tue, 13 Oct 2026 22:27:19 +0800 Subject: [PATCH 4/7] fix(settings): use the host's @deepseek-ai/schemastery so volatile prefs apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every side-card switch was dead: settings.update returned OK and cordis.patch.yml was written, yet the running plugin never saw the new value and nothing reported an error. Root cause: the plugin declared Config with the plain npm `schemastery` (3.18.0), which has no volatile support. The host resolves plugin config via `runtime.Config['~standard'].validate()` — i.e. the plugin's OWN schemastery — so `meta.volatile` nodes were never wrapped in cosmokit volatile references. `cordis-plugin-loader`'s `_commitVolatile()` bails out as soon as `volatileEntries(fiber.config)` is empty, so every volatile-only edit was silently dropped: no in-place update, no plugin restart. Fix: import z from '@deepseek-ai/schemastery' (peer provided by the host at mount; pinned 3.18.4 as a devDependency for the dev tree), matching the repo's existing pnpm-workspace convention for @deepseek-ai/* peers. Verified: probe reports tabsIsVolatile=true and revision climbing 0->6 across six writes, each read-back matching the written value (previously the value was frozen and revision stuck at 1). Also in this change: - apes tab ids lose their colon (apes:tasks -> apesTasks, ...): the host's settings projection dropped map keys containing a colon. - sidebar prefs ns follows the profile entry id (`better-sidebar`). - subagent rows degrade to summary rows when the root catalog never hydrates. - docs/feedback-0.2.0-rc2-contract-gaps.md: four host contract gaps, gap 4 documenting the silent volatile-write drop above. Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed. --- docs/feedback-0.2.0-rc2-contract-gaps.md | 114 ++++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 14 +- src/client/SubagentView.tsx | 165 ++++++++++++----------- src/client/apes/index.ts | 2 +- src/client/apes/tabs.tsx | 42 +++--- src/config.ts | 62 ++++++++- src/prefs-shared.ts | 7 +- tests/apes-tabs.spec.ts | 26 ++-- tests/plugin-shape.spec.ts | 2 +- tests/smoke.spec.ts | 24 ++-- 11 files changed, 320 insertions(+), 141 deletions(-) create mode 100644 docs/feedback-0.2.0-rc2-contract-gaps.md diff --git a/docs/feedback-0.2.0-rc2-contract-gaps.md b/docs/feedback-0.2.0-rc2-contract-gaps.md new file mode 100644 index 0000000..0b2ee74 --- /dev/null +++ b/docs/feedback-0.2.0-rc2-contract-gaps.md @@ -0,0 +1,114 @@ +# DSH 0.2.0-rc.2 插件契约缺口反馈(dsh-apes-sidebar) + +> 反馈人:Hermes@mac13 +> 日期:2026-10-13 +> 对象:figmar(DSH 宿主团队) +> 来源:dsh-apes-sidebar feat/apes-tab-revival 分支适配 0.2.0-rc.2 时发现的四个契约缺口 + +--- + +## 背景 + +dsh-apes-sidebar(better-sidebar 的 fork)在适配 DSH 0.2.0-rc.2 时,遇到四个**宿主侧 API/契约缺口**,目前均以插件侧 workaround 绕过,但根因在宿主。其中**缺口 4 是「设置开关点了不生效」的真根因**,代价最大、也最隐蔽(静默失败、无任何报错)。以下逐项说明现象、根因、当前 workaround、以及期望宿主提供的正式契约。 + +--- + +## 缺口 1:settings 契约——移除 `settings.register()` 后,插件自定义 prefs schema 无注册通道 + +### 现象 +- 0.2.0-rc.2 起 `settings.register()` 被移除,改由 loader 自动注册插件导出的 `Config` schema,ns = **profile 条目的 id**(不是包名;`dsh-apes-sidebar` 这个 ns 注册不上,报 `No configurable plugin entry "dsh-apes-sidebar"`)。 +- `settings.update(ns, patch, expectedRevision)` **只放行 schema 里标了 `volatile` 的字段**(`dsh-settings` 的 `write()` → `validatePaths` → `isVolatilePath`,非 volatile 路径抛 `Config field "" is not volatile`)。 +- 插件的用户偏好(tabsEnabled / autoOpenSubagent 等)原本定义在独立的 `PrefsSchema`,而 **loader 只注册 `Config`**,导致 `PrefsSchema` 成为死代码、从未被宿主认识。 + +### 当前 workaround(插件侧) +- 把用户偏好字段**合并进 `Config`**(真正被 loader 注册的 schema),每个字段标 `.extra('volatile', true)`;部署字段(readLimit / shell 等)保持**非 volatile**。 +- 条目 id 与 settings ns 对齐为 `better-sidebar`。 + +### 期望宿主提供的契约 +1. **明确文档**:0.2.0-rc.2 起插件如何注册「用户偏好 schema」+ 如何声明字段 volatile(目前只能读 `dsh-settings` / `cosmokit` / `schemastery` 源码逆向)。 +2. **ns 解析规则文档化**:settings ns 取的是 profile 条目 id 而非包名,这一条没有任何文档,是 `No configurable plugin entry` 的唯一原因。 +3. **volatile 语义边界**:volatile 字段的约束("fixed object path without an enclosing volatile field")需文档化——尤其 `z.dict` 节点标 volatile 后,dict 内任意 key 是否都可写(实测可以,但无文档背书)。 + +--- + +## 缺口 2:`SidebarSessionsService` 缺 `openSubagent(address)` 方法 + +### 现象 +- 子代理(subagent)拓扑图里,点击一个子代理行应切到子会话。 +- better-sidebar 原版的 `openChild` 调用 `sessions.openSubagent(address)`(`address` 含 `childSessionId` + `parentSessionId`)。 +- **DSH 0.2.0-rc.2 的 `SidebarSessionsService` 没有 `openSubagent` 方法**(`context-types.ts` 声明为可选 `openSubagent?`,但宿主 runtime 未实现/未暴露)。 +- 结果:点击子代理行无反应("点不进去")。 + +### 当前 workaround(插件侧) +- `openChild` 降级:`sessions.openSubagent(address)` 优先;宿主未暴露时回退 `sessions.open(address.childSessionId)`。 +- `sessions.open(id)` 语义是「选中一个已列会话为当前会话」,子会话在 `sessions.list` 里,选中即落地到子会话布局。 + +### 期望宿主提供的契约 +1. **实现并暴露 `openSubagent(address)`**:比 `sessions.open(childSessionId)` 更精确(带 parent 上下文,可区分同 id 不同 parent 的子会话)。 +2. **或明确文档**:若 `sessions.open(childSessionId)` 就是推荐路径,请在 `SidebarSessionsService` 的 JSDoc 注明,避免插件按 `openSubagent` 契约写代码后静默失效。 + +--- + +## 缺口 3:`subagentsByParent` 对 root 会话不 hydrate + +### 现象 +- 子代理 catalog(`subagentsByParent`)对 **root 会话不 hydrate**,`rootCatalog` 恒为 `undefined`。 +- 宿主日志有 warning:`[agent-registry] agent/disposed listener threw: TypeError Cannot read properties of undefined (reading 'catch')`,疑打断 hydrate 流程。 +- 结果:任务管理 / 子 Agent tab 的子代理全部显示「加载中…」,永不 hydrate。 + +### 当前 workaround(插件侧) +- 用 `sessions.list`(byId 镜像)+ `synthesizeCatalog(byId, parentId)` 按原版 `SidebarSubagentChildEntry` 契约**合成 catalog**(label 回退 `summary.displayTitle`,mode/activity/hasChildren 推断),递归补缺后再喂原版 `CatalogRows`。 +- 已生效(行可显示),但**合成数据缺少宿主实时 `activity`/`hasChildren`**,不如宿主 hydrate 精确。 + +### 期望宿主提供的契约 +1. **修复 `agent/disposed` listener 的 `catch` TypeError**:该异常疑打断 `subagentsByParent` 的 hydrate。 +2. **或提供降级数据源**:提供 `subagents.listByParent(rootId)` 返回 summary 级数据,让插件能显示「已知子代理 + 基础元数据」而非恒「加载中」。 + +--- + +## 缺口 4(真根因):插件 Config 必须用 `@deepseek-ai/schemastery`,否则 volatile 写入被 loader **静默丢弃** + +### 现象 +- 设置页的每个开关点击后:**不报错、无 toast、但状态立刻弹回**,即「开关都不能开启或关闭」,且开关**对运行时毫无作用**。 +- 服务端 `settings.update(ns, patch, revision)` **返回成功**,profile 文档 `cordis.patch.yml` **也确实被写入**(例如 `tabsEnabled.git: false` 落盘成功)。 +- 但读回(`settings.describe().value`)与插件自身 `config` **永远不变**,且插件**既不被重启、也不被就地更新**。 + +### 根因链(逐层定位,全部有宿主源码/运行时证据) +1. 宿主 `dsh-settings` 的 `write()` 落盘后,靠 `configEditor.edit()` → `reconcileProfilePatches()` → 最终回到 `cordis-plugin-loader` 的条目 `update()`。 +2. loader 判定 `volatileOnly`(本次改动只涉及 volatile 字段)后走 `_commitVolatile()`,其**第一行**是: + `const refs = volatileEntries(fiber.config); if (!refs.length) return true;` +3. `fiber.config` 里的 volatile 字段应当是 `createVolatile()` 产生的**引用对象**(形如 `Object.freeze({ get(), [Symbol.for('cosmokit.volatile.write')]() })`),而 `volatileEntries()` 只认这种引用。 +4. 这些引用由 **`schemastery` 的 `Schema.resolve()` 在遇到 `meta.volatile` 节点时创建**(`@deepseek-ai/schemastery` 的 `resolve()` 有该分支;公版 `schemastery` **没有**)。 +5. 宿主 `cordis` 的 `resolveConfig()` 走的是 **`runtime.Config["~standard"].validate()`** —— 也就是**插件自己那份 schemastery 的解析器**。 +6. 若插件声明的是 **npm 公版 `schemastery`(3.18.0,`volatile` 相关代码 0 处)**,则解析结果里**没有任何 volatile 引用** → `refs.length === 0` → `_commitVolatile()` 立即 `return true` → loader 视作「已处理」→ **既不就地更新也不重启** → 写入被彻底丢弃。 +7. 插件侧实测确认:修复前 `config.tabsEnabled` 的 `write` 符号为 `undefined`(`tabsIsVolatile=false`);改用宿主同源包后变为 `function`(`tabsIsVolatile=true`),同一套探针下 `revision` 由**恒为 1**变为**单调递增 0→6**,每次写入立即读回正确值。 + +### 当前 workaround(插件侧) +- `src/config.ts`:`import z from 'schemastery'` → `import z from '@deepseek-ai/schemastery'`。 +- `package.json`:`dependencies.schemastery` 移除;`peerDependencies` 加 `"@deepseek-ai/schemastery": "^3.18.1"`(宿主在 mount 时提供,与本 repo `pnpm-workspace.yaml` 的既有约定一致);`devDependencies` 钉 `3.18.4` 供开发树解析。 +- 已验证:`tabsIsVolatile=true`、`rev` 单调递增、写入即时读回、门禁全绿(typecheck/lint/build/test 131 files 1385 passed)。 + +### 期望宿主提供的契约 +1. **文档化**:「声明 volatile 字段的插件必须从 `@deepseek-ai/schemastery` 导入 schemastery」——这是强制要求(宿主的 volatile 能力是 schemastery 的 fork 特性),但当前无任何文档或校验。 +2. **启动期校验(强烈建议)**:loader 加载插件时,若插件 `Config` 含 `meta.volatile` 节点,但解析后 `fiber.config` 里**不存在任何 volatile 引用**,应**报错或 warn**。现状是「静默丢弃每一次用户设置写入」,排查成本极高(需读 4 个包的源码才能定位)。 +3. **或消除版本耦合**:让 `resolveConfig` 使用宿主自己的 schemastery 解析器,而不是插件携带的那一份,从根上避免此类版本错配。 + +--- + +## 汇总 + +| # | 缺口 | 严重度 | 当前 workaround | 期望宿主动作 | +|---|------|--------|----------------|-------------| +| 1 | settings 契约(ns = 条目 id、volatile 无文档) | 中(已绕过) | 偏好合并进 `Config` + `.extra('volatile',true)` + ns 对齐条目 id | 文档化 ns 解析规则与 volatile 声明 | +| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 实现 `openSubagent` 或文档化推荐路径 | +| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 | 修 TypeError 或提供降级数据源 | +| 4 | **插件 Config 未用 `@deepseek-ai/schemastery` → volatile 写入被静默丢弃** | **最高(设置全部失效且无报错)** | 改用宿主同源 schemastery 包 | 文档化强制要求 + 启动期校验 + 解耦解析器 | + +--- + +## 复现环境 +- DSH Desktop 2.0.17(Electron),宿主内置 `@deepseek-ai/dsh = 0.2.0-rc.2`、`@deepseek-ai/schemastery = 3.18.4` +- dsh-apes-sidebar feat/apes-tab-revival(+ 本反馈对应的改动) +- macOS 13.7.8,Node v22.22.0 +- 插件加载点:`~/.dsh/profiles/desktop/node_modules/dsh-apes-sidebar/lib/` +- 关键源码锚点:`@deepseek-ai/cordis/lib/index.js: resolveConfig()`(`~standard.validate`)、`@deepseek-ai/cordis-plugin-loader/lib/index.js: _commitVolatile()`、`@deepseek-ai/schemastery/lib/index.cjs: Schema.resolve()`、`@deepseek-ai/dsh-settings/lib/index.js: describe()/write()` diff --git a/package.json b/package.json index c4c2d8f..2d6a54e 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@deepseek-ai/dsh-settings": "^0.2.0-rc.2", "@deepseek-ai/dsh-subagent": "^0.2.0-rc.2", "@deepseek-ai/dsh-tools": "^0.2.0-rc.2", + "@deepseek-ai/schemastery": "^3.18.1", "@huanlin/dsh-plugin-better-locale": "^0.1.0", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -151,7 +152,6 @@ "node-pty": "^1.1.0", "react-icons": "5.7.0", "rxjs": "^7.8.2", - "schemastery": "^3.18.0", "ws": "^8.18.0" }, "devDependencies": { @@ -182,6 +182,7 @@ "@deepseek-ai/dsh-util-code-language": "0.2.0-rc.2", "@deepseek-ai/dsh-util-time": "0.2.0-rc.2", "@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "3.18.4", "@eslint/js": "^10.0.1", "@huanlin/dsh-plugin-better-locale": "^0.1.0", "@playwright/test": "^1.62.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4c9114..4e1b963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,9 +95,6 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 - schemastery: - specifier: ^3.18.0 - version: 3.18.0 ws: specifier: ^8.18.0 version: 8.21.3 @@ -183,6 +180,9 @@ importers: '@deepseek-ai/dsh-util-workspace-path': specifier: 0.2.0-rc.2 version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/schemastery': + specifier: 3.18.4 + version: 3.18.4 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0) @@ -3009,9 +3009,6 @@ packages: scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} - schemastery@3.18.0: - resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6306,11 +6303,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - schemastery@3.18.0: - dependencies: - '@standard-schema/spec': 1.1.0 - cosmokit: 1.8.1 - semver@6.3.1: {} semver@7.8.5: {} diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index c2e410c..47ac23f 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -74,7 +74,7 @@ const JOB_KILL_ARM_MS = 3000 * loading spinner — the session summaries are already in the mirror, so the * tree degrades to clickable summary rows instead. */ -const CATALOG_HYDRATE_TIMEOUT_MS = 5000 +const CATALOG_HYDRATE_TIMEOUT_MS = 2500 /** The direct subagent children of one parent (durable `origin` rows; * Side Chat threads ride the same origin but are tab-strip conversations, @@ -160,72 +160,57 @@ function CatalogLoadingRows(props: { } /** - * Degrade rows rendered when the root catalog never hydrates within - * {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the - * mirror (`byId`), so instead of pinning the page on a permanent "loading…" - * spinner we render the known children as CLICKABLE rows that jump to the - * child session. The address `mode` is inferred from the summary's `running` - * flag (a running agent is continuable; a settled one is one-shot) — a - * best-effort transport hint, since the durable catalog `mode` is unavailable - * once hydration has given up. + * Synthesize a durable-catalog-shaped view from the session summaries already + * in the mirror, used ONLY as a fallback once the host never hydrates the real + * per-parent catalog (the `summaryBackedLoading` + timeout degrade). Each + * entry is built to the `SidebarSubagentChildEntry` contract so the OFFICIAL + * {@link CatalogRows} renderer can consume it unchanged — live lines, mode + * labels, diagnostic rows, nested branches and arrow-key navigation all come + * from the stock component, not a bespoke row. `mode` is a best-effort + * transport hint inferred from the summary's `running` flag (a running agent + * is continuable; a settled one is one-shot); `label` is left empty so + * {@link childLabel} falls back to the summary title. */ -function SummaryFallbackRows(props: { - parentSessionId: string - byId: Readonly> - level: number - currentSessionId: string - openChild: (address: SidebarSubagentAddress) => void -}) { - const { parentSessionId, byId, level, currentSessionId, openChild } = props - const children = directChildren(byId, parentSessionId) - if (children.length === 0) { - return
{t('loading')}
- } - return ( - <> - {children.map(summary => { - const address: SidebarSubagentAddress = { - parentSessionId, - childSessionId: summary.id, - mode: summary.running === true ? 'continuable' : 'one-shot', - } - const current = summary.id === currentSessionId - const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id - const secondary = [ - summary.displayTitle, - summary.running === true ? t('subagentRunning') : t('subagentInactive'), - ].filter(Boolean).join(' · ') - return ( -
{ openChild(address) }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - event.stopPropagation() - openChild(address) - } - }} - > - - - {label} - {secondary} - -
- ) - })} - +function synthesizeCatalog( + byId: Readonly>, + parentSessionId: string, +): SidebarSubagentCatalog { + const entries: SidebarSubagentChildEntry[] = directChildren(byId, parentSessionId).map( + (summary): SidebarSubagentChildEntry => ({ + kind: 'child', + id: summary.id, + activity: summary.running === true ? 'running' : 'inactive', + hasChildren: directChildren(byId, summary.id).length > 0, + mode: summary.running === true ? 'continuable' : 'one-shot', + }), ) + return { entries, parentAvailable: true, state: 'ready', error: null } +} + +/** + * Layer summary-synthesized catalogs for the root and (recursively) every + * descendant onto the host's `catalogs` map, so the stock {@link CatalogRows} + * never meets an `undefined` child catalog during the degrade — each branch + * expands into its synthesized children instead of a permanent "loading…". + * Real host catalogs always win; synthesis only fills the gaps. + */ +function mergeSynthesizedCatalogs( + catalogs: Readonly>, + byId: Readonly>, + rootId: string, +): Readonly> { + const merged: Record = { ...catalogs } + const seen = new Set() + const visit = (parentSessionId: string): void => { + if (merged[parentSessionId] !== undefined || seen.has(parentSessionId)) return + seen.add(parentSessionId) + merged[parentSessionId] = synthesizeCatalog(byId, parentSessionId) + for (const entry of merged[parentSessionId].entries) { + if (entry.kind === 'child' && entry.hasChildren) visit(entry.id) + } + } + visit(rootId) + return merged } /** @@ -780,7 +765,26 @@ export function SubagentView(props: { // highlighted) — the README "page stays open" contract. onOpenChild?.(address) try { - sessions.openSubagent?.(address) + // Preferred: the host's exact direct-parent jump (keeps the child's + // own layout + the Tasks page re-open on top via `subagentJumpRef`). + // DSH 2.0.17 hosts that haven't exposed `sessions.openSubagent` fall + // through to a plain session open — the child still becomes the current + // session (so the jump-back highlight fires), the node is just reached + // without the host's topology-aware address. Without this fallback a + // missing `openSubagent` silently swallowed the click and the child + // was unreachable ("点不进去"). + if (typeof sessions.openSubagent === 'function') { + sessions.openSubagent(address) + return + } + // `sessions.open(id)` selects a listed session as current (mirror of + // the runtime ISessions.open — the same mechanism the topology root + // node uses to jump back to the main agent). The child subagent session + // IS in `sessions.list` (the byId mirror), so selecting it lands the + // user on the child's own layout and the jump-back highlight fires. + if (typeof sessions.open === 'function') { + sessions.open(address.childSessionId) + } } catch (error) { console.error('[dsh-apes-sidebar] openSubagent failed:', error) } @@ -937,15 +941,6 @@ export function SubagentView(props: { {summaryBackedLoading && !degraded && ( )} - {summaryBackedLoading && degraded && ( - - )} {!summaryBackedLoading && ( )} + {/* Degraded (host never hydrated the root catalog): feed the + session-mirror-synthesized catalog into the STOCK CatalogRows + so the full official interaction (live lines, mode labels, + nested branches, arrow-key nav) is reused — not a bespoke + row. The synthesized root is merged into `catalogs` so any + branch CatalogRows recurses into also has its own + summary-synthesized catalog instead of a permanent loader. */} + {summaryBackedLoading && degraded && rootId !== undefined && ( + + )}
)} {readyEmpty && ( diff --git a/src/client/apes/index.ts b/src/client/apes/index.ts index d740bcf..2d00b77 100644 --- a/src/client/apes/index.ts +++ b/src/client/apes/index.ts @@ -3,7 +3,7 @@ * upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages / * apps / profile). * - * History (C7): `apes:tasks` was removed under the old C6 decision (it looked + * History (C7): `apesTasks` was removed under the old C6 decision (it looked * redundant next to the official DSH session tree). The `v2-revive` line * restores it — the native tree is a *navigator* (one focused lineage), this * panel is a *list* (every session, with an idle filter and a running badge), diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx index d4f0a12..0bd31b5 100644 --- a/src/client/apes/tabs.tsx +++ b/src/client/apes/tabs.tsx @@ -5,18 +5,18 @@ * live outside `builtins/`, so importing them does not couple the apes module * to upstream UI and survives cherry-picks. * - * Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason + * Five information-layer tabs (C7 reversal: `apesTasks` is back). The reason * the earlier C6 removal no longer holds: the native DSH session tree renders * ONE focused lineage, while this panel is a *flat, whole-workspace* list with * a running badge and an idle filter — complementary, not redundant. - * - apes:tasks → every session (running / current / subagent marks) with + * - apesTasks → every session (running / current / subagent marks) with * an idle filter; a running count badges the tab. - * - apes:agents → the subagent topology rooted at the main agent of the + * - apesAgents → the subagent topology rooted at the main agent of the * current session (rootAncestor + per-parent catalogs). - * - apes:messages → honest placeholder: DSH exposes no message-stream feed. - * - apes:apps → no DSH host feed today: shows the workspace (cwd) of the + * - apesMessages → honest placeholder: DSH exposes no message-stream feed. + * - apesApps → no DSH host feed today: shows the workspace (cwd) of the * current session, otherwise an honest "no feed" note. - * - apes:profile → the current session's summary row (title, cwd, running). + * - apesProfile → the current session's summary row (title, cwd, running). * * Live-data discipline (C4): list-reading views subscribe only while the tab * is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel @@ -85,10 +85,10 @@ const S: Record = { taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' }, } -/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */ -export const TASKS_TAB_ID = 'apes:tasks' +/** apesTasks descriptor id (the plugin-toggle blob lives under this key). */ +export const TASKS_TAB_ID = 'apesTasks' -/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */ +/** The plugin-local switch persisted in `pluginSettings['apesTasks']`. */ export const TASKS_SHOW_INACTIVE_KEY = 'showInactive' /** A small colored status dot (self-contained, not the builtin StateDot). */ @@ -135,7 +135,7 @@ function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarS /** * The `showInactive` filter state. The declarative settings row writes it into - * the sidebar prefs document under `pluginSettings['apes:tasks']` (service + * the sidebar prefs document under `pluginSettings['apesTasks']` (service * contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of * the public `SidebarPrefs` type — hence the narrow local cast. Default is * true: the panel is a task list, so showing idle sessions is the useful @@ -153,7 +153,7 @@ export function tasksShowInactive(store: TabComponentProps['store']): boolean { } /** - * apes:tasks rows: every non-side-chat session, running ones first (then the + * apesTasks rows: every non-side-chat session, running ones first (then the * current one), idle ones only when `showInactive`. PURE — unit-tested. */ export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] { @@ -194,7 +194,7 @@ function agentChildren( ) } -/** apes:tasks — the flat, whole-workspace session list. */ +/** apesTasks — the flat, whole-workspace session list. */ export function ApesTasksView(props: TabComponentProps) { const list = useSessionList(props.ctx, props.visible) const store = props.store @@ -244,7 +244,7 @@ export function ApesTasksView(props: TabComponentProps) { ) } -/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */ +/** apesMessages — DSH exposes no message-stream feed; honest placeholder. */ export function ApesMessagesView(_props: TabComponentProps) { return (
@@ -254,7 +254,7 @@ export function ApesMessagesView(_props: TabComponentProps) { ) } -/** apes:agents — the subagent topology of the current session's tree. */ +/** apesAgents — the subagent topology of the current session's tree. */ function ApesAgentsView(props: TabComponentProps) { const list = useSessionList(props.ctx, props.visible) const { byId, subagentsByParent } = list @@ -322,7 +322,7 @@ function ApesAgentsView(props: TabComponentProps) { ) } -/** apes:profile — the current session's identity (title, cwd, running state). */ +/** apesProfile — the current session's identity (title, cwd, running state). */ export function ApesProfileView(props: TabComponentProps) { const list = useSessionList(props.ctx, props.visible) const current = list.current @@ -359,7 +359,7 @@ export function ApesProfileView(props: TabComponentProps) { ) } -/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */ +/** apesApps — no DSH host feed today; shows the current workspace (cwd). */ function ApesAppsView(props: TabComponentProps) { const list = useSessionList(props.ctx, props.visible) const current = list.current @@ -383,7 +383,7 @@ function ApesAppsView(props: TabComponentProps) { } /** - * The five apes:* tab descriptors (C7: `apes:tasks` restored — the native + * The five apes:* tab descriptors (C7: `apesTasks` restored — the native * session tree focuses one lineage, this list covers the whole workspace). * Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in * reading order: tasks / agents / apps / messages / profile. Each is already @@ -410,7 +410,7 @@ export const apesTabs: TabDescriptor[] = [ }, }, { - id: 'apes:agents', + id: 'apesAgents', title: () => t('apesAgentsTitle'), icon: (size: number) => , order: 60, @@ -421,7 +421,7 @@ export const apesTabs: TabDescriptor[] = [ return count > 0 ? count : null }, }, - { id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => , order: 65, single: true, component: ApesAppsView }, - { id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => , order: 67, single: true, component: ApesMessagesView }, - { id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => , order: 70, single: true, component: ApesProfileView }, + { id: 'apesApps', title: () => t('apesAppsTitle'), icon: (size: number) => , order: 65, single: true, component: ApesAppsView }, + { id: 'apesMessages', title: () => t('apesMessagesTitle'), icon: (size: number) => , order: 67, single: true, component: ApesMessagesView }, + { id: 'apesProfile', title: () => t('apesProfileTitle'), icon: (size: number) => , order: 70, single: true, component: ApesProfileView }, ] diff --git a/src/config.ts b/src/config.ts index 61372f6..dc5e88d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ * @module dsh-apes-sidebar/config */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, @@ -28,8 +28,20 @@ export { type SidebarPrefs, } from './prefs-shared.ts' -/** Tunable sidebar host limits (every field optional; defaults fill in). */ -export interface SidebarConfig { +/** + * Tunable sidebar host limits (every field optional; defaults fill in). + * + * Since 0.2.0-rc.2 the loader auto-registers the plugin's `Config` schema + * under ns = package name, and the settings service's `update()` only accepts + * patches to fields marked `volatile`. The user-facing side-card preferences + * (formerly the dead `PrefsSchema` export) are therefore MERGED into this + * schema with `extra('volatile', true)` so the settings routes can persist + * them. The deployment fields above (readLimit/shell/…) are deliberately + * NON-volatile: they are set from `cordis.patch.yml` / profile config, not + * from the user's settings page, and must not be writable through + * `settings.update`. + */ +export interface SidebarConfig extends Partial { /** Read cap of one text file (bytes); larger files return truncated. */ readLimit?: number /** Media route cap (bytes); larger binaries are refused. */ @@ -60,8 +72,9 @@ export interface SidebarConfig { shellArgs?: string[] } -/** Schemastery schema for the plugin configuration. */ +/** Schemastery schema for the plugin configuration (deployment + user prefs). */ export const Config: z = z.object({ + // ── Deployment fields (NON-volatile; set from cordis.patch.yml / profile) ── readLimit: z.number().step(1).min(1).default(512 * 1024), mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024), uploadLimit: z.number().step(1).min(1).default(128 * 1024 * 1024), @@ -70,6 +83,47 @@ export const Config: z = z.object({ reconnectGraceMs: z.number().step(1).min(0).default(30_000), shell: z.string().default(''), shellArgs: z.array(z.string()).default([]), + // ── User-facing side-card preferences (volatile; writable via settings.update) ── + // 0.2.0-rc.2: the settings service's `update()` only accepts patches to fields + // whose schema node (or an ancestor) is marked `volatile`. Every user-editable + // preference below is tagged `extra('volatile', true)` so the /sidebar/settings + // routes can persist them. These were formerly the dead `PrefsSchema` export; + // merging them into `Config` (the schema the loader actually registers) is what + // makes the settings page switches stick instead of 400 → optimistic rollback. + autoOpenSubagent: z.boolean().default(true).extra('volatile', true), + autoOpenJobs: z.boolean().default(true).extra('volatile', true), + agentTerminalTools: z.boolean().default(false).extra('volatile', true), + agentOpenTools: z.boolean().default(false).extra('volatile', true), + bottomPanelAutoTerminal: z.boolean().default(true).extra('volatile', true), + terminalFontFamily: z.string().default('').extra('volatile', true), + terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT).extra('volatile', true), + editorExplorer: z.boolean().default(false).extra('volatile', true), + workspaceFence: z.boolean().default(true).extra('volatile', true), + terminalShell: z.string().default('').extra('volatile', true), + terminalShellArgs: z.string().default('').extra('volatile', true), + titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]).extra('volatile', true), + titleBarPresetId: z.string().extra('volatile', true), + customCss: z.string().extra('volatile', true), + titleBarCompat: z.boolean().default(false).extra('volatile', true), + titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT).extra('volatile', true), + htmlViewerNoSandbox: z.boolean().default(false).extra('volatile', true), + htmlViewerDefaultUnsafe: z.boolean().default(false).extra('volatile', true), + browserNoSandbox: z.boolean().default(false).extra('volatile', true), + browserInterceptLinks: z.boolean().default(true).extra('volatile', true), + browserInterceptHttp: z.boolean().default(true).extra('volatile', true), + browserInterceptHttps: z.boolean().default(false).extra('volatile', true), + browserAllowedLoopback: z.string().default('').extra('volatile', true), + // Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or + // external): an absent key means enabled, so old documents resolve to {} + // (everything on) with no migration. Non-boolean values fail validation. + // The whole dict node is volatile, so any key beneath it is writable. + tabsEnabled: z.dict(z.boolean()).default({}).extra('volatile', true), + viewersEnabled: z.dict(z.boolean()).default({}).extra('volatile', true), + // Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any + // descriptor id may carry any JSON-serializable values. This is the + // "settings seam" opening — without it the seam would drop third-party + // keys as unknown schema fields. + pluginSettings: z.dict(z.dict(z.any())).default({}).extra('volatile', true), }) /** Fully defaulted sidebar host settings. */ diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index ef10b15..cab91c4 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -6,8 +6,11 @@ * the browser bundle never pulls the schema runtime in. */ -/** The user-settings namespace holding the side card preferences. */ -export const SIDEBAR_PREFS_NS = 'dsh-apes-sidebar' +/** The user-settings namespace holding the side card preferences. + * 0.2.0-rc.2: loader registers the ns from `settings.section` id (not + * package.json name), which is 'better-sidebar' (see client/index.tsx:482). + * Must match or settings.update throws "No configurable plugin entry". */ +export const SIDEBAR_PREFS_NS = 'better-sidebar' /** User-facing side card preferences. */ export interface SidebarPrefs { diff --git a/tests/apes-tabs.spec.ts b/tests/apes-tabs.spec.ts index b1c32cd..0f983f8 100644 --- a/tests/apes-tabs.spec.ts +++ b/tests/apes-tabs.spec.ts @@ -2,7 +2,7 @@ * apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers * its information-layer tabs (tasks / agents / apps / messages / profile) * through the same BetterSidebarService the upstream builtins use (dogfood). - * `apes:tasks` is back (C7 reversal of C6): the native DSH session tree focuses + * `apesTasks` is back (C7 reversal of C6): the native DSH session tree focuses * ONE lineage, while this panel is a flat, whole-workspace list with a running * badge and an idle filter — the overlap is complementary. * @@ -38,7 +38,7 @@ import { tasksShowInactive, } from '../src/client/apes/tabs.tsx' -const APES_TAB_IDS = ['apes:agents', 'apes:apps', 'apes:messages', 'apes:profile', 'apes:tasks'] +const APES_TAB_IDS = ['apesAgents', 'apesApps', 'apesMessages', 'apesProfile', 'apesTasks'] function setup(): { service: ReturnType @@ -119,10 +119,10 @@ describe('apes:* tab registrations', () => { try { const orders = { tasks: service.getTab(TASKS_TAB_ID)?.order, - agents: service.getTab('apes:agents')?.order, - apps: service.getTab('apes:apps')?.order, - messages: service.getTab('apes:messages')?.order, - profile: service.getTab('apes:profile')?.order, + agents: service.getTab('apesAgents')?.order, + apps: service.getTab('apesApps')?.order, + messages: service.getTab('apesMessages')?.order, + profile: service.getTab('apesProfile')?.order, } // C8: order 40-50 places apes tabs immediately after the native builtins // (10-35), not in a far-away 900+ band — visual unity in the tab strip. @@ -149,7 +149,7 @@ describe('apes:* tab registrations', () => { }) }) -describe('apes:tasks plugin toggles', () => { +describe('apesTasks plugin toggles', () => { it('declares the showInactive switch under the tasks descriptor', () => { const descriptor = apesTabs.find(d => d.id === TASKS_TAB_ID) const toggles = descriptor?.settings?.pluginToggles ?? [] @@ -166,7 +166,7 @@ describe('apes:tasks plugin toggles', () => { expect(tasksShowInactive(store)).toBe(true) }) - it('reads the boolean from pluginSettings[apes:tasks]', () => { + it('reads the boolean from pluginSettings[apesTasks]', () => { const store = { getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }), } as unknown as TabComponentProps['store'] @@ -183,7 +183,7 @@ describe('apes:tasks plugin toggles', () => { }) }) -describe('apes:tasks row selection (PURE)', () => { +describe('apesTasks row selection (PURE)', () => { it('ranks current first, then running, then idle', () => { const list = snap('idle-b', [ row('idle-a'), @@ -219,7 +219,7 @@ describe('apes:tasks row selection (PURE)', () => { }) }) -describe('apes:agents badge + topology count', () => { +describe('apesAgents badge + topology count', () => { it('counts the subagents of the current session tree only', () => { const list = snap('main', [ row('main'), @@ -251,12 +251,12 @@ describe('apes badge contract', () => { }) it('badges the agents tab with the subagent count, null when childless', () => { - expect(badgeOf('apes:agents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1) - expect(badgeOf('apes:agents', snap('main', [row('main')]), 'main')).toBeNull() + expect(badgeOf('apesAgents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1) + expect(badgeOf('apesAgents', snap('main', [row('main')]), 'main')).toBeNull() }) it('never badges the placeholder/static tabs', () => { - for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) { + for (const id of ['apesApps', 'apesMessages', 'apesProfile']) { expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined() } }) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 446917e..877ecd0 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -53,7 +53,7 @@ describe('dsh-apes-sidebar plugin export shape', () => { it('registers the side card preferences schema with the documented defaults', async () => { const { PrefsSchema, SIDEBAR_PREFS_NS, SIDEBAR_PREFS_DEFAULTS } = await import('../src/config.ts') - expect(SIDEBAR_PREFS_NS).toBe('dsh-apes-sidebar') + expect(SIDEBAR_PREFS_NS).toBe('better-sidebar') const resolved = (PrefsSchema as unknown as { (input: Record | undefined): Record })(undefined) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 4760417..22cb61d 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -11,7 +11,7 @@ import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts' import { SidebarError } from '../src/wire.ts' -import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' +import { SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_NS } from '../src/prefs-shared.ts' import { encodeHtmlUrl } from '../src/html-route.ts' import * as git from '../src/git.ts' import { listDirectory } from '../src/fs-tree.ts' @@ -839,11 +839,11 @@ describe('side card settings routes', () => { // 0.2.0-rc.2: the plugin's Config schema is auto-registered by the loader. // The mock pre-seeds the sidebar namespace so describe() returns it even // when the plugin no longer calls register() explicitly. - if (!pre || !('dsh-apes-sidebar' in pre)) { + if (!pre || !(SIDEBAR_PREFS_NS in pre)) { // Use SIDEBAR_PREFS_DEFAULTS as the resolved value for the sidebar ns: // the real SettingsForms service applies the Config schema defaults // when no stored value exists, which is exactly what this constant is. - namespaces.set('dsh-apes-sidebar', { schema: (input: unknown) => input ?? SIDEBAR_PREFS_DEFAULTS, value: undefined, revision: 0 }) + namespaces.set(SIDEBAR_PREFS_NS, { schema: (input: unknown) => input ?? SIDEBAR_PREFS_DEFAULTS, value: undefined, revision: 0 }) } for (const [ns, value] of Object.entries(pre ?? {})) { namespaces.set(ns, { schema: (input: unknown) => input ?? {}, value, revision: 0 }) @@ -979,7 +979,7 @@ describe('side card settings routes', () => { it('shell.get reflects the settings-page override with the quotes stripped', async () => { const route = mountWithSettings(createFakeSettings({ - 'dsh-apes-sidebar': { + [SIDEBAR_PREFS_NS]: { terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"', terminalShellArgs: '-NoLogo', }, @@ -1184,7 +1184,7 @@ describe('agent terminal tool gating', () => { const handlerRef: { current: ((ns: string) => void) | null } = { current: null } const settings = { describe: () => [{ - ns: 'dsh-apes-sidebar', + ns: SIDEBAR_PREFS_NS, value: { ...SIDEBAR_PREFS_DEFAULTS, agentTerminalTools: enabled }, applies: 'live' as const, revision: 0, @@ -1215,18 +1215,18 @@ describe('agent terminal tool gating', () => { expect(live()).toBe(0) // Flipping the setting on registers all eight tools. enabled = true - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(8) expect(disposed).toBe(0) // Flipping it back off unregisters them (and releases any agent terminals). enabled = false - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(0) expect(disposed).toBe(8) // And a redundant toggle registers them fresh (no double-registration per // flip: the guard only skips when the tools are already live). enabled = true - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(8) expect(registered).toBe(16) }) @@ -1241,7 +1241,7 @@ describe('agent sidebar-open tool gating', () => { const handlerRef: { current: ((ns: string) => void) | null } = { current: null } const settings = { describe: () => [{ - ns: 'dsh-apes-sidebar', + ns: SIDEBAR_PREFS_NS, value: { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: enabled }, applies: 'live' as const, revision: 0, @@ -1272,17 +1272,17 @@ describe('agent sidebar-open tool gating', () => { expect(live()).toBe(0) // Flipping the setting on registers the single sidebar_open tool. enabled = true - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(1) expect(disposed).toBe(0) // Flipping it back off unregisters it (and drains the undelivered queue). enabled = false - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(0) expect(disposed).toBe(1) // And a redundant toggle registers it fresh (no double-registration). enabled = true - handlerRef.current?.('dsh-apes-sidebar') + handlerRef.current?.(SIDEBAR_PREFS_NS) expect(live()).toBe(1) expect(registered).toBe(2) }) -- 2.54.0 From 35d33c7f653ec5f3416a33979029e4fd878fd9ab Mon Sep 17 00:00:00 2001 From: figmar Date: Tue, 13 Oct 2026 23:18:30 +0800 Subject: [PATCH 5/7] fix(subagent): hydrate the catalog via the host's refreshProjections 0.2.0-rc.2 withdrew every catalog/subagent seam this fork was written against, and all three calls degraded to silent no-ops: openSubagent(address) -> gone setSubagentCatalogOpen(id, open) -> gone refreshSubagents(id) -> gone The host's own subagent UI (dsh-client-ui-subagent) rebuilds a parent's child catalog with `sessions.refreshProjections(parentSessionId)`, so that is the only kick that still exists. Wire it into `observe()` and the catalog retry button, keeping the legacy names as fallbacks for older hosts. Without it the root catalog never hydrated: the page sat on "loading" or degraded to synthesized summary rows. Also document what the investigation settled: - docs/subagent-state-enumeration.md: the full state model (catalog child / diagnostic / session / job / sidechat) and proof that this fork already renders it 1:1 with upstream. - docs/plugin-conventions.md: the hard rules another sidebar plugin must follow (host schemastery, volatile prefs, ns = profile entry id, no colons in tab ids, never read a volatile field directly) plus the withdrawn-API replacement table and a pre-release checklist. - docs/feedback-0.2.0-rc2-contract-gaps.md: gaps 2/3 reframed. The ask is documenting the official paths (open(childSessionId) / refreshProjections(id)), not adding new APIs. Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed. --- docs/feedback-0.2.0-rc2-contract-gaps.md | 26 ++++++++- docs/plugin-conventions.md | 70 +++++++++++++++++++++++ docs/subagent-state-enumeration.md | 72 ++++++++++++++++++++++++ src/client/SubagentView.tsx | 10 +++- src/context-types.ts | 19 +++++-- 5 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 docs/plugin-conventions.md create mode 100644 docs/subagent-state-enumeration.md diff --git a/docs/feedback-0.2.0-rc2-contract-gaps.md b/docs/feedback-0.2.0-rc2-contract-gaps.md index 0b2ee74..61dc9b5 100644 --- a/docs/feedback-0.2.0-rc2-contract-gaps.md +++ b/docs/feedback-0.2.0-rc2-contract-gaps.md @@ -100,12 +100,34 @@ dsh-apes-sidebar(better-sidebar 的 fork)在适配 DSH 0.2.0-rc.2 时,遇 | # | 缺口 | 严重度 | 当前 workaround | 期望宿主动作 | |---|------|--------|----------------|-------------| | 1 | settings 契约(ns = 条目 id、volatile 无文档) | 中(已绕过) | 偏好合并进 `Config` + `.extra('volatile',true)` + ns 对齐条目 id | 文档化 ns 解析规则与 volatile 声明 | -| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 实现 `openSubagent` 或文档化推荐路径 | -| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 | 修 TypeError 或提供降级数据源 | +| 2 | 缺 `openSubagent(address)` | 高(影响核心交互) | 降级 `sessions.open(childSessionId)` | 文档化推荐路径(官方路径见下方补充) | +| 3 | `subagentsByParent` root 不 hydrate + `catch` TypeError | 高(恒「加载中」) | 合成 catalog 绕过 + 改用 `sessions.refreshProjections` | 修 TypeError 或文档化 `refreshProjections` | | 4 | **插件 Config 未用 `@deepseek-ai/schemastery` → volatile 写入被静默丢弃** | **最高(设置全部失效且无报错)** | 改用宿主同源 schemastery 包 | 文档化强制要求 + 启动期校验 + 解耦解析器 | --- +## 补充(2026-10-13 晚,从宿主自身代码学到的官方路径) + +在上面三个替代 API 都 0 命中的同时,宿主**自己的**子代理 UI(`@deepseek-ai/dsh-client-ui-subagent/lib/client.js:945-1005`)给出了官方正确姿势: + +```js +const catalogActions = (_parentSessionId) => ({ + openChild(address) { ctx.uiWorkspace.openSession(address) }, // 传 address(多态 target) + openChildAside(address) { ctx.sidebarRight.openResource(subagentChatAddress(address), + { kind: 'subagentchat', preferNewPane: true }) }, + refreshProjection(id) { ctx.sessions.refreshProjections(id) }, // ← catalog 唯一 kick +}) +``` + +链路:`uiWorkspace.openSession(target)` → `replaceMain(target)` → `sessions.retain(target)`,**target 接受 SessionId 或 SubagentAddress**,且**没有子会话守卫**。 + +⇒ **缺口 2/3 的正确诉求不是「补 API」,而是「文档化这两条官方路径」**: +① 切到子会话 = `sessions.open(childSessionId)`(等价于官方的 `openSession(target)`); +② 让宿主 hydrate 某个 parent 的 catalog = `sessions.refreshProjections(parentId)`(**唯一**的 kick,旧名 `refreshSubagents`/`setSubagentCatalogOpen` 已删除且不报错)。 +另外官方还有一条我们**未接入**的能力:把子会话聊天作为原生右侧栏资源打开(`sidebarRight.openResource(..., {kind:'subagentchat'})`),建议一并文档化。 + +--- + ## 复现环境 - DSH Desktop 2.0.17(Electron),宿主内置 `@deepseek-ai/dsh = 0.2.0-rc.2`、`@deepseek-ai/schemastery = 3.18.4` - dsh-apes-sidebar feat/apes-tab-revival(+ 本反馈对应的改动) diff --git a/docs/plugin-conventions.md b/docs/plugin-conventions.md new file mode 100644 index 0000000..3d06555 --- /dev/null +++ b/docs/plugin-conventions.md @@ -0,0 +1,70 @@ +# 侧边栏插件操作规范(给其他插件的 Agent) + +> 版本基线:DSH Desktop 2.0.17 / 宿主 `@deepseek-ai/*` 0.2.0-rc.2 +> 用途:任何往 DSH 右侧栏挂 tab 的插件都按这份规范做,避免重复踩坑(每条都有实测根因) + +## 一、硬规则(违反 = 静默失效,不报错) + +### 1. `Config` 必须用宿主的 schemastery ⭐ 头号坑 +```ts +import z from '@deepseek-ai/schemastery' // ✅ 宿主 fork,含 volatile 支持 +import z from 'schemastery' // ❌ 公版 3.18.0,无 volatile +``` +- 后果:`settings.update` **写进 `cordis.patch.yml` 成功**,但宿主 loader 静默 no-op —— 不生效、不报错、不重启、值冻结。 +- 机制:宿主 `cordis.resolveConfig` 走 `runtime.Config['~standard'].validate()`,用的是**插件自己那份 schemastery** 的解析器;无 volatile 分支 ⇒ 没有 volatile 引用 ⇒ `cordis-plugin-loader._commitVolatile()` 首行 `if (!volatileEntries(fiber.config).length) return true` ⇒ 视作已提交。 +- `package.json`:`peerDependencies` 加 `"@deepseek-ai/schemastery": "^3.18.1"`(宿主 mount 时提供),`devDependencies` 钉 `3.18.4`。 + +### 2. 用户偏好字段必须标 volatile,且并入 `Config` +```ts +tabsEnabled: z.dict(z.boolean()).default({}).extra('volatile', true) +``` +- 0.2.0+ 移除了 `settings.register()`,**loader 只注册插件导出的 `Config`**;独立 `PrefsSchema` 是死代码。 +- `settings.update` **只放行 volatile 字段**(非 volatile 路径抛 `Config field "" is not volatile`)。 +- **部署字段**(limit / shell 之类)保持**非 volatile**——它们不该被设置页改写。 + +### 3. settings ns = **profile 条目 id**,不是包名 +`cordis.patch.yml` 里 `- id: ` 的那个 id。用包名会在 `settings.describe()` 里找不到自己(报 `No configurable plugin entry "<包名>"`)。 + +### 4. tab id / map key **不要用冒号** +`apesTasks` ✅ / `apes:tasks` ❌。冒号 key 在设置投影链路上出现过丢失。 +(i18n key 不受影响,本来就是 camelCase。) + +### 5. volatile 字段**不能直读** +修复后 `config.tabsEnabled` 是 `createVolatile()` 产出的**不透明引用**: +```js +Object.freeze({ get: () => current, [Symbol.for('cosmokit.volatile.write')]: v => { current = v } }) +``` +⇒ 没有数据属性,`config.tabsEnabled.git` 是 `undefined`(不是旧值),`JSON.stringify` 出 `{}`。 +**必须走 `settings.get()` / `settings.describe()`**(宿主对 ref 调 `plainConfig()` 解包)。 + +## 二、0.2.0-rc.2 消失的宿主 API 与官方替代 + +| 老调用(会静默 no-op) | 官方替代(宿主自己的 UI 就是这么调的) | +|---|---| +| `sessions.openSubagent(address)` | `sessions.open(childSessionId)` | +| `sessions.setSubagentCatalogOpen(id, open)` | `sessions.refreshProjections(id)` | +| `sessions.refreshSubagents(id)` | `sessions.refreshProjections(id)` | + +判定方法(别猜):在 app 内全量 grep 宿主包—— +```bash +A="/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai" +grep -rn "" "$A"/*/lib/*.js | head +# 0 命中 = 宿主没实现;再看官方自己的插件怎么调的(如下): +sed -n '945,1005p' "$A/dsh-client-ui-subagent/lib/client.js" +``` +`dsh-client-ui-subagent` 是**宿主自己的子代理 UI**,是找「官方正确姿势」的最权威样板。 + +## 三、注册/生命周期 + +- 服务注册必须等服务、不能等槽声明:`ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(槽声明早于服务 provide,按槽注册会静默什么都不注册且不重试);槽只用来挂 tab 体。 +- 原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**,native 适配层要给每个 tab 体包 `height:100%` 的列 flex 宿主。 +- 跨会话操作原生栏用 `openTabIn/openResourceIn/closeIn`(不在 `ISidebarRight` 接口里,需结构化探测)。 + +## 四、自检清单(发版前逐条过) + +1. `pnpm typecheck && pnpm lint && pnpm build && pnpm test` 全绿(构建 ≠ typecheck,tsdown 跳过 tsc 严格性)。 +2. `grep '^import z' lib/index.js` → 必须是 `@deepseek-ai/schemastery`。 +3. `grep -c 'schemastery' package.json` → `dependencies` 里**没有**公版 schemastery。 +4. 设置开关实测:点两次,值**不再弹回**,且 `cordis.patch.yml` 里的新值等于 UI 显示值。 +5. 改完配置**不需要重启 DSH**(volatile 就地生效);若需要重启才生效 = 第 1 条没做对。 +6. 产物 rsync 到 `~/.dsh/profiles//node_modules//lib/` 后**逐文件 md5 比对**(tsdown 输出非确定性,字节数相同≠内容相同)。 diff --git a/docs/subagent-state-enumeration.md b/docs/subagent-state-enumeration.md new file mode 100644 index 0000000..355678b --- /dev/null +++ b/docs/subagent-state-enumeration.md @@ -0,0 +1,72 @@ +# 子 Agent 完整状态枚举(对齐 better-sidebar 原版) + +> 口径来源:better-sidebar 原版 `src/client/SubagentView.tsx` + `src/subagent-activity.ts`(本 fork 完整保留) +> 宿主事实来源:`@deepseek-ai/*` 0.2.0-rc.2 真实包内代码 +> 结论:**本插件的状态渲染已 100% 对齐原版**;枚举上限由宿主字段决定,不是插件缺口。 + +## 一、三个状态域 + 一个诊断分支 + +### 域 1:子代理 catalog 行(拓扑卡片)— 主战场 + +数据源:`sessions.list.getSnapshot().subagentsByParent[parentId].entries[]`,其中 `kind: 'child'`。 + +| 字段 | 取值 | 渲染 | +|---|---|---| +| `activity` | `'running'` \| `'inactive'` | `StateDot state='ongoing'` \| `state='done'` | +| `mode` | `'one-shot'` \| `'continuable'` | 副行文本(一次性 / 可续) | +| `hasChildren` | `boolean` | 是否可展开(真叶子不给 `aria-expanded`) | +| `label?` | `string` | 主标签;缺省回退 `summary.displayTitle`,再回退 `entry.id` | +| `id` | `string` | 行标识 + address 组装 | + +副行格式(原版 `cardSecondary`):`displayTitle · mode · activity`,空段跳过。 + +**running 行额外显示实时活动**:`subagents.live` 批量路由 → `LastActivity { text?, tool?{name,args} }` +(服务端 `lastActivity()` 扫会话事件日志尾部的最后一条 assistant 文本 + 最后一次工具调用)。 + +### 域 2:catalog 行不可读 → 诊断分支 + +`kind: 'diagnostic'`(损坏 / 不受支持 / 不可用)→ `StateDot state='error'`。 +容器级状态另有 `SidebarSubagentCatalog.state: 'loading' | 'ready' | 'error'`,驱动空态行与重试按钮。 + +### 域 3:会话行(非拓扑列表) + +`SidebarSessionSummary.running?: boolean` → `StateDot state={running ? 'ongoing' : 'done'}`。 +**注意:宿主对会话只给这一个布尔**,没有更细的状态。 + +### 域 4:后台 job 与 SideChat + +| 面 | 字段 | 取值 → dot | +|---|---|---| +| 后台 job | `job.status` | `running`/`stopping`/`completed`/`killed`/`failed` → `jobDotState()` | +| SideChat 行 | 行级 `failed` / `running` | `error` / `ongoing` | + +## 二、StateDot 的可用状态(宿主固定,插件改不了) + +`StateDot` 来自 `@deepseek-ai/dsh-client-ui-primitives`,**只有 3 个 state**: +`'ongoing'`(活动)/ `'done'`(静止)/ `'error'`(失败)。 + +⇒ 插件能把 N 态数据映射到 3 个视觉态,仅此而已。**要更细的视觉必须宿主开口子**。 + +## 三、原版 vs 本 fork 对照(结论:已对齐) + +| 面 | 原版 | 本 fork | +|---|---|---| +| activity → dot | `running ? 'ongoing' : 'done'` | **同** | +| diagnostic → dot | `'error'` | **同** | +| 副行 mode·activity | `cardSecondary` | **同** | +| running 实时行 | `LastActivity` | **同** | +| 会话行 dot | `running ? 'ongoing' : 'done'` | **同** | +| job dot | `jobDotState(status)` | **同** | +| SideChat 失败行 | `'error'` | **同** | + +## 四、宿主侧真实缺口(不是枚举本身,而是枚举的「上游」) + +0.2.0-rc.2 里原版依赖的**三个 API 全部消失**(app 内 `@deepseek-ai/*` 全量 grep 0 命中): + +| 原版调用 | 0.2.0-rc.2 状态 | 官方替代 | +|---|---|---| +| `sessions.openSubagent(address)` | **不存在** | `sessions.open(childSessionId)`(切主面板;最终走 `replaceMain → retain`) | +| `sessions.setSubagentCatalogOpen(id, open)` | **不存在** | `sessions.refreshProjections(id)` | +| `sessions.refreshSubagents(id)` | **不存在** | 同上 | + +⇒ catalog 的**来源**(宿主 hydrate)曾长期缺失,这才是「恒加载中」与「点不进去」的真根因;枚举与渲染层面本插件无缺口。 diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 47ac23f..de3b8db 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -717,6 +717,11 @@ export function SubagentView(props: { const observedRef = useRef(new Set()) const observe = useCallback((parentSessionId: string, open: boolean): void => { + // Ask the host to (re)build this parent's child catalog. The projection is + // lazy and 0.2.0-rc.2 exposes ONLY this kick — the old subscription + // (`setSubagentCatalogOpen`) is gone, so a consumer that called just that + // ended up with an un-hydrated root stuck on "loading" forever. + if (open) void sessions.refreshProjections?.(parentSessionId) sessions.setSubagentCatalogOpen?.(parentSessionId, open) if (open) observedRef.current.add(parentSessionId) else observedRef.current.delete(parentSessionId) @@ -801,7 +806,10 @@ export function SubagentView(props: { }, [sessions, rootId]) const refresh = useCallback((parentSessionId: string): void => { - void sessions.refreshSubagents?.(parentSessionId) + // 0.2.0-rc.2 renamed this kick to `refreshProjections` (it is what the + // host's own subagent UI calls); preferring the old name made the catalog + // retry button a silent no-op. + void (sessions.refreshProjections ?? sessions.refreshSubagents)?.(parentSessionId) }, [sessions]) const totals = useMemo( diff --git a/src/context-types.ts b/src/context-types.ts index 5e261d1..b8caed4 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -375,8 +375,9 @@ export interface SidebarSessionsService { */ scope(id: string): Context | undefined /** - * Open a healthy catalog child through its exact direct-parent address - * (mirror of the runtime ISessions.openSubagent). + * Open a healthy catalog child through its exact direct-parent address. + * Absent on 0.2.0-rc.2 (the runtime seam was withdrawn) — callers fall back + * to `open(childSessionId)`. */ openSubagent?(address: SidebarSubagentAddress): void /** @@ -384,12 +385,18 @@ export interface SidebarSessionsService { */ subagentAddress?(id: string): SidebarSubagentAddress | undefined /** - * Mark whether a catalog surface is consuming live membership updates. + * Rebuild one session's lazy projections — including its subagent child + * catalog (`subagentsByParent`). This is the ONLY catalog kick 0.2.0-rc.2 + * exposes; the host's own subagent UI calls exactly this + * (`ctx.sessions.refreshProjections(parentSessionId)`). Without it the + * catalog never hydrates and the root stays on "loading" forever. + */ + refreshProjections?(sessionId: string): Promise + /** + * Legacy catalog surfaces from pre-0.2.0 hosts. Absent on 0.2.0-rc.2 — + * calling them is a silent no-op, kept only so older hosts keep working. */ setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void - /** - * Refresh one direct-child catalog. - */ refreshSubagents?(parentSessionId: string): Promise } -- 2.54.0 From 6b4a29ba24eec8ebde3b957085fe2831695b14fe Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 14 Oct 2026 09:32:16 +0800 Subject: [PATCH 6/7] feat(subagent): open a child's chat in the native right sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host's own catalog puts an "open beside" action on every non-current child row; this port had never wired it. Add it: the row grows a hover button that opens the child's chat as a `subagentchat` resource in the native right Sidebar, so a user can watch a child work without giving up the pane he is in (the in-pane jump stays the plain row click). - src/client/native/subagent-chat.ts: the resource-address codec, copied from @deepseek-ai/dsh-client-ui-subagent rather than imported (the package exports no helpers and the plugin must not reach into it). - native/surface.ts: carry `kind` / `preferNewPane` on an open. - SubagentView: `openChildAside` via `ctx.get?.('sidebarRight')` — an optional probe, since a fake ctx without `get` otherwise crashes the whole render (9 specs caught it) — threaded through all three CatalogRows call sites. - locales: `subagentOpenAside` in zh/en plus all 20 third-language dictionaries (tests/locales.spec.ts enforces key-set parity). - docs: the conventions page now documents the resource-open recipe. Gates: typecheck / lint / build / test 132 files 1390 passed / check:consumer-types, plus the repo's runtime-contract symbol check. --- docs/feedback-0.2.0-rc2-contract-gaps.md | 2 +- docs/plugin-conventions.md | 17 ++++++++ src/client/SubagentView.module.css | 27 ++++++++++++ src/client/SubagentView.tsx | 55 +++++++++++++++++++++++- src/client/locales-ar.ts | 1 + src/client/locales-de.ts | 1 + src/client/locales-fr.ts | 1 + src/client/locales-hi.ts | 1 + src/client/locales-id.ts | 1 + src/client/locales-it.ts | 1 + src/client/locales-ja.ts | 1 + src/client/locales-ko.ts | 1 + src/client/locales-nl.ts | 1 + src/client/locales-pl.ts | 1 + src/client/locales-pt.ts | 1 + src/client/locales-ru.ts | 1 + src/client/locales-sv.ts | 1 + src/client/locales-th.ts | 1 + src/client/locales-tr.ts | 1 + src/client/locales-vi.ts | 1 + src/client/locales-zh-HK.ts | 1 + src/client/locales-zh-MO.ts | 1 + src/client/locales-zh-TW.ts | 1 + src/client/locales.ts | 2 + src/client/native/subagent-chat.ts | 32 ++++++++++++++ src/client/native/surface.ts | 21 +++++++-- tests/subagent-chat-aside.spec.ts | 52 ++++++++++++++++++++++ 27 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 src/client/native/subagent-chat.ts create mode 100644 tests/subagent-chat-aside.spec.ts diff --git a/docs/feedback-0.2.0-rc2-contract-gaps.md b/docs/feedback-0.2.0-rc2-contract-gaps.md index 61dc9b5..1ddbe2f 100644 --- a/docs/feedback-0.2.0-rc2-contract-gaps.md +++ b/docs/feedback-0.2.0-rc2-contract-gaps.md @@ -124,7 +124,7 @@ const catalogActions = (_parentSessionId) => ({ ⇒ **缺口 2/3 的正确诉求不是「补 API」,而是「文档化这两条官方路径」**: ① 切到子会话 = `sessions.open(childSessionId)`(等价于官方的 `openSession(target)`); ② 让宿主 hydrate 某个 parent 的 catalog = `sessions.refreshProjections(parentId)`(**唯一**的 kick,旧名 `refreshSubagents`/`setSubagentCatalogOpen` 已删除且不报错)。 -另外官方还有一条我们**未接入**的能力:把子会话聊天作为原生右侧栏资源打开(`sidebarRight.openResource(..., {kind:'subagentchat'})`),建议一并文档化。 +另外官方还有一条能力:把子会话聊天作为原生右侧栏资源打开(`sidebarRight.openResource(..., {kind:'subagentchat', preferNewPane:true})`)——**本插件已按官方形式接入**(子代理行上的「在侧栏打开」按钮),同样建议文档化。 --- diff --git a/docs/plugin-conventions.md b/docs/plugin-conventions.md index 3d06555..1759eb3 100644 --- a/docs/plugin-conventions.md +++ b/docs/plugin-conventions.md @@ -54,6 +54,23 @@ sed -n '945,1005p' "$A/dsh-client-ui-subagent/lib/client.js" ``` `dsh-client-ui-subagent` 是**宿主自己的子代理 UI**,是找「官方正确姿势」的最权威样板。 +## 二·补、把「另一个会话的聊天」开到侧栏(官方做法) + +不想切走主面板、又想盯着某个子会话时,用官方资源式打开(宿主自己的子代理 UI 就是这么做的): + +```ts +import { SUBAGENT_CHAT_KIND, subagentChatAddress } from './native/subagent-chat.ts' // 本插件提供的编解码 + +const column = (ctx as { get?: (n: string) => unknown }).get?.('sidebarRight') as + { openResource?: (a: string, o?: { kind?: string; preferNewPane?: boolean }) => void } | undefined +column?.openResource?.(subagentChatAddress(address), { kind: SUBAGENT_CHAT_KIND, preferNewPane: true }) +``` + +- 地址格式(必须逐字节一致,否则宿主解析不到):`dsh-resource://subagentchat/session/?parent=&mode=` +- `kind: 'subagentchat'` + `preferNewPane: true` 是官方那两个参数,别省。 +- 读取服务必须**可选探测**(`ctx.get?.()`):测试里的假 ctx 没有 `get`,直接 `ctx.get(...)` 会在渲染期崩掉整棵子树(我们踩过:9 个渲染测试全挂)。 +- 交互:官方只在「该行不是当前会话」时显示按钮,并给 `title` + `aria-label` 同一份文案。 + ## 三、注册/生命周期 - 服务注册必须等服务、不能等槽声明:`ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(槽声明早于服务 provide,按槽注册会静默什么都不注册且不重试);槽只用来挂 tab 体。 diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css index 14ddd9b..69ce95a 100644 --- a/src/client/SubagentView.module.css +++ b/src/client/SubagentView.module.css @@ -113,6 +113,33 @@ } /* The state dot aligns to the first text line (official catalog detail). */ +/* The per-row "open beside" affordance (mirrors the host catalog's own row + action); revealed on hover/focus so the row stays calm at rest. */ +.subagentAside { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0; +} + +.subagentRow:hover .subagentAside, +.subagentRow:focus-within .subagentAside { + opacity: 0.7; +} + +.subagentAside:hover { + opacity: 1; +} + .subagentDot { margin-top: 4px; } diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index de3b8db..cece3d4 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -23,7 +23,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { - IconRefreshOutlineRegular, StateDot, + IconChevronRightOutlineRegular, + IconRefreshOutlineRegular, + StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, @@ -42,6 +44,7 @@ import { rootAncestor, } from './subagent-detect.ts' import { type LastActivity } from '../subagent-activity.ts' +import { SUBAGENT_CHAT_KIND, subagentChatAddress } from './native/subagent-chat.ts' import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' import { collectTreeJobs, @@ -283,13 +286,19 @@ interface RowsProps { /** The batch live-preview map (child id → latest activity). */ live: Readonly> openChild: (address: SidebarSubagentAddress) => void + /** + * Open a child's chat as a native right-Sidebar page BESIDE the current + * session instead of switching the main pane to it. Absent when the + * deployment has no native right Sidebar. + */ + openChildAside?: (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, live, - openChild, refresh, + openChild, openChildAside, refresh, }: RowsProps) { const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0 // Side Chat threads are honest catalog citizens (durable descriptor, 'Side: ' @@ -383,6 +392,24 @@ function CatalogRows({ )} + {/* Mirrors the host catalog's own row action: open the child's + chat as a right-Sidebar page without leaving this pane. */} + {!current && openChildAside !== undefined && ( + + )}
{!knownLeaf && (
@@ -404,6 +431,7 @@ function CatalogRows({ currentSessionId={currentSessionId} live={live} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} /> )} @@ -812,6 +840,27 @@ export function SubagentView(props: { void (sessions.refreshProjections ?? sessions.refreshSubagents)?.(parentSessionId) }, [sessions]) + // The native right Sidebar controller — a structural slice only (the service + // is absent in plain-web deployments), read lazily like every other host seam. + const column = useMemo( + () => (ctx as { get?: (name: string) => unknown }).get?.('sidebarRight') as unknown as + { openResource?: (address: string, options?: { kind?: string; preferNewPane?: boolean }) => void } | undefined, + [ctx], + ) + + /** + * Open a child's chat as a native right-Sidebar page beside the current + * session — the host's own `openChildAside` action, so a user can watch a + * child work without surrendering the pane he is in. The in-pane jump stays + * `openChild`; this one never switches the main pane. + */ + const openChildAside = useCallback((address: SidebarSubagentAddress): void => { + column?.openResource?.(subagentChatAddress(address), { + kind: SUBAGENT_CHAT_KIND, + preferNewPane: true, + }) + }, [column]) + const totals = useMemo( () => rootId === undefined ? { count: 0, runningCount: 0 } @@ -959,6 +1008,7 @@ export function SubagentView(props: { currentSessionId={sessionId} live={live} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} /> )} @@ -979,6 +1029,7 @@ export function SubagentView(props: { currentSessionId={sessionId} live={live} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} /> )} diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 5d8cdca..8ec119e 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -327,6 +327,7 @@ export const ar: Record = { subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا', subagentRunning: 'قيد التشغيل', subagentInactive: 'خامل', + subagentOpenAside: 'الفتح في الشريط الجانبي', subagentModeOneShot: 'مرة واحدة', subagentModeContinuable: 'قابل للاستئناف', subagentCount: '{count} وكلاء فرعيون', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index c5767a5..8882cc4 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -312,6 +312,7 @@ export const de: Record = { subagentEmptyDesc: 'Subagenten, die unter dem Hauptagenten erzeugt werden, erscheinen hier', subagentRunning: 'Läuft', subagentInactive: 'Inaktiv', + subagentOpenAside: 'In Seitenleiste öffnen', subagentModeOneShot: 'Einmalig', subagentModeContinuable: 'Fortsetzbar', subagentCount: '{count} Subagenten', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 29a0b30..d685206 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -319,6 +319,7 @@ export const fr: Record = { subagentEmptyDesc: 'Les sous-agents dérivés de l’agent principal actuel s’afficheront ici', subagentRunning: 'En cours', subagentInactive: 'Inactif', + subagentOpenAside: 'Ouvrir dans le volet', subagentModeOneShot: 'À usage unique', subagentModeContinuable: 'Reprenable', subagentCount: '{count} sous-agent(s)', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index ed458f6..ebc198e 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -326,6 +326,7 @@ export const hi: Record = { subagentEmptyDesc: 'मुख्य एजेंट के अंतर्गत बने सबएजेंट यहाँ दिखेंगे', subagentRunning: 'चल रहा', subagentInactive: 'निष्क्रिय', + subagentOpenAside: 'साइडबार में खोलें', subagentModeOneShot: 'एक-बार', subagentModeContinuable: 'जारी रखने योग्य', subagentCount: '{count} सबएजेंट', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 9851ca8..32a379f 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -324,6 +324,7 @@ export const id: Record = { subagentEmptyDesc: 'Subagen yang dibangkitkan di bawah agen utama akan muncul di sini', subagentRunning: 'Berjalan', subagentInactive: 'Menganggur', + subagentOpenAside: 'Buka di bilah samping', subagentModeOneShot: 'Sekali pakai', subagentModeContinuable: 'Dapat dilanjutkan', subagentCount: '{count} subagen', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 394bd68..829df48 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -317,6 +317,7 @@ export const it: Record = { subagentEmptyDesc: 'I sottoagenti generati dall’agente principale compariranno qui', subagentRunning: 'In esecuzione', subagentInactive: 'Inattivo', + subagentOpenAside: 'Apri nella barra laterale', subagentModeOneShot: 'Monouso', subagentModeContinuable: 'Proseguibile', subagentCount: '{count} sottoagenti', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 1621634..7b6aeae 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -326,6 +326,7 @@ export const ja: Record = { subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます', subagentRunning: '実行中', subagentInactive: 'アイドル', + subagentOpenAside: 'サイドバーで開く', subagentModeOneShot: 'ワンショット', subagentModeContinuable: '継続可', subagentCount: '{count} 個のサブエージェント', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 1fd2476..a59a3c3 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -318,6 +318,7 @@ export const ko: Record = { subagentEmptyDesc: '현재 주 에이전트에서 파생된 서브 에이전트가 여기에 표시됩니다', subagentRunning: '실행 중', subagentInactive: '대기 중', + subagentOpenAside: '사이드바에서 열기', subagentModeOneShot: '일회성', subagentModeContinuable: '이어서 가능', subagentCount: '서브 에이전트 {count}개', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 242e48c..74ec2c6 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -324,6 +324,7 @@ export const nl: Record = { subagentEmptyDesc: 'Subagents voortgebracht door de hoofdagent verschijnen hier', subagentRunning: 'Actief', subagentInactive: 'Inactief', + subagentOpenAside: 'Openen in zijbalk', subagentModeOneShot: 'One-shot', subagentModeContinuable: 'Vervolgbaar', subagentCount: '{count} subagents', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 4f038be..e274040 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -328,6 +328,7 @@ export const pl: Record = { subagentEmptyDesc: 'Podagenci powołani przez agenta głównego pojawią się tutaj', subagentRunning: 'Działa', subagentInactive: 'Bezczynny', + subagentOpenAside: 'Otwórz na pasku bocznym', subagentModeOneShot: 'Jednorazowy', subagentModeContinuable: 'Kontynuowalny', subagentCount: '{count} podagentów', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index ddf3c03..8d659e3 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -309,6 +309,7 @@ export const pt: Record = { subagentEmptyDesc: 'Subagentes gerados sob o agente principal aparecerão aqui', subagentRunning: 'Executando', subagentInactive: 'Inativo', + subagentOpenAside: 'Abrir na barra lateral', subagentModeOneShot: 'De uso único', subagentModeContinuable: 'Continuável', subagentCount: '{count} subagentes', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 03f3ce8..93ac943 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -324,6 +324,7 @@ export const ru: Record = { subagentEmptyDesc: 'Субагенты, порождённые главным агентом, появятся здесь', subagentRunning: 'Выполняется', subagentInactive: 'Простаивает', + subagentOpenAside: 'Открыть на боковой панели', subagentModeOneShot: 'Одноразовый', subagentModeContinuable: 'Продолжаемый', subagentCount: '{count} субагентов', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 075a161..96ad073 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -309,6 +309,7 @@ export const sv: Record = { subagentEmptyDesc: 'Subagenter skapade under huvudagenten visas här', subagentRunning: 'Kör', subagentInactive: 'Inaktiv', + subagentOpenAside: 'Öppna i sidofältet', subagentModeOneShot: 'Engångs', subagentModeContinuable: 'Fortsättbar', subagentCount: '{count} subagenter', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index a7b0dce..37b2255 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -326,6 +326,7 @@ export const th: Record = { subagentEmptyDesc: 'ตัวแทนย่อยที่สร้างภายใต้ตัวแทนหลักจะปรากฏที่นี่', subagentRunning: 'กำลังทำงาน', subagentInactive: 'ไม่ได้ทำงาน', + subagentOpenAside: 'เปิดในแถบด้านข้าง', subagentModeOneShot: 'ครั้งเดียว', subagentModeContinuable: 'ทำต่อได้', subagentCount: '{count} ตัวแทนย่อย', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 7dc9b9e..35faa6c 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -326,6 +326,7 @@ export const tr: Record = { subagentEmptyDesc: 'Ana aracının altında doğan alt aracılar burada görünür', subagentRunning: 'Çalışıyor', subagentInactive: 'Etkin değil', + subagentOpenAside: 'Kenar çubuğunda aç', subagentModeOneShot: 'Tek seferlik', subagentModeContinuable: 'Sürdürülebilir', subagentCount: '{count} alt aracı', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 235f750..8c400be 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -326,6 +326,7 @@ export const vi: Record = { subagentEmptyDesc: 'Tác nhân con do tác nhân chính tạo ra sẽ hiển thị ở đây', subagentRunning: 'Đang chạy', subagentInactive: 'Nghỉ', + subagentOpenAside: 'Mở trong thanh bên', subagentModeOneShot: 'Một lần', subagentModeContinuable: 'Có thể tiếp tục', subagentCount: '{count} tác nhân con', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 63d8b0c..90da338 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -341,6 +341,7 @@ export const zhHK: Record = { subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', subagentInactive: '閒置', + subagentOpenAside: 'Open in sidebar', subagentModeOneShot: '一次性', subagentModeContinuable: '可續接', subagentCount: '{count} 個子代理', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index bbcc66f..7d53519 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -341,6 +341,7 @@ export const zhMO: Record = { subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', subagentInactive: '閒置', + subagentOpenAside: 'Open in sidebar', subagentModeOneShot: '一次性', subagentModeContinuable: '可續接', subagentCount: '{count} 個子代理', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 0e70e61..d443f5e 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -341,6 +341,7 @@ export const zhTW: Record = { subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', subagentInactive: '閒置', + subagentOpenAside: '在側欄中開啟', subagentModeOneShot: '一次性', subagentModeContinuable: '可續接', subagentCount: '{count} 個子代理', diff --git a/src/client/locales.ts b/src/client/locales.ts index 1cd1c2b..e1b77bf 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -338,6 +338,7 @@ export const zh = { subagentEmptyDesc: '当前主代理派生的子代理将显示在这里', subagentRunning: '运行中', subagentInactive: '空闲', + subagentOpenAside: '在侧栏中打开', subagentModeOneShot: '一次性', subagentModeContinuable: '可续接', subagentCount: '{count} 个子代理', @@ -787,6 +788,7 @@ export const en: Record = { subagentEmptyDesc: 'Subagents spawned under the main agent will appear here', subagentRunning: 'Running', subagentInactive: 'Inactive', + subagentOpenAside: 'Open in sidebar', subagentModeOneShot: 'One-shot', subagentModeContinuable: 'Continuable', subagentCount: '{count} subagents', diff --git a/src/client/native/subagent-chat.ts b/src/client/native/subagent-chat.ts new file mode 100644 index 0000000..624228b --- /dev/null +++ b/src/client/native/subagent-chat.ts @@ -0,0 +1,32 @@ +/** + * Resource-address codec for an embedded subagent Session chat. + * + * Mirrors `@deepseek-ai/dsh-client-ui-subagent`'s own `subagentChatAddress` + * (the `dsh-resource://subagentchat/session/?parent&mode` form the + * native right Sidebar roots its `subagentchat` pages under). Copied rather + * than imported: the package exports no helpers and the plugin must not depend + * on its internals. Kept dependency-free so it is unit-testable in node. + * + * @module + */ +import type { SidebarSubagentAddress } from '../../context-types.ts' + +/** Resource-address prefix for an embedded Session chat. */ +export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/' + +/** The native right Sidebar page kind an embedded Session chat opens as. */ +export const SUBAGENT_CHAT_KIND = 'subagentchat' + +/** + * Address one subagent Session together with the routing facts needed to + * restore it. + * @param address - durable direct-parent subagent address. + * @returns canonical Sidebar resource address. + */ +export function subagentChatAddress(address: SidebarSubagentAddress): string { + const query = new URLSearchParams({ + parent: address.parentSessionId, + mode: address.mode, + }) + return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query.toString()}` +} diff --git a/src/client/native/surface.ts b/src/client/native/surface.ts index 6dc0861..ff8b495 100644 --- a/src/client/native/surface.ts +++ b/src/client/native/surface.ts @@ -26,14 +26,27 @@ type Pending = | { kind: 'tab'; sessionId: string; tabKind: string; params: NativeTabParams; revealIfOpened: boolean } | { kind: 'resource'; sessionId: string; address: string; line: number | undefined; revealIfOpened: boolean } +/** + * Placement + navigation options one native open accepts. `kind` is the page + * kind a resource opens as (e.g. `subagentchat`) and `preferNewPane` forces a + * fresh pane instead of reusing a matching one — both are what the host's own + * subagent UI passes when it opens a child chat beside the current session. + */ +interface NativeOpenOptions { + params?: unknown + revealIfOpened?: boolean + kind?: string + preferNewPane?: boolean +} + /** The controller face this module uses (a structural slice of `ISidebarRight`). */ interface NativeController { - openTab(kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void - openResource(address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openTab(kind: string, options?: NativeOpenOptions): void + openResource(address: string, options?: NativeOpenOptions): void close(tabId: string): void /** Not part of `ISidebarRight`: the concrete controller's per-session writes. */ - openTabIn?(sessionId: string, kind: string, options?: { params?: unknown; revealIfOpened?: boolean }): void - openResourceIn?(sessionId: string, address: string, options?: { params?: unknown; revealIfOpened?: boolean }): void + openTabIn?(sessionId: string, kind: string, options?: NativeOpenOptions): void + openResourceIn?(sessionId: string, address: string, options?: NativeOpenOptions): void closeIn?(sessionId: string, tabId: string): void } diff --git a/tests/subagent-chat-aside.spec.ts b/tests/subagent-chat-aside.spec.ts new file mode 100644 index 0000000..c6e8826 --- /dev/null +++ b/tests/subagent-chat-aside.spec.ts @@ -0,0 +1,52 @@ +/** + * The `subagentchat` resource address must stay byte-compatible with the host's + * own codec: `@deepseek-ai/dsh-client-ui-subagent` parses what we emit, so a + * different query order or an unescaped child id would silently open the wrong + * page (or none). + */ +import { describe, expect, it } from 'vitest' +import { + SUBAGENT_CHAT_ADDRESS, + SUBAGENT_CHAT_KIND, + subagentChatAddress, +} from '../src/client/native/subagent-chat.ts' + +describe('subagentChatAddress', () => { + it("emits the canonical form with parent and mode", () => { + expect(subagentChatAddress({ + parentSessionId: 'parent-1', + childSessionId: 'child-2', + mode: 'one-shot', + })).toBe('dsh-resource://subagentchat/session/child-2?parent=parent-1&mode=one-shot') + }) + + it('keeps a continuable mode verbatim', () => { + expect(subagentChatAddress({ + parentSessionId: 'p', + childSessionId: 'c', + mode: 'continuable', + })).toContain('mode=continuable') + }) + + it('percent-encodes a child id so the address stays one path segment', () => { + const address = subagentChatAddress({ + parentSessionId: 'p', + childSessionId: 'child/with:odd chars', + mode: 'one-shot', + }) + expect(address).toContain('/child%2Fwith%3Aodd%20chars?') + }) + + it('never leaks the parent into the path (it is a routing query only)', () => { + const address = subagentChatAddress({ + parentSessionId: 'p secret', + childSessionId: 'c', + mode: 'one-shot', + }) + expect(address.split('?')[0]).toBe(`${SUBAGENT_CHAT_ADDRESS}c`) + }) + + it('names the page kind the native Sidebar roots these pages under', () => { + expect(SUBAGENT_CHAT_KIND).toBe('subagentchat') + }) +}) -- 2.54.0 From 061964e3e8e6f0157abbdf19bef897719b3e68ef Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 14 Oct 2026 10:18:58 +0800 Subject: [PATCH 7/7] fix(subagent): stop 401-ing the aside-open with a guessed child mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aside button could hit `subagent/unauthorized: subagent mode does not match the supplied address`. The host's session-controller (dsh-api-session- controller validateAddress) compares the `mode` in the resource address against the child's durable descriptor mode and rejects any mismatch. The address `mode` came from the row, and on the degraded path the row's mode is a best-effort guess from the summary `running` flag (running => continuable) — so a child that is genuinely one-shot but still running was guessed as continuable and the very open it was meant to trigger 401'd. Two layers, so it holds on both the real-catalog and the degraded path: - subagentChatAddress: only pass a mode through when it is one of the two durable values (`one-shot` / `continuable`). Anything else is omitted, which the controller reads as `mode: "unknown"` and therefore skips the comparison — the safest form when the caller does not actually know. - openChildAside: prefer the host's real descriptor mode for the child (the `subagentsByParent` entry carries the durable mode) over the address mode, falling back to the address only when the real catalog never arrived. Gates: typecheck / lint / build / test 132 files 1392 passed. --- src/client/SubagentView.tsx | 13 +++++++++++-- src/client/native/subagent-chat.ts | 18 ++++++++++++++---- tests/subagent-chat-aside.spec.ts | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index cece3d4..2b07c41 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -855,11 +855,20 @@ export function SubagentView(props: { * `openChild`; this one never switches the main pane. */ const openChildAside = useCallback((address: SidebarSubagentAddress): void => { - column?.openResource?.(subagentChatAddress(address), { + // The address's `mode` may be an inference (the degraded synthesized + // catalog). Prefer the host's REAL descriptor mode for this child — the + // `subagentsByParent` entry carries the durable `mode` the session- + // controller validates against — and only fall back to the address when + // the real catalog never arrived. + const entry = catalogs[address.parentSessionId]?.entries.find( + (e): e is SidebarSubagentChildEntry => e.kind === 'child' && e.id === address.childSessionId, + ) + const mode = entry?.mode ?? address.mode + column?.openResource?.(subagentChatAddress({ ...address, mode }), { kind: SUBAGENT_CHAT_KIND, preferNewPane: true, }) - }, [column]) + }, [column, catalogs]) const totals = useMemo( () => rootId === undefined diff --git a/src/client/native/subagent-chat.ts b/src/client/native/subagent-chat.ts index 624228b..c740b97 100644 --- a/src/client/native/subagent-chat.ts +++ b/src/client/native/subagent-chat.ts @@ -11,6 +11,9 @@ */ import type { SidebarSubagentAddress } from '../../context-types.ts' +/** The only modes the host's session-controller will accept in an address. */ +const KNOWN_MODES = new Set(['one-shot', 'continuable']) + /** Resource-address prefix for an embedded Session chat. */ export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/' @@ -24,9 +27,16 @@ export const SUBAGENT_CHAT_KIND = 'subagentchat' * @returns canonical Sidebar resource address. */ export function subagentChatAddress(address: SidebarSubagentAddress): string { - const query = new URLSearchParams({ - parent: address.parentSessionId, - mode: address.mode, - }) + // `mode` is a GATE, not a label: the host's session-controller rejects an + // address whose `mode` differs from the child's durable descriptor mode + // (`subagent/unauthorized: subagent mode does not match the supplied + // address`). The row-level `mode` can be a best-effort inference (the + // degraded summary-synthesized catalog guesses from `running`), so a wrong + // guess used to 401 the very open it was meant to trigger. A valid mode is + // passed through verbatim; anything else is OMITTED, which the controller + // reads as `mode: "unknown"` and therefore skips the comparison entirely — + // the safest form when the caller does not actually know the mode. + const query = new URLSearchParams({ parent: address.parentSessionId }) + if (KNOWN_MODES.has(address.mode)) query.set('mode', address.mode) return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query.toString()}` } diff --git a/tests/subagent-chat-aside.spec.ts b/tests/subagent-chat-aside.spec.ts index c6e8826..3b4ed7d 100644 --- a/tests/subagent-chat-aside.spec.ts +++ b/tests/subagent-chat-aside.spec.ts @@ -49,4 +49,25 @@ describe('subagentChatAddress', () => { it('names the page kind the native Sidebar roots these pages under', () => { expect(SUBAGENT_CHAT_KIND).toBe('subagentchat') }) + + it('omits an unknown mode so the host treats it as "unknown" and skips the check', () => { + // The degraded synthesized catalog guesses mode from `running`; a wrong + // guess used to 401 the open (the controller compares address.mode against + // the child's durable descriptor). An unrecognized mode must be dropped, + // which the controller reads as `mode: "unknown"` and does not compare. + const address = subagentChatAddress({ + parentSessionId: 'p', + childSessionId: 'c', + mode: 'unknown' as never, + }) + expect(address).toBe('dsh-resource://subagentchat/session/c?parent=p') + expect(address).not.toContain('mode=') + }) + + it('still emits the mode when it is one of the two durable values', () => { + expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'one-shot' })) + .toContain('mode=one-shot') + expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'continuable' })) + .toContain('mode=continuable') + }) }) -- 2.54.0