From 6b4a29ba24eec8ebde3b957085fe2831695b14fe Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 14 Oct 2026 09:32:16 +0800 Subject: [PATCH] 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') + }) +})