feat(subagent): open a child's chat in the native right sidebar
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s

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.
This commit is contained in:
figmar
2026-10-14 09:32:16 +08:00
parent 35d33c7f65
commit 6b4a29ba24
27 changed files with 220 additions and 7 deletions
+1 -1
View File
@@ -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})`)——**本插件已按官方形式接入**(子代理行上的「在侧栏打开」按钮),同样建议文档化。
---
+17
View File
@@ -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/<encodeURIComponent(childSessionId)>?parent=<parentSessionId>&mode=<mode>`
- `kind: 'subagentchat'` + `preferNewPane: true` 是官方那两个参数,别省。
- 读取服务必须**可选探测**(`ctx.get?.()`):测试里的假 ctx 没有 `get`,直接 `ctx.get(...)` 会在渲染期崩掉整棵子树(我们踩过:9 个渲染测试全挂)。
- 交互:官方只在「该行不是当前会话」时显示按钮,并给 `title` + `aria-label` 同一份文案。
## 三、注册/生命周期
- 服务注册必须等服务、不能等槽声明:`ctx.inject(['sidebarRightTabs'], cb)` 驱动类型注册(槽声明早于服务 provide,按槽注册会静默什么都不注册且不重试);槽只用来挂 tab 体。
+27
View File
@@ -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;
}
+53 -2
View File
@@ -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<Record<string, LastActivity>>
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({
<SubagentLiveLines live={live[entry.id]} />
)}
</span>
{/* 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 && (
<button
type="button"
className={css.subagentAside}
title={t('subagentOpenAside')}
aria-label={t('subagentOpenAside')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
openChildAside(address)
}}
onKeyDown={(event) => { event.stopPropagation() }}
>
<IconChevronRightOutlineRegular />
</button>
)}
</div>
{!knownLeaf && (
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
@@ -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}
/>
)}
+1
View File
@@ -327,6 +327,7 @@ export const ar: Record<string, string> = {
subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا',
subagentRunning: 'قيد التشغيل',
subagentInactive: 'خامل',
subagentOpenAside: 'الفتح في الشريط الجانبي',
subagentModeOneShot: 'مرة واحدة',
subagentModeContinuable: 'قابل للاستئناف',
subagentCount: '{count} وكلاء فرعيون',
+1
View File
@@ -312,6 +312,7 @@ export const de: Record<string, string> = {
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',
+1
View File
@@ -319,6 +319,7 @@ export const fr: Record<string, string> = {
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)',
+1
View File
@@ -326,6 +326,7 @@ export const hi: Record<string, string> = {
subagentEmptyDesc: 'मुख्य एजेंट के अंतर्गत बने सबएजेंट यहाँ दिखेंगे',
subagentRunning: 'चल रहा',
subagentInactive: 'निष्क्रिय',
subagentOpenAside: 'साइडबार में खोलें',
subagentModeOneShot: 'एक-बार',
subagentModeContinuable: 'जारी रखने योग्य',
subagentCount: '{count} सबएजेंट',
+1
View File
@@ -324,6 +324,7 @@ export const id: Record<string, string> = {
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',
+1
View File
@@ -317,6 +317,7 @@ export const it: Record<string, string> = {
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',
+1
View File
@@ -326,6 +326,7 @@ export const ja: Record<string, string> = {
subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます',
subagentRunning: '実行中',
subagentInactive: 'アイドル',
subagentOpenAside: 'サイドバーで開く',
subagentModeOneShot: 'ワンショット',
subagentModeContinuable: '継続可',
subagentCount: '{count} 個のサブエージェント',
+1
View File
@@ -318,6 +318,7 @@ export const ko: Record<string, string> = {
subagentEmptyDesc: '현재 주 에이전트에서 파생된 서브 에이전트가 여기에 표시됩니다',
subagentRunning: '실행 중',
subagentInactive: '대기 중',
subagentOpenAside: '사이드바에서 열기',
subagentModeOneShot: '일회성',
subagentModeContinuable: '이어서 가능',
subagentCount: '서브 에이전트 {count}개',
+1
View File
@@ -324,6 +324,7 @@ export const nl: Record<string, string> = {
subagentEmptyDesc: 'Subagents voortgebracht door de hoofdagent verschijnen hier',
subagentRunning: 'Actief',
subagentInactive: 'Inactief',
subagentOpenAside: 'Openen in zijbalk',
subagentModeOneShot: 'One-shot',
subagentModeContinuable: 'Vervolgbaar',
subagentCount: '{count} subagents',
+1
View File
@@ -328,6 +328,7 @@ export const pl: Record<string, string> = {
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',
+1
View File
@@ -309,6 +309,7 @@ export const pt: Record<string, string> = {
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',
+1
View File
@@ -324,6 +324,7 @@ export const ru: Record<string, string> = {
subagentEmptyDesc: 'Субагенты, порождённые главным агентом, появятся здесь',
subagentRunning: 'Выполняется',
subagentInactive: 'Простаивает',
subagentOpenAside: 'Открыть на боковой панели',
subagentModeOneShot: 'Одноразовый',
subagentModeContinuable: 'Продолжаемый',
subagentCount: '{count} субагентов',
+1
View File
@@ -309,6 +309,7 @@ export const sv: Record<string, string> = {
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',
+1
View File
@@ -326,6 +326,7 @@ export const th: Record<string, string> = {
subagentEmptyDesc: 'ตัวแทนย่อยที่สร้างภายใต้ตัวแทนหลักจะปรากฏที่นี่',
subagentRunning: 'กำลังทำงาน',
subagentInactive: 'ไม่ได้ทำงาน',
subagentOpenAside: 'เปิดในแถบด้านข้าง',
subagentModeOneShot: 'ครั้งเดียว',
subagentModeContinuable: 'ทำต่อได้',
subagentCount: '{count} ตัวแทนย่อย',
+1
View File
@@ -326,6 +326,7 @@ export const tr: Record<string, string> = {
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ı',
+1
View File
@@ -326,6 +326,7 @@ export const vi: Record<string, string> = {
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',
+1
View File
@@ -341,6 +341,7 @@ export const zhHK: Record<string, string> = {
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
subagentRunning: '執行中',
subagentInactive: '閒置',
subagentOpenAside: 'Open in sidebar',
subagentModeOneShot: '一次性',
subagentModeContinuable: '可續接',
subagentCount: '{count} 個子代理',
+1
View File
@@ -341,6 +341,7 @@ export const zhMO: Record<string, string> = {
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
subagentRunning: '執行中',
subagentInactive: '閒置',
subagentOpenAside: 'Open in sidebar',
subagentModeOneShot: '一次性',
subagentModeContinuable: '可續接',
subagentCount: '{count} 個子代理',
+1
View File
@@ -341,6 +341,7 @@ export const zhTW: Record<string, string> = {
subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡',
subagentRunning: '執行中',
subagentInactive: '閒置',
subagentOpenAside: '在側欄中開啟',
subagentModeOneShot: '一次性',
subagentModeContinuable: '可續接',
subagentCount: '{count} 個子代理',
+2
View File
@@ -338,6 +338,7 @@ export const zh = {
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
subagentRunning: '运行中',
subagentInactive: '空闲',
subagentOpenAside: '在侧栏中打开',
subagentModeOneShot: '一次性',
subagentModeContinuable: '可续接',
subagentCount: '{count} 个子代理',
@@ -787,6 +788,7 @@ export const en: Record<keyof typeof zh, string> = {
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',
+32
View File
@@ -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/<childId>?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()}`
}
+17 -4
View File
@@ -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
}
+52
View File
@@ -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')
})
})