feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews

DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
This commit is contained in:
Menghuan1918
2026-09-22 17:09:08 +08:00
parent de99fd4cfb
commit 6586e3e2d3
96 changed files with 4567 additions and 2080 deletions
+139 -117
View File
@@ -9,38 +9,52 @@
* back to the main session), with its subagents hanging below it in clearly
* LAYERED levels: tree connector lines (first level included) and per-level
* indentation show the hierarchy, and the currently-open session is
* highlighted in place. Every branch is expanded automatically (lazy
* catalogs hydrate on demand and consume live membership while visible).
* highlighted in place. Every branch is expanded automatically.
*
* The child rows come from DSH 0.1.7's host-computed `subagentCatalog`
* projection (folded per parent by ./subagent-catalog.ts), which the client
* loads for every session with the connection — there is no per-parent
* observe/refresh handshake to run any more. What a row no longer carries is
* derived: a branch stays open until the child's own catalog is known-empty,
* and live status comes from the `subagents.live` batch channel.
*
* Each node card carries live status (state dot, durable label, mode and
* activity); while a child RUNS, its card additionally shows the LAST text
* output and LAST tool call pulled from its history tail, auto-refreshing
* every few seconds while the page is visible. Clicking a card jumps
* straight into the child transcript (`openSubagent`); the page stays open
* straight into the child transcript (`openChild`); the page stays open
* and the topology remains rooted at the main session.
*
* The background-jobs section below the tree is fed by the plugin's
* `jobs.list` route, one polled read per tree session (the registry's fence
* admits a job only to its owner, and 0.1.7 stopped mirroring jobs into the
* client snapshot).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconRefreshOutline14, StateDot,
IconRefreshOutlineRegular, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarSubagentCatalogEntry,
SidebarJobView,
} from '../context-types.ts'
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import {
childActivity,
isKnownLeaf,
subagentCatalogs,
type SubagentCatalogView,
} from './subagent-catalog.ts'
import { type LastActivity } from '../subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
@@ -50,6 +64,7 @@ import {
orderJobs,
jobDotState,
jobStatusLabel,
treeSessionIds,
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
@@ -60,6 +75,8 @@ import css from './SubagentView.module.css'
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
const POLL_MS = 3000
/** Refresh cadence of the tree's background-job lists (one read per tree session). */
const JOBS_POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded job-output panel while its job runs. */
@@ -82,29 +99,37 @@ function directChildren(
/** Human label of one catalog child: durable label, then summary title, then id. */
function childLabel(
entry: SidebarSubagentChildEntry,
entry: SidebarSubagentCatalogEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
}
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
switch (entry.reason) {
case 'corrupt': return t('subagentDiagCorrupt')
case 'unsupported': return t('subagentDiagUnsupported')
case 'unavailable': return t('subagentDiagUnavailable')
/**
* The mode word of one catalog row. DSH 0.1.7 added `unknown` (a child the
* host's catalog fold kept without a readable descriptor): it claims NEITHER
* mode, so the segment is omitted rather than mislabelled — printing
* "Continuable" would be a lie, and the shipped dictionaries have no key for
* it yet (adding one would have to touch every locale chunk).
*/
function modeLabel(mode: SidebarSubagentCatalogEntry['mode']): string | undefined {
switch (mode) {
case 'one-shot': return t('subagentModeOneShot')
case 'continuable': return t('subagentModeContinuable')
case 'unknown': return undefined
}
}
/** The secondary line of one card: title · mode · activity (skips empty parts). */
function cardSecondary(
summary: SidebarSessionSummary | undefined,
entry: SidebarSubagentChildEntry,
entry: SidebarSubagentCatalogEntry,
activity: 'running' | 'inactive',
): string {
return [
summary?.displayTitle,
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
modeLabel(entry.mode),
activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
].filter(Boolean).join(' · ')
}
@@ -211,8 +236,8 @@ function useSubagentLive(
interface RowsProps {
parentSessionId: string
catalog: SidebarSubagentCatalog | undefined
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
catalog: SubagentCatalogView | undefined
catalogs: Readonly<Record<string, SubagentCatalogView>>
byId: Readonly<Record<string, SidebarSessionSummary>>
level: number
/** The currently-open session id (highlighted in the topology). */
@@ -223,7 +248,7 @@ interface RowsProps {
refresh: (parentSessionId: string) => void
}
/** Render one topology level; branches are always expanded (lazy catalogs). */
/** Render one topology level; branches are always expanded. */
function CatalogRows({
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
openChild, refresh,
@@ -231,12 +256,12 @@ function CatalogRows({
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
// strip owns them). The label lives on the entry for a continuable child and
// on the summary for a one-shot whose descriptor carried none, so both are
// checked.
const visibleEntries = (catalog?.entries ?? []).filter(entry =>
!(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
&& !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false))
return (
<>
{emptyLoading && (
@@ -250,43 +275,31 @@ function CatalogRows({
className={css.subagentErrorRetry}
onClick={() => { refresh(parentSessionId) }}
>
<IconRefreshOutline14 />
<IconRefreshOutlineRegular size={14} />
{t('retry')}
</button>
</div>
)}
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
title={diagnosticReason(entry)}
>
<StateDot state="error" className={css.subagentDot} />
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{entry.id}</span>
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
</span>
</div>
</div>
)
}
const childCatalog = catalogs[entry.id]
const knownLeaf = !entry.hasChildren
// A branch stays open until the child's OWN catalog loaded empty; a
// catalog that is missing, still loading, or failed keeps its level so
// the rows appear the moment the projection lands.
const knownLeaf = isKnownLeaf(catalogs, entry.id)
const summary = byId[entry.id]
const label = childLabel(entry, summary)
const secondary = cardSecondary(summary, entry)
const activity = childActivity(live, entry.id)
const secondary = cardSecondary(summary, entry, activity)
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const address: SidebarSubagentAddress = {
parentSessionId,
childSessionId: entry.id,
mode: entry.mode,
// DSH 0.1.7 widened the runtime address to `mode: 'unknown'` (the
// row's own mode). This plugin's structural mirror of the address
// still spells the 0.1.6 pair — the cast is that pending widening,
// not a claim about a shape the host rejects.
mode: entry.mode as SidebarSubagentAddress['mode'],
}
const current = entry.id === currentSessionId
@@ -310,13 +323,13 @@ function CatalogRows({
}}
>
<StateDot
state={entry.activity === 'running' ? 'ongoing' : 'done'}
state={activity === 'running' ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
{entry.activity === 'running' && (
{activity === 'running' && (
<SubagentLiveLines live={live[entry.id]} />
)}
</span>
@@ -450,20 +463,42 @@ function JobOutputPane(props: {
/**
* The background-job section of the Subagent page: every job of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no jobs.
* current tree (main agent + subagents, owner-labeled), read through the
* plugin's `jobs.list` route while the page is visible. Clicking a row feeds
* its model-read output to the shared bottom dock (event replay — never the
* model's cursor); live rows carry a two-click-confirm kill button. Renders
* nothing while the tree has no jobs.
*/
function JobsSection(props: {
byId: SidebarSessionList['byId']
jobsBySession: SidebarSessionList['jobsBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, jobsBySession, rootId, active } = props
const { byId, rootId, active } = props
// The registry's access fence admits a job to its OWNER session only, so the
// tree's jobs are one read per tree session — fanned out on each tick and
// keyed by owner for the (pure) collection below.
const treeIds = useMemo(() => [...treeSessionIds(byId, rootId)], [byId, rootId])
const [jobsBySession, setJobsBySession] = useState<Readonly<Record<string, readonly SidebarJobView[]>>>({})
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
const entries = await Promise.all(treeIds.map(async (sessionId): Promise<[string, readonly SidebarJobView[]]> => {
try {
const result = await api.jobsList(sessionId, signal)
return [sessionId, result.jobs]
} catch {
// A host without the jobs service (503) or one dropped read degrades
// to an empty set for THIS session; the next tick retries.
return [sessionId, []]
}
}))
if (!signal.aborted) setJobsBySession(Object.fromEntries(entries))
}, [treeIds])
usePolling(active && treeIds.length > 0, poll, {
intervalMs: JOBS_POLL_MS,
mode: 'self-scheduling',
immediate: true,
})
const rows = useMemo(
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, jobsBySession, rootId],
@@ -614,11 +649,28 @@ function JobsSection(props: {
)
}
/**
* The client's own "show this conversation" verb. DSH 0.1.6 moved child and
* session navigation OFF `ISessions` (0.1.5's `open` / `openSubagent`, neither
* of which exists any more — the optional calls this page used were silently
* dead) onto the workspace face, which owns the main-view selection. The
* pre-0.1.6 faces stay as the fallback for a host that predates the move.
*/
interface ConversationNavigation {
openSession?(target: SidebarSubagentAddress | string): void
}
/** The per-parent catalog refresh across the two DSH generations. */
interface CatalogRefreshFace {
refreshProjections?(sessionId: string): Promise<void>
refreshSubagents?(sessionId: string): Promise<void>
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
* (active tab + open panel), the client context, and an optional
* jump-notify hook fired right before `openSubagent` (lets the sidebar
* jump-notify hook fired right before the child is opened (lets the sidebar
* shell re-open the Subagent page after the conversation switch lands on
* the child session).
* @returns the main agent's topology tree, or the empty/error/loading states.
@@ -632,17 +684,19 @@ export function SubagentView(props: {
const { sessionId, active, ctx, onOpenChild } = props
const sessions = ctx.sessions
// The same list feed the official catalog consumes (byId lineage + the
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
// simply leave these surfaces empty — the page degrades to the empty state.
// The same list feed the official catalog consumes (`byId` lineage + the
// host-computed projection values). A snapshot without the projection seam
// (a pre-0.1.7 runtime) leaves every catalog absent, so the topology shows
// the root card alone — no rows, and no empty-state copy either.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
const byId = list.byId
// Memoized so the empty-catalog fallback keeps a stable identity — a fresh
// `{}` per render would invalidate every catalog-dependent memo/effect.
const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent])
// One fold per snapshot: the views are what every row, the branch test and
// the loading/error states read, and a fresh object per render would
// invalidate each of their memos.
const catalogs = useMemo(() => subagentCatalogs(list.projectionsBySession), [list.projectionsBySession])
// The topology root: the main agent of the current session's tree.
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
@@ -650,50 +704,15 @@ export function SubagentView(props: {
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const live = useSubagentLive(rootId, active)
/** Catalog owners currently consuming live membership updates. */
const observedRef = useRef(new Set<string>())
const observe = useCallback((parentSessionId: string, open: boolean): void => {
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
if (open) observedRef.current.add(parentSessionId)
else observedRef.current.delete(parentSessionId)
}, [sessions])
// While the page is visible the topology root consumes live membership; a
// root change (switching to another main agent's tree) or the page hiding
// (tab switched away / panel collapsed) releases everything observed.
useEffect(() => {
if (rootId === undefined || !active) return
observe(rootId, true)
return () => {
// The cleanup must release everything observed AT cleanup time (the set
// mutates as branches open), so reading the ref here is the point.
// eslint-disable-next-line react-hooks/exhaustive-deps
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
const openConversation = useCallback((target: SidebarSubagentAddress | string): void => {
const workspace = ctx.get('uiWorkspace') as unknown as ConversationNavigation | undefined
if (typeof workspace?.openSession === 'function') {
workspace.openSession(target)
return
}
}, [rootId, active, observe, sessions])
// Every branch of the always-expanded topology consumes live membership
// (add-only: a branch stays observed until the root changes or the page
// hides, which releases the whole set via the root effect's cleanup).
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
useEffect(() => {
if (!active) return
for (const id of branches) {
if (!observedRef.current.has(id)) observe(id, true)
}
}, [branches, active, observe])
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
useEffect(() => () => {
for (const parentSessionId of observedRef.current) {
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
}
observedRef.current.clear()
}, [sessions])
if (typeof target === 'string') sessions.open?.(target)
else sessions.openSubagent?.(target)
}, [ctx, sessions])
const openChild = useCallback((address: SidebarSubagentAddress): void => {
// Notify the shell first: the jump switches the sidebar to the child
@@ -702,24 +721,28 @@ export function SubagentView(props: {
// highlighted) — the README "page stays open" contract.
onOpenChild?.(address)
try {
sessions.openSubagent?.(address)
openConversation(address)
} catch (error) {
console.error('[dsh-better-sidebar] openSubagent failed:', error)
console.error('[dsh-better-sidebar] open subagent failed:', error)
}
}, [sessions, onOpenChild])
}, [openConversation, onOpenChild])
/** Jump back to the main agent (the topology root) from its node. */
const openMain = useCallback((): void => {
if (rootId === undefined) return
try {
sessions.open?.(rootId)
openConversation(rootId)
} catch (error) {
console.error('[dsh-better-sidebar] open session failed:', error)
}
}, [sessions, rootId])
}, [openConversation, rootId])
const refresh = useCallback((parentSessionId: string): void => {
void sessions.refreshSubagents?.(parentSessionId)
// 0.1.7 renamed the per-parent catalog refresh to `refreshProjections`
// (the projection store owns every session's values now); calling the
// removed 0.1.6 name alone would make this button a silent no-op.
const face = sessions as CatalogRefreshFace
void (face.refreshProjections ?? face.refreshSubagents)?.(parentSessionId)
}, [sessions])
const totals = useMemo(
@@ -789,7 +812,7 @@ export function SubagentView(props: {
disabled={rootId === undefined}
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
>
<IconRefreshOutline14 />
<IconRefreshOutlineRegular size={14} />
</button>
</div>
<div
@@ -862,7 +885,6 @@ export function SubagentView(props: {
</div>
<JobsSection
byId={byId}
jobsBySession={list.jobsBySession}
rootId={rootId}
active={active}
/>