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: { {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 { {!collapsed && ( @@ -247,7 +247,7 @@ export function TeamBoard(props: TeamBoardProps): ReactNode {