diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7e5a7be..277562e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -151,8 +151,10 @@ jobs:
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
- # DSH 0.1.6-alpha.2 is the `alpha` dist-tag on npm (`latest` is still
- # the 0.1.5 line); the lanes speak the slash-only RPC dialect
+ # Write an EXACT version, never a dist-tag: when 0.1.7-rc.1 shipped, the
+ # npm `alpha` tag still meant 0.1.7-alpha.2, rc.1 went out on `next`, and
+ # `latest` was still the 0.1.5 line — `@alpha` would mount a host this
+ # plugin no longer supports. The lanes speak the slash-only RPC dialect
# (tests/e2e/host-protocol.ts).
#
# The larger heap is the fix for one of the two ways this step used to
diff --git a/scripts/package-registry.mjs b/scripts/package-registry.mjs
index b18ca1b..43fe5d6 100644
--- a/scripts/package-registry.mjs
+++ b/scripts/package-registry.mjs
@@ -31,10 +31,16 @@ const files = [
'lib/client-registry.js.map',
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
+ // EVERY `CHUNK_NAMES` entry belongs here — the route reads the file out of
+ // the installed copy, so a chunk left out of this list 404s on the registry
+ // channel (`locale` was). tests/manifest-consistency.spec.ts guards the list
+ // against src/bundle-route.ts.
'lib/client-editor.js',
'lib/client-editor.js.map',
'lib/client-mermaid.js',
'lib/client-mermaid.js.map',
+ 'lib/client-locale.js',
+ 'lib/client-locale.js.map',
'README.md',
'LICENSE',
]
diff --git a/src/client/JobsDrawer.tsx b/src/client/JobsDrawer.tsx
index ef1a8b1..d6662df 100644
--- a/src/client/JobsDrawer.tsx
+++ b/src/client/JobsDrawer.tsx
@@ -15,7 +15,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
- Button, IconChevronUpOutline14, IconCopyOutline16, IconStopFill16, StateDot, Switch, Tag,
+ Button, IconChevronUpOutlineRegular, IconCopyOutlineRegular, IconStopFillRegular, StateDot, Switch, Tag,
type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarJobView } from '../context-types.ts'
@@ -120,7 +120,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
{liveCount > 0 ? liveCount : rows.length}
{countLabel}
-
+
{!autoOpen &&
{t('jobsAutoCollapsed')}
}
@@ -174,7 +174,7 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
size="sm"
className={css.jobsKill}
data-armed={armed ? 'true' : 'false'}
- icon={ }
+ icon={ }
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
@@ -288,7 +288,7 @@ export function JobOutputPopoverContent(props: {
}
+ icon={ }
aria-label={copied ? t('jobCopied') : t('jobCopyOutput')}
title={copied ? t('jobCopied') : t('jobCopyOutput')}
disabled={text === ''}
@@ -343,7 +343,7 @@ export function JobOutputPopoverContent(props: {
variant="outline"
size="sm"
className={armed ? css.jobPopKillArmed : undefined}
- icon={ }
+ icon={ }
disabled={killing}
onClick={() => {
if (armed) void kill()
diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx
index 1f06bde..91464b7 100644
--- a/src/client/SideChatView.tsx
+++ b/src/client/SideChatView.tsx
@@ -73,21 +73,6 @@ import { api } from './api.ts'
import { usePolling } from './use-polling.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
-import type { ComponentProps, ComponentType } from 'react'
-
-/**
- * Cross-line compatibility shim for `ConnectionIndicator`.
- *
- * 0.1.6-alpha.2 REMOVED the `reconnectLabel` prop (the outage label already
- * names the action), while the 0.1.5-rc line still REQUIRES it as the retry
- * button's hover/focus action text. The plugin's peer range covers both, so
- * the component type is widened once here and the prop is always passed: rc
- * renders it, alpha.2 ignores the extra key. Delete this shim (and the prop)
- * when the peer floor moves past the rc line.
- */
-const ConnectionIndicatorCompat = ConnectionIndicator as unknown as ComponentType<
- ComponentProps & { reconnectLabel?: string }
->
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
@@ -768,10 +753,9 @@ export function SideChatView(props: {
{connectionState !== undefined && connectionState !== 'connected' && (
-
+ refreshSubagents?(sessionId: string): Promise
+}
/**
* One shared live-preview poller for the whole tree. At most ONE
@@ -134,6 +155,46 @@ function useTeamView(
return { view, refresh }
}
+/**
+ * The background-job lists of the whole tree, keyed by OWNER session.
+ *
+ * DSH 0.1.7 dropped the client session snapshot's `jobsBySession` push mirror,
+ * so the registry is read through the plugin's own `jobs.list` route — and the
+ * registry's access fence admits a job to its OWNER session only, so the whole
+ * tree is one read per tree session, fanned out on each tick. A host without
+ * the jobs service (503) or one dropped read degrades to an empty set for THAT
+ * session; the next tick retries.
+ */
+function useTreeJobs(
+ byId: SidebarSessionList['byId'],
+ rootId: string | undefined,
+ active: boolean,
+): Readonly> {
+ const treeIds = useMemo(
+ () => (rootId === undefined ? [] : [...treeSessionIds(byId, rootId)]),
+ [byId, rootId],
+ )
+ const [jobsBySession, setJobsBySession] = useState>>({})
+ useEffect(() => { setJobsBySession({}) }, [rootId])
+ const poll = useCallback(async (signal: AbortSignal): Promise => {
+ 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 {
+ 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,
+ })
+ return jobsBySession
+}
+
/** The open popover of the page (one at a time, anchored). */
type PagePopover =
| { kind: 'node'; nodeId: string; anchor: HTMLElement }
@@ -158,17 +219,20 @@ export function SubagentView(props: {
const { sessionId, active, ctx, store, 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. DSH 0.1.7 publishes the
+ // host-computed `subagentCatalog` projection per session and loads every
+ // session's projections once per connection, so there is no observe/refresh
+ // handshake left to run — the page is a pure reader of the snapshot. A host
+ // without the projection store leaves these surfaces empty and the page
+ // degrades to its empty state.
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])
+ // The projected per-parent catalogs, already folded into the view shape the
+ // topology consumes (see subagent-catalog.ts).
+ 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])
@@ -212,48 +276,22 @@ export function SubagentView(props: {
[byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded],
)
- /** Catalog owners currently consuming live membership updates. */
- const observedRef = useRef(new Set())
-
- 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()
+ /**
+ * Show one conversation in the main view. DSH moved child and session
+ * navigation OFF `ISessions` (0.1.6's `open` / `openSubagent` are gone from
+ * the runtime, and the optional calls this page used were silently dead)
+ * onto the workspace face, which owns the main-view selection. The old
+ * names stay as the fallback for a host that predates the move.
+ */
+ 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.
- 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
@@ -262,24 +300,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])
/** Jump from the detail window into the node's transcript. */
@@ -316,10 +358,11 @@ export function SubagentView(props: {
)
const agentCount = totals.count + 1
- /** The tree's ordered job rows (owner-labeled). */
+ /** The tree's ordered job rows (owner-labeled, read per tree session). */
+ const jobsBySession = useTreeJobs(byId, rootId, active)
const jobRows = useMemo(
- () => orderJobs(collectTreeJobs(byId, list.jobsBySession, rootId)),
- [byId, list.jobsBySession, rootId],
+ () => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
+ [byId, jobsBySession, rootId],
)
/** Catalogs that failed to load (surfaced as one banner in both modes). */
@@ -445,7 +488,7 @@ export function SubagentView(props: {
variant="ghost"
size="sm"
className={legacy.subagentRefresh}
- icon={ }
+ icon={ }
aria-label={t('refresh')}
title={t('refresh')}
disabled={rootId === undefined}
@@ -474,7 +517,7 @@ export function SubagentView(props: {
variant="outline"
size="sm"
className={legacy.subagentErrorRetry}
- icon={ }
+ icon={ }
onClick={() => { for (const parent of failedParents) refresh(parent) }}
>
{t('retry')}
diff --git a/src/client/TaskWindow.tsx b/src/client/TaskWindow.tsx
index 0e2eb77..560b26f 100644
--- a/src/client/TaskWindow.tsx
+++ b/src/client/TaskWindow.tsx
@@ -17,8 +17,8 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
- Button, IconCheckOutline14, IconCloseOutline16, IconEditOutline16,
- IconRefreshOutline14, IconTrashOutline16, Input, MarkdownText, Pill, StateDot, Tag,
+ Button, IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular,
+ IconRefreshOutlineRegular, IconTrashOutlineRegular, Input, MarkdownText, Pill, StateDot, Tag,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
import { api } from './api.ts'
@@ -133,7 +133,13 @@ function TaskViewBody(props: {
? {t('teamTaskNoDescription')}
: (
)}
@@ -309,7 +315,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
+ icon={ }
aria-label={t('teamTaskCancel')}
title={t('teamTaskCancel')}
onClick={onClose}
@@ -345,7 +351,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
+ icon={ }
disabled={busy || subject.trim() === ''}
onClick={save}
>
@@ -374,7 +380,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
+ icon={ }
onClick={() => { setEditing(true) }}
>
{t('teamTaskEdit')}
@@ -387,7 +393,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
+ icon={ }
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'reopen',
@@ -400,7 +406,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
}
+ icon={ }
disabled={busy}
onClick={() => void mutate(() => api.teamsTaskUpdate(rootId, {
taskId: task.id, expectedRevision: task.revision, action: 'complete',
@@ -413,7 +419,7 @@ export function TaskWindow(props: TaskWindowProps): ReactNode {
variant="outline"
size="sm"
className={clsx(css.taskDanger, armedDelete && css.taskDangerArmed)}
- icon={ }
+ icon={ }
disabled={busy}
onClick={() => {
if (!armedDelete) { setArmedDelete(true); return }
diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx
index 9baa6e5..88148c9 100644
--- a/src/client/TasksGraph.tsx
+++ b/src/client/TasksGraph.tsx
@@ -30,7 +30,7 @@ import {
} from 'react'
import clsx from 'clsx'
import {
- Button, IconChecklistOutline14, IconFullscreenOutline16, IconLoadingOutline16, IconTreeCorner8x10,
+ Button, IconChecklistOutlineRegular, IconFullscreenOutlineRegular, IconLoadingOutlineRegular, IconTreeCornerRegular,
StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
@@ -110,7 +110,7 @@ export function ViewModeToggle(props: {
: }
+ icon={toTree ? : }
aria-label={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
title={t(toTree ? 'tasksViewSwitchToTree' : 'tasksViewSwitchToGraph')}
onClick={() => { onModeChange(toTree ? 'tree' : 'graph') }}
@@ -128,7 +128,7 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void
variant="ghost"
size="sm"
className={clsx(css.controlBtn, folded && css.controlBtnActive)}
- icon={ }
+ icon={ }
aria-pressed={folded}
aria-label={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
title={t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}
@@ -322,7 +322,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
zoom / node clicks fully live underneath. */}
{loading === true && nodes.length === 0 && (
-
+
{t('loading')}
)}
@@ -411,7 +411,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
variant="ghost"
size="sm"
className={css.controlBtn}
- icon={ }
+ icon={ }
aria-label={t('tasksZoomFit')}
title={t('tasksZoomFit')}
onClick={refit}
diff --git a/src/client/TasksPopovers.tsx b/src/client/TasksPopovers.tsx
index 0d5f42c..b198051 100644
--- a/src/client/TasksPopovers.tsx
+++ b/src/client/TasksPopovers.tsx
@@ -26,11 +26,11 @@
*/
import type { ReactNode } from 'react'
import clsx from 'clsx'
-import { Button, IconRightUpOutline14, StateDot, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconRightUpOutlineRegular, StateDot, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
import {
- AgentGlyph, WorkflowGlyph, flatten, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
+ AgentGlyph, WorkflowGlyph, flatten, modeLabel, nodeDotState, taskDotState, taskStatusKey, workflowStatusKey,
} from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -78,6 +78,8 @@ export function AgentNodePopover(props: {
onOpenTask(taskId: string, anchor: HTMLElement): void
}): ReactNode {
const { node, onJump, onOpenTask } = props
+ /** The row's mode word; `unknown` claims no mode and this row is omitted. */
+ const nodeMode = modeLabel(node.mode)
const liveText = node.live?.text !== undefined ? flatten(node.live.text) : undefined
const liveTool = node.live?.tool !== undefined
? `${node.live.tool.name}${node.live.tool.args === '' ? '' : ` ${node.live.tool.args}`}`
@@ -92,10 +94,8 @@ export function AgentNodePopover(props: {
{t(stateKey(node.state))}
- {node.mode !== undefined && (
-
- {node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
-
+ {nodeMode !== undefined && (
+ {nodeMode}
)}
{node.team !== undefined && (
@@ -141,7 +141,7 @@ export function AgentNodePopover(props: {
}
+ icon={ }
onClick={() => { onJump(node) }}
>
{t('tasksNodeJump')}
diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx
index 045b7f6..089e582 100644
--- a/src/client/TasksTree.tsx
+++ b/src/client/TasksTree.tsx
@@ -17,7 +17,7 @@
*/
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
-import { IconLoadingOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconLoadingOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
import {
AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine,
@@ -200,7 +200,7 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
>
{loading === true && roots.length === 0 && (
-
+
{t('loading')}
)}
diff --git a/src/client/TeamBoard.tsx b/src/client/TeamBoard.tsx
index a3c0a7c..559b38a 100644
--- a/src/client/TeamBoard.tsx
+++ b/src/client/TeamBoard.tsx
@@ -20,9 +20,9 @@
*/
import { useMemo, useRef, useState, type ReactNode } from 'react'
import {
- Button, IconChecklistOutline14, IconCheckOutline14, IconChevronUpOutline14, IconEditOutline16,
- IconEllipsisOutline16, IconPlusOutline16, IconRefreshOutline14, IconTrashOutline16,
- IconUserOutline16, Menu, Pill, StateDot, Tag,
+ Button, IconChecklistOutlineRegular, IconCheckOutlineRegular, IconChevronUpOutlineRegular, IconEditOutlineRegular,
+ IconEllipsisOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
+ IconUserOutlineRegular, Menu, Pill, StateDot, Tag,
type MenuEntry, type TagTone,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
@@ -66,20 +66,20 @@ function taskMenuItems(
{
id: 'state',
label: t(completed ? 'teamTaskReopen' : 'teamTaskComplete'),
- icon: completed ? : ,
+ icon: completed ? : ,
},
- { id: 'edit', label: t('teamTaskEdit'), icon: },
+ { id: 'edit', label: t('teamTaskEdit'), icon: },
{
id: 'owner',
label: t('teamTaskOwner'),
- icon: ,
+ icon: ,
submenu: [
{ id: 'owner:', label: t('teamTaskUnowned') },
...teammates.map(member => ({ id: `owner:${member.name}`, label: member.name })),
],
},
{ type: 'separator', id: 'team-task-sep' },
- { id: 'delete', label: t('teamTaskDelete'), icon: },
+ { id: 'delete', label: t('teamTaskDelete'), icon: },
]
}
@@ -148,7 +148,7 @@ function TeamTaskRow(props: {
}
+ icon={ }
aria-label={`${t('teamTaskActions')} ${task.subject}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
@@ -191,7 +191,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
aria-expanded={!collapsed}
onClick={onToggleCollapsed}
>
-
+
{t('teamBoard')}
{t('teamChip', { members: members.length, tasks: live.length })}
@@ -201,7 +201,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
style={{ display: 'inline-flex', transform: collapsed ? undefined : 'rotate(180deg)' }}
aria-hidden="true"
>
-
+
{!collapsed && (
@@ -247,7 +247,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {
}
+ icon={ }
onClick={(event) => { onOpenTask(undefined, event.currentTarget) }}
>
{t('teamTaskCreate')}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 20d12fb..14fe5ad 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -333,7 +333,6 @@ export const ar: Record = {
codeBlockUnwrap: 'بدون التفاف للأسطر',
sideChatBlockWindow: '{shown} من {total} سطرًا',
sideChatConnDisconnected: 'انقطع الاتصال',
- sideChatConnReconnect: 'إعادة الاتصال',
sideChatConnConnecting: 'جارٍ إعادة الاتصال…',
sideChatConnRecovered: 'تم استعادة الاتصال',
sideChatConnReconnectAction: 'إعادة الاتصال الآن',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index e3d6ce5..e0754a6 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -318,7 +318,6 @@ export const de: Record = {
codeBlockUnwrap: 'Zeilen nicht umbrechen',
sideChatBlockWindow: '{shown} von {total} Zeilen',
sideChatConnDisconnected: 'Verbindung verloren',
- sideChatConnReconnect: 'Neu verbinden',
sideChatConnConnecting: 'Verbinde neu…',
sideChatConnRecovered: 'Verbindung wiederhergestellt',
sideChatConnReconnectAction: 'Jetzt neu verbinden',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index 629fbd4..7a91c45 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -325,7 +325,6 @@ export const fr: Record = {
codeBlockUnwrap: 'Ne pas retourner à la ligne',
sideChatBlockWindow: '{shown} sur {total} lignes',
sideChatConnDisconnected: 'Connexion perdue',
- sideChatConnReconnect: 'Reconnecter',
sideChatConnConnecting: 'Reconnexion…',
sideChatConnRecovered: 'Connexion rétablie',
sideChatConnReconnectAction: 'Reconnecter maintenant',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index e9f8175..b21bd38 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -332,7 +332,6 @@ export const hi: Record = {
codeBlockUnwrap: 'पंक्तियाँ न लपेटें',
sideChatBlockWindow: 'कुल {total} में से {shown} पंक्तियाँ',
sideChatConnDisconnected: 'कनेक्शन टूट गया',
- sideChatConnReconnect: 'फिर से कनेक्ट करें',
sideChatConnConnecting: 'फिर से कनेक्ट हो रहा है…',
sideChatConnRecovered: 'कनेक्शन बहाल हुआ',
sideChatConnReconnectAction: 'अभी फिर से कनेक्ट करें',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 91b9ef6..499ce5e 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -330,7 +330,6 @@ export const id: Record = {
codeBlockUnwrap: 'Jangan bungkus baris',
sideChatBlockWindow: '{shown} dari {total} baris',
sideChatConnDisconnected: 'Koneksi terputus',
- sideChatConnReconnect: 'Sambungkan ulang',
sideChatConnConnecting: 'Menyambung ulang…',
sideChatConnRecovered: 'Koneksi dipulihkan',
sideChatConnReconnectAction: 'Sambungkan ulang sekarang',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 01c24ab..7201a55 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -323,7 +323,6 @@ export const it: Record = {
codeBlockUnwrap: 'Nessun a capo',
sideChatBlockWindow: '{shown} di {total} righe',
sideChatConnDisconnected: 'Connessione persa',
- sideChatConnReconnect: 'Riconnetti',
sideChatConnConnecting: 'Riconnessione…',
sideChatConnRecovered: 'Connessione ripristinata',
sideChatConnReconnectAction: 'Riconnetti ora',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index 43d1387..25dd042 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -332,7 +332,6 @@ export const ja: Record = {
codeBlockUnwrap: '折り返しを無効にする',
sideChatBlockWindow: '全 {total} 行中 {shown} 行を表示',
sideChatConnDisconnected: '接続が切断されました。クリックで再接続',
- sideChatConnReconnect: '再接続',
sideChatConnConnecting: '再接続中…',
sideChatConnRecovered: '接続が復旧しました',
sideChatConnReconnectAction: '今すぐ再接続',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index e6549e7..0d86480 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -324,7 +324,6 @@ export const ko: Record = {
codeBlockUnwrap: '줄 바꿈 해제',
sideChatBlockWindow: '전체 {total}줄 중 {shown}줄 표시',
sideChatConnDisconnected: '연결이 끊겼습니다',
- sideChatConnReconnect: '다시 연결',
sideChatConnConnecting: '다시 연결 중…',
sideChatConnRecovered: '연결이 복원되었습니다',
sideChatConnReconnectAction: '지금 다시 연결',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 0959634..c1f65c0 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -330,7 +330,6 @@ export const nl: Record = {
codeBlockUnwrap: 'Regels niet afbreken',
sideChatBlockWindow: '{shown} van {total} regels',
sideChatConnDisconnected: 'Verbinding verbroken',
- sideChatConnReconnect: 'Opnieuw verbinden',
sideChatConnConnecting: 'Opnieuw verbinden…',
sideChatConnRecovered: 'Verbinding hersteld',
sideChatConnReconnectAction: 'Nu opnieuw verbinden',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 16ebee6..73a4387 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -334,7 +334,6 @@ export const pl: Record = {
codeBlockUnwrap: 'Bez zawijania wierszy',
sideChatBlockWindow: '{shown} z {total} wierszy',
sideChatConnDisconnected: 'Połączenie utracone',
- sideChatConnReconnect: 'Połącz ponownie',
sideChatConnConnecting: 'Łączenie ponownie…',
sideChatConnRecovered: 'Połączenie przywrócone',
sideChatConnReconnectAction: 'Połącz ponownie teraz',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 19005df..262d277 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -315,7 +315,6 @@ export const pt: Record = {
codeBlockUnwrap: 'Não quebrar linhas',
sideChatBlockWindow: '{shown} de {total} linhas',
sideChatConnDisconnected: 'Conexão perdida',
- sideChatConnReconnect: 'Reconectar',
sideChatConnConnecting: 'Reconectando…',
sideChatConnRecovered: 'Conexão restabelecida',
sideChatConnReconnectAction: 'Reconectar agora',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index dcf9a16..8cf6b70 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -330,7 +330,6 @@ export const ru: Record = {
codeBlockUnwrap: 'Не переносить строки',
sideChatBlockWindow: '{shown} из {total} строк',
sideChatConnDisconnected: 'Соединение потеряно',
- sideChatConnReconnect: 'Переподключиться',
sideChatConnConnecting: 'Переподключение…',
sideChatConnRecovered: 'Соединение восстановлено',
sideChatConnReconnectAction: 'Переподключиться сейчас',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 0bf54fa..8111969 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -315,7 +315,6 @@ export const sv: Record = {
codeBlockUnwrap: 'Ingen radbrytning',
sideChatBlockWindow: '{shown} av {total} rader',
sideChatConnDisconnected: 'Anslutningen bröts',
- sideChatConnReconnect: 'Anslut igen',
sideChatConnConnecting: 'Ansluter igen…',
sideChatConnRecovered: 'Anslutningen återställd',
sideChatConnReconnectAction: 'Anslut igen nu',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 4e95bfb..61f4553 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -332,7 +332,6 @@ export const th: Record = {
codeBlockUnwrap: 'ไม่ตัดบรรทัด',
sideChatBlockWindow: '{shown} จาก {total} บรรทัด',
sideChatConnDisconnected: 'การเชื่อมต่อขาดหาย',
- sideChatConnReconnect: 'เชื่อมต่อใหม่',
sideChatConnConnecting: 'กำลังเชื่อมต่อใหม่…',
sideChatConnRecovered: 'กู้คืนการเชื่อมต่อแล้ว',
sideChatConnReconnectAction: 'เชื่อมต่อใหม่ตอนนี้',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index c101c7c..cd92405 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -332,7 +332,6 @@ export const tr: Record = {
codeBlockUnwrap: 'Satırları kaydırma',
sideChatBlockWindow: '{total} satırdan {shown} satır',
sideChatConnDisconnected: 'Bağlantı koptu',
- sideChatConnReconnect: 'Yeniden bağlan',
sideChatConnConnecting: 'Yeniden bağlanıyor…',
sideChatConnRecovered: 'Bağlantı geri yüklendi',
sideChatConnReconnectAction: 'Şimdi yeniden bağlan',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index c48e67d..f6bf0c5 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -332,7 +332,6 @@ export const vi: Record = {
codeBlockUnwrap: 'Không ngắt dòng',
sideChatBlockWindow: '{shown} trong {total} dòng',
sideChatConnDisconnected: 'Mất kết nối',
- sideChatConnReconnect: 'Kết nối lại',
sideChatConnConnecting: 'Đang kết nối lại…',
sideChatConnRecovered: 'Đã khôi phục kết nối',
sideChatConnReconnectAction: 'Kết nối lại ngay',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index d59489a..f73f1dd 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -347,7 +347,6 @@ export const zhHK: Record = {
codeBlockUnwrap: '取消自動換行',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
- sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index 6dbe7e4..5b74383 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -347,7 +347,6 @@ export const zhMO: Record = {
codeBlockUnwrap: '取消自動換行',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
- sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index 8fcb2c5..a0e7e57 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -347,7 +347,6 @@ export const zhTW: Record = {
codeBlockUnwrap: '取消自動換行',
sideChatBlockWindow: '共 {total} 行 · 顯示 {shown} 行',
sideChatConnDisconnected: '連線已中斷',
- sideChatConnReconnect: '重新連線',
sideChatConnConnecting: '正在重新連線…',
sideChatConnRecovered: '連線已恢復',
sideChatConnReconnectAction: '立即重新連線',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index f017f0e..54e9126 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -344,7 +344,6 @@ export const zh = {
codeBlockUnwrap: '取消自动换行',
sideChatBlockWindow: '共 {total} 行 · 显示 {shown} 行',
sideChatConnDisconnected: '连接已断开,点击立即重连',
- sideChatConnReconnect: '重新连接',
sideChatConnConnecting: '正在重连…',
sideChatConnRecovered: '连接已恢复',
sideChatConnReconnectAction: '立即重连',
@@ -815,7 +814,6 @@ export const en: Record = {
codeBlockUnwrap: 'Do not wrap lines',
sideChatBlockWindow: '{shown} of {total} lines',
sideChatConnDisconnected: 'Disconnected, reconnect now',
- sideChatConnReconnect: 'Reconnect',
sideChatConnConnecting: 'Reconnecting…',
sideChatConnRecovered: 'Connection restored',
sideChatConnReconnectAction: 'Reconnect now',
diff --git a/src/client/subagent-catalog.ts b/src/client/subagent-catalog.ts
index b6ccafc..aadf370 100644
--- a/src/client/subagent-catalog.ts
+++ b/src/client/subagent-catalog.ts
@@ -81,7 +81,7 @@ export function subagentCatalogs(
* @param childSessionId - the child row whose own catalog decides.
*/
export function isKnownLeaf(
- catalogs: Readonly>,
+ catalogs: Readonly>,
childSessionId: string,
): boolean {
const catalog = catalogs[childSessionId]
@@ -97,7 +97,7 @@ export function isKnownLeaf(
* @param childSessionId - the child row being drawn.
*/
export function childActivity(
- live: Readonly>,
+ live: Readonly>,
childSessionId: string,
): 'running' | 'inactive' {
return live[childSessionId] === undefined ? 'inactive' : 'running'
diff --git a/src/client/tasks-model.ts b/src/client/tasks-model.ts
index ede2281..320a51b 100644
--- a/src/client/tasks-model.ts
+++ b/src/client/tasks-model.ts
@@ -1,14 +1,14 @@
/**
* The unified view model of the Tasks page: one ordered node list the graph
* canvas AND the tree mode both render (same data, same fold state). Pure
- * derivation over the sessions list feed (byId lineage + per-parent
- * catalogs), the live activity map, the folded workflow runs, and the
- * optional team view — kept framework-free for node-environment unit tests.
+ * derivation over the sessions list feed (byId lineage + the host's
+ * per-session `subagentCatalog` projection views), the live activity map, the
+ * folded workflow runs, and the optional team view — kept framework-free for
+ * node-environment unit tests.
*
* Shape rules:
- * - agent rows walk the lazy per-parent catalogs in pre-order (the same
- * recursion the classic tree used); Side Chat threads and diagnostic rows
- * never become nodes;
+ * - agent rows walk the per-parent catalog views in pre-order (the same
+ * recursion the classic tree used); Side Chat threads never become nodes;
* - a workflow run hangs under its ORIGIN agent; member agents that already
* exist as the origin's catalog children are RE-PARENTED under the run
* node, members without a catalog row are synthesized from the run's own
@@ -22,14 +22,14 @@
import type {
SidebarSessionSummary,
SidebarSubagentAddress,
- SidebarSubagentCatalog,
- SidebarSubagentChildEntry,
+ SidebarSubagentCatalogEntry,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../context-types.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import { isSideThreadSummary } from './subagent-detect.ts'
+import { childActivity, isKnownLeaf, type SubagentCatalogView } from './subagent-catalog.ts'
/** Display state of one agent node (drives the dot + fold candidacy). */
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
@@ -52,7 +52,8 @@ export interface TasksAgentNode {
label: string
/** The summary's display title when it differs from the label. */
title?: string
- mode?: 'one-shot' | 'continuable'
+ /** The catalog row's mode; `unknown` claims neither and renders nothing. */
+ mode?: SidebarSubagentCatalogEntry['mode']
state: TasksNodeState
/** The catalog's raw activity word (the secondary line localizes it). */
activity: 'running' | 'inactive'
@@ -103,7 +104,8 @@ export type TasksNode = TasksAgentNode | TasksWorkflowNode | TasksFoldNode
/** Inputs of the model build (all already-resolved client mirrors). */
export interface TasksModelInput {
byId: Readonly>
- catalogs: Readonly>
+ /** Per-parent catalog views (DSH 0.1.7 projectors, see {@link subagentCatalogs}). */
+ catalogs: Readonly>
rootId: string
currentSessionId: string
live: Readonly>
@@ -117,7 +119,7 @@ export interface TasksModelInput {
/** Human label of one catalog child (the classic rule). */
function childLabel(
- entry: SidebarSubagentChildEntry,
+ entry: SidebarSubagentCatalogEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
@@ -196,26 +198,27 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const knownAgentIds = new Set()
for (const catalog of Object.values(catalogs)) {
if (catalog?.state !== 'ready') continue
- for (const entry of catalog.entries) {
- if (entry.kind === 'child') knownAgentIds.add(entry.id)
- }
+ for (const entry of catalog.entries) knownAgentIds.add(entry.id)
}
// The local children lists of `appendChildren` are built lazily per parent,
// so this global set is what makes the guard below order-independent.
- /** The agent children of one parent, in catalog order (side threads and
- * diagnostics excluded), with workflow runs appended in start order. */
+ /** The agent children of one parent, in catalog order (side threads
+ * excluded), with workflow runs appended in start order. */
const appendChildren = (parentId: string): void => {
const catalog = catalogs[parentId]
const entries = catalog?.state === 'ready' ? catalog.entries : []
const agentChildren: TasksAgentNode[] = []
for (const entry of entries) {
- if (entry.kind !== 'child') continue
const summary = byId[entry.id]
if (summary !== undefined && isSideThreadSummary(summary)) continue
if (entry.label?.startsWith('Side: ') ?? false) continue
const team = teamOf(entry.id)
- const state: TasksNodeState = entry.activity === 'running'
+ // DSH 0.1.7's catalog row carries the identity only: the live channel
+ // folds RUNNING children (absence = not running) and a row keeps its
+ // disclosure unless its own catalog is known-empty.
+ const activity = childActivity(live, entry.id)
+ const state: TasksNodeState = activity === 'running'
? 'running'
: team !== undefined ? teamState(team.status) : 'done'
agentChildren.push({
@@ -227,12 +230,12 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] {
? { title: summary.displayTitle } : {}),
mode: entry.mode,
state,
- activity: entry.activity,
+ activity,
current: entry.id === currentSessionId,
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
...(team !== undefined ? { team } : {}),
...(tasksByNode.get(entry.id) !== undefined ? { tasks: tasksByNode.get(entry.id) } : {}),
- hasChildren: entry.hasChildren,
+ hasChildren: !isKnownLeaf(catalogs, entry.id),
childAddress: { parentSessionId: parentId, childSessionId: entry.id, mode: entry.mode },
})
}
diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx
index d83e077..9e21341 100644
--- a/src/client/tasks-shared.tsx
+++ b/src/client/tasks-shared.tsx
@@ -13,7 +13,7 @@
*/
import type { ReactNode } from 'react'
import {
- IconAgentPresetOutline16, IconBranchOutline16, IconChecklistOutline14, IconUserOutline16,
+ IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconUserOutlineRegular,
type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { LastActivity } from '../subagent-activity.ts'
@@ -75,18 +75,33 @@ export function flatten(text: string): string {
/** The host icon of one agent node (the lead and plain children share one). */
export function AgentGlyph(props: { node: TasksAgentNode; size?: number }): ReactNode {
const size = props.size ?? 11
- if (props.node.team?.role === 'teammate') return
- return
+ if (props.node.team?.role === 'teammate') return
+ return
}
/** The workflow-run glyph. */
export function WorkflowGlyph(props: { size?: number }): ReactNode {
- return
+ return
}
/** The fold aggregate glyph. */
export function FoldGlyph(props: { size?: number }): ReactNode {
- return
+ return
+}
+
+/**
+ * The mode word of one catalog row, or undefined when it names no mode. 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.
+ */
+export function modeLabel(mode: TasksAgentNode['mode']): string | undefined {
+ switch (mode) {
+ case 'one-shot': return t('subagentModeOneShot')
+ case 'continuable': return t('subagentModeContinuable')
+ case 'unknown':
+ case undefined: return undefined
+ }
}
/**
@@ -101,10 +116,8 @@ export function agentMeta(node: TasksAgentNode): string {
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return `${node.team.model} · ${state}`
}
- if (node.mode !== undefined) {
- return `${node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')} · ${state}`
- }
- return state
+ const mode = modeLabel(node.mode)
+ return mode === undefined ? state : `${mode} · ${state}`
}
/** The mono meta line of a workflow run card: status · member tally. */
@@ -167,7 +180,7 @@ export function TaskLine(props: {
props.onOpenTask(primary.id, event.currentTarget)
}}
>
-
+
{primary.subject}
{t(taskStatusKey(primary.status))}
{tasks.length > 1 && {`+${tasks.length - 1}`} }
diff --git a/src/client/tool-icons.tsx b/src/client/tool-icons.tsx
index 6531adf..6df6ba7 100644
--- a/src/client/tool-icons.tsx
+++ b/src/client/tool-icons.tsx
@@ -7,13 +7,13 @@
*/
import type { ReactNode } from 'react'
import {
- IconCodeOutline16,
- IconEditOutline16,
- IconEllipsisOutline16,
- IconGlobeOutline14,
- IconPlayOutline16,
- IconSearchOutline16,
- IconThinkOutline16,
+ IconCodeOutlineRegular,
+ IconEditOutlineRegular,
+ IconEllipsisOutlineRegular,
+ IconGlobeOutlineRegular,
+ IconPlayOutlineRegular,
+ IconSearchOutlineRegular,
+ IconThinkOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
/** One glyph renderer (host icon components accept a pixel size). */
@@ -21,21 +21,21 @@ type Glyph = (size: number) => ReactNode
/** The explicit tool-family map (longest-prefix wins at lookup). */
const TOOL_GLYPHS: ReadonlyArray = [
- ['read', size => ],
- ['write', size => ],
- ['edit', size => ],
- ['glob', size => ],
- ['grep', size => ],
- ['search', size => ],
- ['bash', size => ],
- ['shell', size => ],
- ['web', size => ],
- ['fetch', size => ],
- ['think', size => ],
+ ['read', size => ],
+ ['write', size => ],
+ ['edit', size => ],
+ ['glob', size => ],
+ ['grep', size => ],
+ ['search', size => ],
+ ['bash', size => ],
+ ['shell', size => ],
+ ['web', size => ],
+ ['fetch', size => ],
+ ['think', size => ],
]
/** The fallback glyph for unmapped tools. */
-const FALLBACK_GLYPH: Glyph = size =>
+const FALLBACK_GLYPH: Glyph = size =>
/**
* The glyph of one tool name: exact match first, then a prefix match so
diff --git a/src/context-types.ts b/src/context-types.ts
index 82582cc..9e110ce 100644
--- a/src/context-types.ts
+++ b/src/context-types.ts
@@ -390,10 +390,14 @@ export interface SidebarSessionList {
* this plugin reads is `subagentCatalog`, the direct-child list the 0.1.6
* runtime published as `subagentsByParent`.
*
- * Two facts the 0.1.6 snapshot carried are gone and must not be re-read:
- * there is no `current` session id (`ctx.sidebarRight.mounted` is the
- * sanctioned feed for "which session's seat is on screen"), and there is no
- * background-jobs mirror (the `jobs.list` route reads the registry itself).
+ * Three facts the 0.1.6 snapshot carried are gone and must not be brought
+ * back: there is no `current` session id (`ctx.sidebarRight.mounted` is the
+ * sanctioned feed for "which session's seat is on screen"), there is no
+ * background-jobs mirror (the `jobs.list` route reads the registry itself),
+ * and there is no per-parent observe handshake — 0.1.7 loads every session's
+ * projections once per connection, so a catalog surface reads them instead
+ * of observing and unobserving (0.1.6's `setSubagentCatalogOpen` is deleted,
+ * not renamed).
*/
projectionsBySession?: Readonly>
}
@@ -460,10 +464,6 @@ export interface SidebarSessionsService {
* Resolve an already discovered direct-parent address without opening it.
*/
subagentAddress?(id: string): SidebarSubagentAddress | undefined
- /**
- * Mark whether a catalog surface is consuming live membership updates.
- */
- setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
/**
* Refresh one direct-child catalog.
*/
diff --git a/tests/chunk-artifact.spec.ts b/tests/chunk-artifact.spec.ts
index d2b99e3..d6d6e8b 100644
--- a/tests/chunk-artifact.spec.ts
+++ b/tests/chunk-artifact.spec.ts
@@ -14,10 +14,17 @@ import { describe, expect, it } from 'vitest'
// (CodeMirror's UA probe).
import './browser-globals.ts'
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
+import { CHUNK_NAMES } from '../src/bundle-route.ts'
const g = globalThis as Record
-const CHUNKS = ['editor', 'mermaid']
+/**
+ * The chunk set, DERIVED from the host route's allowlist — the same registry
+ * `/sidebar/bundle` serves from (src/bundle-route.ts). Never hand-mirrored:
+ * the hand-written list that used to live here drifted (`locale` was
+ * missing), so the locale artifact went unguarded.
+ */
+const CHUNKS = CHUNK_NAMES
/** All chunk artifacts present (tsdown emits the whole lib/ in one run). */
const chunksBuilt = CHUNKS.every(name => existsSync(`lib/client-${name}.js`))
diff --git a/tests/manifest-consistency.spec.ts b/tests/manifest-consistency.spec.ts
index c15333d..eec7e9a 100644
--- a/tests/manifest-consistency.spec.ts
+++ b/tests/manifest-consistency.spec.ts
@@ -146,6 +146,21 @@ describe.skipIf(!libBuilt)('registry manifest consistency (dsh.plugin.json)', ()
}
})
+ it('every chunk is staged by scripts/package-registry.mjs (the registry channel ships its own file list)', () => {
+ // The community-registry channel installs the staged `registry/` tree
+ // wholesale, and /sidebar/bundle reads the chunk from THAT copy — a chunk
+ // missing from the staging list 404s there exactly like one missing from
+ // package.json#files does on npm. The list is read as source text because
+ // the script performs its copies at module scope (it cannot be imported).
+ const script = readFileSync(resolve(ROOT, 'scripts/package-registry.mjs'), 'utf8')
+ const start = script.indexOf('const files = [')
+ expect(start, 'scripts/package-registry.mjs declares no `const files = [` list').toBeGreaterThan(-1)
+ const staged = script.slice(start, script.indexOf(']', start))
+ for (const file of CHUNK_FILES) {
+ expect(staged.includes(`'${file}'`), `${file} is not staged by scripts/package-registry.mjs`).toBe(true)
+ }
+ })
+
it('client bundles require only frozen module-table entries', () => {
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
diff --git a/tests/subagent-jobs-view.spec.tsx b/tests/subagent-jobs-view.spec.tsx
index 8c1da5f..c2b2801 100644
--- a/tests/subagent-jobs-view.spec.tsx
+++ b/tests/subagent-jobs-view.spec.tsx
@@ -1,9 +1,13 @@
/**
- * Tasks page tests for the background-jobs DRAWER: rows render from the
- * `jobsBySession` mirror, clicking a row peeks its output in an anchored
- * popover (portaled to document.body) through `jobs.output` with the OWNER
- * session scope, the kill button needs a two-click confirm, settled rows
- * offer no kill, and the output popover never polls while the page is hidden.
+ * Tasks page tests: the projection-backed topology (branch expansion inferred
+ * from the child's OWN catalog, the `mode: 'unknown'` row, navigation through
+ * the workspace face, catalog retry through the 0.1.7 refresh name) and the
+ * background-jobs DRAWER — rows read through the plugin's `jobs.list` route
+ * one session at a time (the registry's access fence admits a job to its OWNER
+ * only), clicking a row peeks its output in an anchored popover (portaled to
+ * document.body) through `jobs.output` with the owner scope, the kill button
+ * needs a two-click confirm, settled rows offer no kill, and nothing polls
+ * while the page is hidden.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -11,7 +15,13 @@ import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
-import type { Context, SidebarSessionList } from '../src/context-types.ts'
+import type {
+ Context,
+ SidebarJobView,
+ SidebarSessionList,
+ SidebarSubagentAddress,
+ SidebarSubagentCatalogEntry,
+} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
@@ -32,48 +42,59 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType
-/** The client context face SubagentView touches (history stub; everything else inert). */
-function makeCtx(store: Store): Context {
+/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
+interface NavigationSpy {
+ opened: Array
+}
+
+/**
+ * The client context face SubagentView touches. Navigation rides the
+ * workspace face: 0.1.6 moved main-view selection off `ISessions`, so a spy
+ * left on `sessions.openSubagent` would never fire.
+ */
+function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
- sessions: {
- list: store,
- setSubagentCatalogOpen: () => {},
- openSubagent: () => {},
- open: () => {},
- refreshSubagents: async () => {},
- },
- connection: {
- api: {
- subagents: {
- history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
- },
- },
- },
+ sessions: { list: store },
+ get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
+ ? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
+ : undefined),
} as unknown as Context
}
const outputCalls: Array<{ sessionId: string; id: string }> = []
const killCalls: Array<{ sessionId: string; id: string }> = []
+/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
+let jobsByOwner: Record = {}
+/** The owner sessions the page actually read (the fence's unit of access). */
+let jobListCalls: string[] = []
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
+/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
+function entry(
+ id: string,
+ mode: SidebarSubagentCatalogEntry['mode'],
+ label?: string,
+): SidebarSubagentCatalogEntry {
+ return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) }
+}
+
+/** One session's loaded projection snapshot. */
+function ready(entries: SidebarSubagentCatalogEntry[]) {
+ return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null }
+}
+
function baseSnapshot(): SidebarSessionList {
return {
- current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话', running: true },
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
},
- subagentsByParent: {},
- jobsBySession: {
- root: [
- { id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
- ],
- child: [
- { id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
- ],
+ projectionsBySession: {
+ root: ready([entry('child', 'continuable')]),
+ child: ready([]),
},
}
}
@@ -81,12 +102,22 @@ function baseSnapshot(): SidebarSessionList {
beforeEach(() => {
outputCalls.length = 0
killCalls.length = 0
+ jobListCalls = []
+ jobsByOwner = {
+ root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
+ child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }],
+ }
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
if (method === 'subagents.live') {
return jsonResponse({ ok: true, value: { live: {} } })
}
+ if (method === 'jobs.list') {
+ const owner = body.sessionId ?? ''
+ jobListCalls.push(owner)
+ return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } })
+ }
if (method === 'jobs.output') {
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
// bash-9 stands for a job the model never read (read:false).
@@ -116,15 +147,127 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
+ vi.useRealTimers()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
-describe('SubagentView background jobs', () => {
- it('renders the tree jobs with status, durations, and owner labels', () => {
+/**
+ * Flush the mount-time job reads: the poll fires immediately, and the request
+ * → envelope → json → Promise.all chain settles over several microtask ticks.
+ */
+async function flushJobs(): Promise {
+ for (let tick = 0; tick < 4; tick++) {
+ await act(async () => { await Promise.resolve() })
+ }
+}
+
+/** Render the page and wait for its first job read to land. */
+async function renderPage(store: Store, active = true, navigation?: NavigationSpy) {
+ const rendered = renderRoot(
+ createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }),
+ )
+ await flushJobs()
+ return rendered
+}
+
+describe('SubagentView topology (projectionsBySession)', () => {
+ it("expands a branch inferred from the child's own catalog", async () => {
+ const store = makeStore({
+ byId: {
+ root: { id: 'root', displayTitle: '主会话' },
+ child: { id: 'child', displayTitle: 'Worker', origin: 'subagent', parentId: 'root' },
+ grand: { id: 'grand', displayTitle: 'Grandchild', origin: 'subagent', parentId: 'child' },
+ great: { id: 'great', displayTitle: 'Great', origin: 'subagent', parentId: 'grand' },
+ },
+ projectionsBySession: {
+ root: ready([entry('child', 'continuable', 'Worker')]),
+ // The child carries a child of its own: 0.1.7 rows have no
+ // `hasChildren`, it is the child's own catalog that says so.
+ child: ready([entry('grand', 'one-shot', 'Grandchild')]),
+ grand: ready([entry('great', 'one-shot', 'Great')]),
+ great: ready([]),
+ },
+ })
+ const { container, unmount } = await renderPage(store)
+ expect(container.textContent).toContain('Worker')
+ expect(container.textContent).toContain('Grandchild')
+ expect(container.textContent).toContain('可续接')
+ expect(container.textContent).toContain('一次性')
+ unmount()
+ })
+
+ it('never claims a mode for an unknown row', async () => {
+ const store = makeStore({
+ byId: { root: { id: 'root', displayTitle: '主会话' } },
+ projectionsBySession: {
+ // 0.1.7's third mode: a child the host kept without a readable
+ // descriptor. It claims neither mode, so the card states neither.
+ root: ready([entry('opaque', 'unknown', 'Opaque child')]),
+ opaque: ready([]),
+ },
+ })
+ const { container, unmount } = await renderPage(store)
+ expect(container.textContent).toContain('Opaque child')
+ expect(container.textContent).not.toContain('一次性')
+ expect(container.textContent).not.toContain('可续接')
+ unmount()
+ })
+
+ it('opens a child through the workspace face (the sessions face is gone since 0.1.6)', async () => {
+ const navigation: NavigationSpy = { opened: [] }
const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ const { container, unmount } = await renderPage(store, true, navigation)
+ // Unfold so the settled child is reachable, open its detail window and
+ // use the jump button (the card itself is the detail affordance).
+ const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement
+ await act(async () => { foldToggle.click() })
+ const node = container.querySelector('[data-graph-node="child"]') as HTMLElement
+ await act(async () => { node.click() })
+ const jump = [...document.querySelectorAll('[role="dialog"] button')]
+ .find(button => button.textContent?.includes('查看转录')) as HTMLButtonElement | undefined
+ await act(async () => { jump?.click() })
+ expect(navigation.opened).toEqual([
+ { parentSessionId: 'root', childSessionId: 'child', mode: 'continuable' },
+ ])
+ unmount()
+ })
+
+ it('retries a failed catalog through the 0.1.7 refresh name', async () => {
+ const refreshed: string[] = []
+ const store = makeStore({
+ byId: { root: { id: 'root', displayTitle: '主会话' } },
+ projectionsBySession: {
+ root: { values: {}, state: 'error', error: { code: 'boom', message: '目录读取失败' } },
+ },
+ })
+ const rendered = renderRoot(createElement(SubagentView, {
+ sessionId: 'root',
+ active: true,
+ ctx: {
+ sessions: {
+ list: store,
+ refreshProjections: async (sessionId: string) => { refreshed.push(sessionId) },
+ },
+ get: () => undefined,
+ } as unknown as Context,
+ }))
+ await flushJobs()
+ // The banner names the failed branch count; the per-row message lives in
+ // the catalog view the page folds (see `subagent-catalog.ts`).
+ expect(rendered.container.textContent).toContain('1 个分支加载失败')
+ const retry = [...rendered.container.querySelectorAll('button')]
+ .find(button => button.textContent?.includes('重试'))
+ expect(retry).toBeDefined()
+ await act(async () => { retry?.click() })
+ expect(refreshed).toEqual(['root'])
+ rendered.unmount()
+ })
+})
+
+describe('SubagentView background jobs', () => {
+ it('renders the tree jobs with status, durations, and owner labels', async () => {
+ const store = makeStore(baseSnapshot())
+ const { container, unmount } = await renderPage(store)
const text = container.textContent ?? ''
expect(text).toContain('后台任务')
expect(text).toContain('2 个后台任务 · 1 运行中')
@@ -133,43 +276,50 @@ describe('SubagentView background jobs', () => {
expect(text).toContain('echo hi')
expect(text).toContain('主会话')
expect(text).toContain('子代理')
+ // The fence admits a job to its owner only, so the tree costs one read per
+ // tree session (and never a read of a session outside the tree).
+ expect([...jobListCalls].sort()).toEqual(['child', 'root'])
// Only the running row offers a kill button.
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
unmount()
})
- it('renders nothing job-related when the mirror is empty', () => {
- const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ it('renders nothing job-related when the tree has no jobs', async () => {
+ jobsByOwner = {}
+ const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
+ const { container, unmount } = await renderPage(store)
expect(container.textContent).not.toContain('后台任务')
unmount()
})
- it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
- const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
- expect(container.textContent).toContain('sleep 300')
- // The mirror empties (all jobs settled and dropped): the section must
- // vanish WITHOUT reordering hooks — a hook below the empty-state return
- // would crash React with "Rendered fewer hooks than expected" (the
- // minified #300 the sidebar boundary surfaces with a retry button).
- await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
- expect(container.textContent).not.toContain('后台任务')
- // And returning jobs must work too, with the same hook order.
- await act(async () => { store.set(baseSnapshot()) })
- expect(container.textContent).toContain('sleep 300')
- unmount()
+ it('survives the job list emptying while mounted (hook-order regression #300)', async () => {
+ vi.useFakeTimers()
+ try {
+ const store = makeStore(baseSnapshot())
+ const { container, unmount } = await renderPage(store)
+ expect(container.textContent).toContain('sleep 300')
+ // Every job settles and drops: the section must vanish WITHOUT reordering
+ // hooks — a hook below the empty-state return would crash React with
+ // "Rendered fewer hooks than expected" (the minified #300 the sidebar
+ // boundary surfaces with a retry button).
+ jobsByOwner = {}
+ await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
+ expect(container.textContent).not.toContain('后台任务')
+ // And returning jobs must work too, with the same hook order.
+ jobsByOwner = {
+ root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
+ }
+ await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
+ expect(container.textContent).toContain('sleep 300')
+ unmount()
+ } finally {
+ vi.useRealTimers()
+ }
})
it('shows the selected job output in an anchored popover, dismissable', async () => {
const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ const { container, unmount } = await renderPage(store)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
// The peek request carries the OWNER session (the fence compares it).
@@ -188,9 +338,7 @@ describe('SubagentView background jobs', () => {
it('switches the single output popover between rows', async () => {
const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ const { container, unmount } = await renderPage(store)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(document.body.textContent).toContain('output-of-bash-1')
@@ -208,17 +356,14 @@ describe('SubagentView background jobs', () => {
})
it('explains when the model has not read the job yet', async () => {
- const snapshot = baseSnapshot()
- snapshot.jobsBySession = {
+ jobsByOwner = {
root: [
- ...(snapshot.jobsBySession?.root ?? []),
+ ...jobsByOwner.root ?? [],
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
],
}
- const store = makeStore(snapshot)
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ const store = makeStore(baseSnapshot())
+ const { container, unmount } = await renderPage(store)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() })
// read:false → the popover explains the output awaits the model's job_output
@@ -236,15 +381,9 @@ describe('SubagentView background jobs', () => {
startedAt: 1_000 + index,
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
}))
- const store = makeStore({
- current: 'root',
- byId: { root: { id: 'root', displayTitle: '主会话' } },
- subagentsByParent: {},
- jobsBySession: { root: many },
- })
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ jobsByOwner = { root: many }
+ const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
+ const { container, unmount } = await renderPage(store)
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
// Clicking a row anywhere in the long list still feeds the single popover.
@@ -257,9 +396,7 @@ describe('SubagentView background jobs', () => {
it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
- )
+ const { container, unmount } = await renderPage(store)
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
await act(async () => { kill.click() })
// First click only arms the confirm — no request leaves the page.
@@ -271,18 +408,17 @@ describe('SubagentView background jobs', () => {
unmount()
})
- it('does not poll the output while the page is hidden', async () => {
+ it('does not read jobs or poll output while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
- const { container, unmount } = renderRoot(
- createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
- )
- const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
- await act(async () => { row.click() })
+ const { container, unmount } = await renderPage(store, false)
+ // A hidden page issues no job read at all (it also has no rows to show).
+ expect(jobListCalls).toEqual([])
+ expect(container.textContent).not.toContain('后台任务')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
- // One peek for the initial load; the 2s poll never ran while inactive.
- expect(outputCalls).toHaveLength(1)
+ expect(jobListCalls).toEqual([])
+ expect(outputCalls).toEqual([])
unmount()
} finally {
vi.useRealTimers()
diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts
index 48cd5e1..3bf6fd4 100644
--- a/tests/tasks-model.spec.ts
+++ b/tests/tasks-model.spec.ts
@@ -13,10 +13,11 @@ import {
} from '../src/client/tasks-model.ts'
import type {
SidebarSessionSummary,
- SidebarSubagentCatalog,
- SidebarSubagentChildEntry,
+ SidebarSubagentCatalogEntry,
SidebarTeamMemberView,
} from '../src/context-types.ts'
+import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
+import type { LastActivity } from '../src/subagent-activity.ts'
import type { WorkflowRunView } from '../src/workflow-runs.ts'
/** A summary row. */
@@ -24,14 +25,19 @@ function summary(id: string, over: Partial = {}): Sidebar
return { id, displayTitle: id, ...over }
}
-/** A catalog child row. */
-function child(id: string, over: Partial = {}): SidebarSubagentChildEntry {
- return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', ...over }
+/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
+function child(id: string, over: Partial = {}): SidebarSubagentCatalogEntry {
+ return { id, createdAt: 0, mode: 'one-shot', ...over }
}
-/** A ready catalog. */
-function catalog(entries: SidebarSubagentCatalog['entries']): SidebarSubagentCatalog {
- return { entries, parentAvailable: true, state: 'ready', error: null }
+/** A loaded catalog view (what the projection folds into). */
+function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
+ return { entries, state: 'ready', error: null }
+}
+
+/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
+function running(...ids: string[]): Record {
+ return Object.fromEntries(ids.map(id => [id, { text: `${id} is working` }]))
}
/** A workflow run view. */
@@ -71,7 +77,7 @@ describe('buildTasksModel', () => {
it('walks the catalogs in pre-order and marks the current session', () => {
const model = buildTasksModel(input({
catalogs: {
- root: catalog([child('a'), child('b', { activity: 'inactive' })]),
+ root: catalog([child('a'), child('b')]),
a: catalog([child('a1')]),
},
byId: {
@@ -79,6 +85,7 @@ describe('buildTasksModel', () => {
a: summary('a'), b: summary('b'), a1: summary('a1'),
},
currentSessionId: 'a1',
+ live: running('a', 'a1'),
folded: false,
}))
expect(model.map(node => node.id)).toEqual(['root', 'a', 'a1', 'b'])
@@ -86,6 +93,9 @@ describe('buildTasksModel', () => {
expect(agents(model).find(node => node.id === 'a1')?.childAddress).toEqual({
parentSessionId: 'a', childSessionId: 'a1', mode: 'one-shot',
})
+ // Activity comes from the live channel, and `b` (absent there) is settled.
+ expect(agents(model).find(node => node.id === 'a')?.state).toBe('running')
+ expect(agents(model).find(node => node.id === 'b')?.state).toBe('done')
})
it('excludes Side Chat threads from the model', () => {
@@ -108,7 +118,7 @@ describe('buildTasksModel', () => {
}],
})
const model = buildTasksModel(input({
- catalogs: { root: catalog([child('m1', { activity: 'inactive' })]) },
+ catalogs: { root: catalog([child('m1')]) },
byId: { root: summary('root'), m1: summary('m1', { displayTitle: 'M1' }) },
runs: [audit],
folded: false,
@@ -138,12 +148,15 @@ describe('buildTasksModel', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([
- child('done-1', { activity: 'inactive' }),
- child('done-2', { activity: 'inactive' }),
- child('live', { activity: 'running' }),
- child('mate', { activity: 'inactive' }),
- child('parent', { activity: 'inactive', hasChildren: true }),
+ child('done-1'), child('done-2'), child('live'), child('mate'), child('parent'),
]),
+ // A KNOWN LEAF is a child whose OWN catalog loaded empty (0.1.7 rows
+ // carry no `hasChildren`): only those two are fold candidates.
+ 'done-1': catalog([]),
+ 'done-2': catalog([]),
+ 'live': catalog([]),
+ 'mate': catalog([]),
+ // `parent` has a child of its own, so it keeps its whole branch.
parent: catalog([child('grandchild')]),
},
byId: {
@@ -152,6 +165,7 @@ describe('buildTasksModel', () => {
live: summary('live'), mate: summary('mate'),
parent: summary('parent'), grandchild: summary('grandchild'),
},
+ live: running('live'),
teamMembers: [member({ id: 'mate', status: 'idle' })],
}))
expect(model.map(node => node.id)).toEqual([
@@ -161,9 +175,17 @@ describe('buildTasksModel', () => {
expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] })
})
+ it('keeps a child whose own catalog is unknown (never folds on missing data)', () => {
+ const model = buildTasksModel(input({
+ catalogs: { root: catalog([child('opaque')]) },
+ byId: { root: summary('root'), opaque: summary('opaque') },
+ }))
+ expect(model.map(node => node.id)).toEqual(['root', 'opaque'])
+ })
+
it('unfolds everything when folded is false', () => {
const base = input({
- catalogs: { root: catalog([child('done-1', { activity: 'inactive' })]) },
+ catalogs: { root: catalog([child('done-1')]) },
byId: { root: summary('root'), 'done-1': summary('done-1') },
folded: false,
})
@@ -204,7 +226,7 @@ describe('buildTasksModel: duplicate workflow members', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a')]),
- a: catalog([child('m1', { activity: 'inactive' })]),
+ a: catalog([child('m1')]),
},
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
runs: [audit],
diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx
index f5e9d4c..9ae75a9 100644
--- a/tests/tasks-page.spec.tsx
+++ b/tests/tasks-page.spec.tsx
@@ -12,9 +12,12 @@ import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type {
Context,
+ SidebarJobView,
+ SidebarProjectionSnapshot,
SidebarSessionList,
SidebarSessionSummary,
- SidebarSubagentCatalog,
+ SidebarSubagentAddress,
+ SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
@@ -36,16 +39,21 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType
+/** The navigation spy the page must reach (DSH 0.1.7 `ctx.uiWorkspace`). */
+interface NavigationSpy {
+ opened: Array
+}
+
/** The client context face SubagentView touches. */
-function makeCtx(store: Store, spies: { openSubagent?: (address: unknown) => void } = {}): Context {
+function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
sessions: {
list: store,
- setSubagentCatalogOpen: () => {},
- openSubagent: spies.openSubagent ?? (() => {}),
- open: () => {},
- refreshSubagents: async () => {},
+ refreshProjections: async () => {},
},
+ get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
+ ? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
+ : undefined),
} as unknown as Context
}
@@ -56,11 +64,16 @@ function jsonResponse(value: unknown): Response {
let teamPayload: unknown = { available: false }
const teamMutations: Array<{ method: string; body: Record }> = []
const fetchedMethods: string[] = []
+/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
+let jobsByOwner: Record = {}
beforeEach(() => {
teamPayload = { available: false }
teamMutations.length = 0
fetchedMethods.length = 0
+ jobsByOwner = {
+ root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
+ }
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
fetchedMethods.push(method ?? '')
@@ -68,6 +81,9 @@ beforeEach(() => {
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
+ if (method === 'jobs.list') {
+ return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } })
+ }
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
teamMutations.push({ method: method ?? '', body })
return jsonResponse({
@@ -92,35 +108,42 @@ afterEach(() => {
for (const el of document.querySelectorAll('body > div')) el.remove()
})
-/** A snapshot with N direct children of root (all settled one-shots). */
+/**
+ * A snapshot with N direct children of root (all settled one-shots). The rows
+ * come from the host's `subagentCatalog` projection and each child carries a
+ * loaded EMPTY catalog of its own — DSH 0.1.7 rows have no `hasChildren`, so
+ * that is what makes them known leaves (and therefore fold candidates).
+ */
function snapshotWithChildren(count: number): SidebarSessionList {
const byId: Record = {
root: { id: 'root', displayTitle: '主会话', running: true },
}
- const entries: SidebarSubagentCatalog['entries'] = []
+ const projectionsBySession: Record = {}
+ const entries: SidebarSubagentCatalogEntry[] = []
for (let index = 0; index < count; index += 1) {
const id = `child-${index}`
byId[id] = { id, displayTitle: id, origin: 'subagent', parentId: 'root', running: false }
- entries.push({ kind: 'child', id, activity: 'inactive', hasChildren: false, mode: 'one-shot', label: `子代理 ${index}` })
+ entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
+ projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
}
- return {
- current: 'root',
- byId,
- subagentsByParent: {
- root: { entries, parentAvailable: true, state: 'ready', error: null },
- },
- jobsBySession: {
- root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
- },
+ projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
+ return { byId, projectionsBySession }
+}
+
+/** Flush the mount-time job reads (request → envelope → json → Promise.all). */
+async function flushJobs(): Promise {
+ for (let tick = 0; tick < 4; tick++) {
+ await act(async () => { await Promise.resolve() })
}
}
describe('Tasks page interactions', () => {
- it('keeps the jobs drawer open below the agent threshold', () => {
+ it('keeps the jobs drawer open below the agent threshold', async () => {
const store = makeStore(snapshotWithChildren(2))
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
+ await flushJobs()
// 3 agents (root + 2 children) < 8: the drawer renders its rows open.
expect(container.textContent).toContain('sleep 300')
expect(container.textContent).not.toContain('已自动折叠')
@@ -132,6 +155,7 @@ describe('Tasks page interactions', () => {
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
+ await flushJobs()
// 9 agents ≥ 8: collapsed, the auto note explains, no rows rendered.
expect(container.textContent).not.toContain('sleep 300')
expect(container.textContent).toContain('已自动折叠')
@@ -162,9 +186,9 @@ describe('Tasks page interactions', () => {
})
it('opens the node detail window on card click; the jump button goes to the transcript', async () => {
- const opened: unknown[] = []
+ const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
- const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
+ const ctx = makeCtx(store, navigation)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
@@ -174,14 +198,16 @@ describe('Tasks page interactions', () => {
const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
await act(async () => { node.click() })
// The card no longer jumps directly: it IS the detail affordance.
- expect(opened).toEqual([])
+ expect(navigation.opened).toEqual([])
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('节点详情')
const jump = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('查看转录'))
expect(jump).toBeDefined()
await act(async () => { jump?.click() })
- expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
+ // The workspace face owns main-view selection since 0.1.6; ISessions.openSubagent
+ // is gone from the runtime, so that is the call the page must make.
+ expect(navigation.opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
unmount()
})
@@ -218,9 +244,9 @@ describe('Tasks page interactions', () => {
describe('Tasks page graph interactions and team board', () => {
it('activates a graph node after a background pointerdown (no click theft)', async () => {
- const opened: unknown[] = []
+ const navigation: NavigationSpy = { opened: [] }
const store = makeStore(snapshotWithChildren(1))
- const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
+ const ctx = makeCtx(store, navigation)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
)
@@ -238,7 +264,7 @@ describe('Tasks page graph interactions and team board', () => {
await act(async () => { node.click() })
// The click is not stolen: the card's own action (its detail window) ran.
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
- expect(opened).toEqual([])
+ expect(navigation.opened).toEqual([])
unmount()
})
@@ -391,6 +417,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
+ await flushJobs()
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
const card = document.querySelector('[role="dialog"]') as HTMLElement
diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts
index 7897479..683f83d 100644
--- a/tests/theme.spec.ts
+++ b/tests/theme.spec.ts
@@ -197,104 +197,3 @@ describe('skin contract: the plugin owns no color of its own', () => {
}
})
})
-
-/**
- * Task-page migration guard (the shadcn/ui rework).
- *
- * The vendored components under `src/client/ui/**` and every migrated
- * task-page module must take their colors from a DSH token — either directly
- * (`var(--dsw-…)`) or through one of the shadcn aliases bridged in
- * `src/client/ui/theme.css` (`--background`, `--border`, …). Three ways that
- * can rot silently:
- *
- * 1. A color literal (`#hex`, `rgb()`, `oklch()`, …) — the host skin can no
- * longer repaint it, and the value stops flipping with dark mode.
- * 2. A Tailwind default-palette class (`bg-blue-500`, `text-white`) — its
- * value is defined by Tailwind's own theme, not by DSH.
- * 3. A `var()` in a migrated stylesheet pointing at neither a `--dsw-*` token
- * nor a bridged alias — a typo falls back to the initial color.
- *
- * Comments are stripped first: prose legitimately quotes the old values (the
- * Badge docstring records that upstream's `text-white` became the
- * `--destructive-foreground` token), and upstream issue numbers look like
- * three-digit hex colors.
- */
-const VENDORED_UI_DIR = 'src/client/ui'
-const MIGRATED_TASK_FILES = [
- 'src/client/SubagentView.tsx',
- 'src/client/SubagentView.module.css',
- 'src/client/TasksGraph.tsx',
- 'src/client/TasksTree.tsx',
- 'src/client/TaskWindow.tsx',
- 'src/client/TeamBoard.tsx',
- 'src/client/JobsDrawer.tsx',
- 'src/client/TasksPopovers.tsx',
- 'src/client/AnchoredPopover.tsx',
- 'src/client/tasks-shared.tsx',
- 'src/client/tasks-canvas.module.css',
-]
-
-/** Drop block comments and whole-line `//` comments (URLs/strings stay intact). */
-function stripComments(source: string): string {
- return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
-}
-
-/** Any spelled-out color value (the skin contract's forbidden set). */
-const COLOR_LITERAL = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color-mix|light-dark)\(/
-
-/** Tailwind's default palette families, which are not DSH tokens. */
-const PALETTE_CLASS
- = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|divide|shadow|accent|caret|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?\b/
-
-/** Paint declarations whose value must resolve to a token. */
-const PAINT_DECLARATION
- = /(?:^|;)\s*(background(?:-color|-image)?|color|border(?:-(?:top|right|bottom|left))?-color|border|outline(?:-color)?|fill|stroke|box-shadow|text-decoration-color|caret-color|accent-color|scrollbar-color)\s*:\s*([^;}]+)/g
-
-/** A paint declaration naming no token at all may only be inert. */
-const INERT_PAINT = /^(?:none|transparent|currentcolor|inherit|initial|unset|revert)$/i
-
-/** Every variable the theme bridge defines (or forwards) — the alias allowlist. */
-const THEME_ALIASES = new Set(
- [...readFileSync(resolve(ROOT, `${VENDORED_UI_DIR}/theme.css`), 'utf8').matchAll(/--([a-z0-9-]+)\s*:/g)]
- .map(match => match[1]),
-)
-
-/** `--dsw-*` is the contract; the bridged aliases and runtime vars are the only indirection. */
-function tokenAllows(name: string): boolean {
- return name.startsWith('dsw-') || name.startsWith('tw-') || name.startsWith('radix-') || THEME_ALIASES.has(name)
-}
-
-describe('skin contract: the migrated task page and the vendored ui/ own no color', () => {
- const uiFiles = readdirSync(resolve(ROOT, VENDORED_UI_DIR))
- .filter(name => /\.(?:tsx|ts|css)$/.test(name))
- .map(name => `${VENDORED_UI_DIR}/${name}`)
- const files = [...uiFiles, ...MIGRATED_TASK_FILES]
-
- it('covers the vendored components and the migrated task-page modules', () => {
- expect(uiFiles.length).toBeGreaterThan(10)
- for (const file of files) expect(readFileSync(resolve(ROOT, file), 'utf8').length, file).toBeGreaterThan(0)
- })
-
- it.each(files)('%s carries no color literal', (file) => {
- expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(COLOR_LITERAL)
- })
-
- it.each(files)('%s uses no Tailwind default-palette class', (file) => {
- expect(stripComments(readFileSync(resolve(ROOT, file), 'utf8')), file).not.toMatch(PALETTE_CLASS)
- })
-
- it('binds every paint declaration in the migrated stylesheets to a token', () => {
- for (const file of files.filter(name => name.endsWith('.css'))) {
- const styles = stripComments(readFileSync(resolve(ROOT, file), 'utf8'))
- for (const declaration of styles.matchAll(PAINT_DECLARATION)) {
- const [, property, value] = declaration
- const vars = [...(value ?? '').matchAll(/var\(--([a-z0-9-]+)/gi)].map(match => match[1] ?? '')
- if (vars.length === 0) {
- expect(INERT_PAINT.test((value ?? '').trim()), `${file}: ${property}: ${value}`).toBe(true)
- continue
- }
- for (const name of vars) expect(tokenAllows(name), `${file}: ${property}: var(--${name})`).toBe(true)
- }
- }
- })
-})