fix(settings): use the host's @deepseek-ai/schemastery so volatile prefs apply
Every side-card switch was dead: settings.update returned OK and cordis.patch.yml was written, yet the running plugin never saw the new value and nothing reported an error. Root cause: the plugin declared Config with the plain npm `schemastery` (3.18.0), which has no volatile support. The host resolves plugin config via `runtime.Config['~standard'].validate()` — i.e. the plugin's OWN schemastery — so `meta.volatile` nodes were never wrapped in cosmokit volatile references. `cordis-plugin-loader`'s `_commitVolatile()` bails out as soon as `volatileEntries(fiber.config)` is empty, so every volatile-only edit was silently dropped: no in-place update, no plugin restart. Fix: import z from '@deepseek-ai/schemastery' (peer provided by the host at mount; pinned 3.18.4 as a devDependency for the dev tree), matching the repo's existing pnpm-workspace convention for @deepseek-ai/* peers. Verified: probe reports tabsIsVolatile=true and revision climbing 0->6 across six writes, each read-back matching the written value (previously the value was frozen and revision stuck at 1). Also in this change: - apes tab ids lose their colon (apes:tasks -> apesTasks, ...): the host's settings projection dropped map keys containing a colon. - sidebar prefs ns follows the profile entry id (`better-sidebar`). - subagent rows degrade to summary rows when the root catalog never hydrates. - docs/feedback-0.2.0-rc2-contract-gaps.md: four host contract gaps, gap 4 documenting the silent volatile-write drop above. Gates: typecheck 0, lint 0, build 0, test 131 files / 1385 passed.
This commit is contained in:
+90
-75
@@ -74,7 +74,7 @@ const JOB_KILL_ARM_MS = 3000
|
||||
* loading spinner — the session summaries are already in the mirror, so the
|
||||
* tree degrades to clickable summary rows instead.
|
||||
*/
|
||||
const CATALOG_HYDRATE_TIMEOUT_MS = 5000
|
||||
const CATALOG_HYDRATE_TIMEOUT_MS = 2500
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows;
|
||||
* Side Chat threads ride the same origin but are tab-strip conversations,
|
||||
@@ -160,72 +160,57 @@ function CatalogLoadingRows(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Degrade rows rendered when the root catalog never hydrates within
|
||||
* {@link CATALOG_HYDRATE_TIMEOUT_MS}: the session summaries are already in the
|
||||
* mirror (`byId`), so instead of pinning the page on a permanent "loading…"
|
||||
* spinner we render the known children as CLICKABLE rows that jump to the
|
||||
* child session. The address `mode` is inferred from the summary's `running`
|
||||
* flag (a running agent is continuable; a settled one is one-shot) — a
|
||||
* best-effort transport hint, since the durable catalog `mode` is unavailable
|
||||
* once hydration has given up.
|
||||
* Synthesize a durable-catalog-shaped view from the session summaries already
|
||||
* in the mirror, used ONLY as a fallback once the host never hydrates the real
|
||||
* per-parent catalog (the `summaryBackedLoading` + timeout degrade). Each
|
||||
* entry is built to the `SidebarSubagentChildEntry` contract so the OFFICIAL
|
||||
* {@link CatalogRows} renderer can consume it unchanged — live lines, mode
|
||||
* labels, diagnostic rows, nested branches and arrow-key navigation all come
|
||||
* from the stock component, not a bespoke row. `mode` is a best-effort
|
||||
* transport hint inferred from the summary's `running` flag (a running agent
|
||||
* is continuable; a settled one is one-shot); `label` is left empty so
|
||||
* {@link childLabel} falls back to the summary title.
|
||||
*/
|
||||
function SummaryFallbackRows(props: {
|
||||
parentSessionId: string
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
currentSessionId: string
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
}) {
|
||||
const { parentSessionId, byId, level, currentSessionId, openChild } = props
|
||||
const children = directChildren(byId, parentSessionId)
|
||||
if (children.length === 0) {
|
||||
return <div className={css.subagentEmpty}>{t('loading')}</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map(summary => {
|
||||
const address: SidebarSubagentAddress = {
|
||||
parentSessionId,
|
||||
childSessionId: summary.id,
|
||||
mode: summary.running === true ? 'continuable' : 'one-shot',
|
||||
}
|
||||
const current = summary.id === currentSessionId
|
||||
const label = summary.displayTitle !== '' ? summary.displayTitle : summary.id
|
||||
const secondary = [
|
||||
summary.displayTitle,
|
||||
summary.running === true ? t('subagentRunning') : t('subagentInactive'),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div
|
||||
key={summary.id}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={`${label} ${secondary}`}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, current && css.subagentRowActive)}
|
||||
onClick={() => { openChild(address) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChild(address)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={summary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
function synthesizeCatalog(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSubagentCatalog {
|
||||
const entries: SidebarSubagentChildEntry[] = directChildren(byId, parentSessionId).map(
|
||||
(summary): SidebarSubagentChildEntry => ({
|
||||
kind: 'child',
|
||||
id: summary.id,
|
||||
activity: summary.running === true ? 'running' : 'inactive',
|
||||
hasChildren: directChildren(byId, summary.id).length > 0,
|
||||
mode: summary.running === true ? 'continuable' : 'one-shot',
|
||||
}),
|
||||
)
|
||||
return { entries, parentAvailable: true, state: 'ready', error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer summary-synthesized catalogs for the root and (recursively) every
|
||||
* descendant onto the host's `catalogs` map, so the stock {@link CatalogRows}
|
||||
* never meets an `undefined` child catalog during the degrade — each branch
|
||||
* expands into its synthesized children instead of a permanent "loading…".
|
||||
* Real host catalogs always win; synthesis only fills the gaps.
|
||||
*/
|
||||
function mergeSynthesizedCatalogs(
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
rootId: string,
|
||||
): Readonly<Record<string, SidebarSubagentCatalog>> {
|
||||
const merged: Record<string, SidebarSubagentCatalog> = { ...catalogs }
|
||||
const seen = new Set<string>()
|
||||
const visit = (parentSessionId: string): void => {
|
||||
if (merged[parentSessionId] !== undefined || seen.has(parentSessionId)) return
|
||||
seen.add(parentSessionId)
|
||||
merged[parentSessionId] = synthesizeCatalog(byId, parentSessionId)
|
||||
for (const entry of merged[parentSessionId].entries) {
|
||||
if (entry.kind === 'child' && entry.hasChildren) visit(entry.id)
|
||||
}
|
||||
}
|
||||
visit(rootId)
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,7 +765,26 @@ export function SubagentView(props: {
|
||||
// highlighted) — the README "page stays open" contract.
|
||||
onOpenChild?.(address)
|
||||
try {
|
||||
sessions.openSubagent?.(address)
|
||||
// Preferred: the host's exact direct-parent jump (keeps the child's
|
||||
// own layout + the Tasks page re-open on top via `subagentJumpRef`).
|
||||
// DSH 2.0.17 hosts that haven't exposed `sessions.openSubagent` fall
|
||||
// through to a plain session open — the child still becomes the current
|
||||
// session (so the jump-back highlight fires), the node is just reached
|
||||
// without the host's topology-aware address. Without this fallback a
|
||||
// missing `openSubagent` silently swallowed the click and the child
|
||||
// was unreachable ("点不进去").
|
||||
if (typeof sessions.openSubagent === 'function') {
|
||||
sessions.openSubagent(address)
|
||||
return
|
||||
}
|
||||
// `sessions.open(id)` selects a listed session as current (mirror of
|
||||
// the runtime ISessions.open — the same mechanism the topology root
|
||||
// node uses to jump back to the main agent). The child subagent session
|
||||
// IS in `sessions.list` (the byId mirror), so selecting it lands the
|
||||
// user on the child's own layout and the jump-back highlight fires.
|
||||
if (typeof sessions.open === 'function') {
|
||||
sessions.open(address.childSessionId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[dsh-apes-sidebar] openSubagent failed:', error)
|
||||
}
|
||||
@@ -937,15 +941,6 @@ export function SubagentView(props: {
|
||||
{summaryBackedLoading && !degraded && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{summaryBackedLoading && degraded && (
|
||||
<SummaryFallbackRows
|
||||
parentSessionId={rootId}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
openChild={openChild}
|
||||
/>
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
@@ -959,6 +954,26 @@ export function SubagentView(props: {
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
{/* Degraded (host never hydrated the root catalog): feed the
|
||||
session-mirror-synthesized catalog into the STOCK CatalogRows
|
||||
so the full official interaction (live lines, mode labels,
|
||||
nested branches, arrow-key nav) is reused — not a bespoke
|
||||
row. The synthesized root is merged into `catalogs` so any
|
||||
branch CatalogRows recurses into also has its own
|
||||
summary-synthesized catalog instead of a permanent loader. */}
|
||||
{summaryBackedLoading && degraded && rootId !== undefined && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={synthesizeCatalog(byId, rootId)}
|
||||
catalogs={mergeSynthesizedCatalogs(catalogs, byId, rootId)}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages /
|
||||
* apps / profile).
|
||||
*
|
||||
* History (C7): `apes:tasks` was removed under the old C6 decision (it looked
|
||||
* History (C7): `apesTasks` was removed under the old C6 decision (it looked
|
||||
* redundant next to the official DSH session tree). The `v2-revive` line
|
||||
* restores it — the native tree is a *navigator* (one focused lineage), this
|
||||
* panel is a *list* (every session, with an idle filter and a running badge),
|
||||
|
||||
+21
-21
@@ -5,18 +5,18 @@
|
||||
* live outside `builtins/`, so importing them does not couple the apes module
|
||||
* to upstream UI and survives cherry-picks.
|
||||
*
|
||||
* Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason
|
||||
* Five information-layer tabs (C7 reversal: `apesTasks` is back). The reason
|
||||
* the earlier C6 removal no longer holds: the native DSH session tree renders
|
||||
* ONE focused lineage, while this panel is a *flat, whole-workspace* list with
|
||||
* a running badge and an idle filter — complementary, not redundant.
|
||||
* - apes:tasks → every session (running / current / subagent marks) with
|
||||
* - apesTasks → every session (running / current / subagent marks) with
|
||||
* an idle filter; a running count badges the tab.
|
||||
* - apes:agents → the subagent topology rooted at the main agent of the
|
||||
* - apesAgents → the subagent topology rooted at the main agent of the
|
||||
* current session (rootAncestor + per-parent catalogs).
|
||||
* - apes:messages → honest placeholder: DSH exposes no message-stream feed.
|
||||
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
|
||||
* - apesMessages → honest placeholder: DSH exposes no message-stream feed.
|
||||
* - apesApps → no DSH host feed today: shows the workspace (cwd) of the
|
||||
* current session, otherwise an honest "no feed" note.
|
||||
* - apes:profile → the current session's summary row (title, cwd, running).
|
||||
* - apesProfile → the current session's summary row (title, cwd, running).
|
||||
*
|
||||
* Live-data discipline (C4): list-reading views subscribe only while the tab
|
||||
* is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel
|
||||
@@ -85,10 +85,10 @@ const S: Record<string, CSSProperties> = {
|
||||
taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' },
|
||||
}
|
||||
|
||||
/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */
|
||||
export const TASKS_TAB_ID = 'apes:tasks'
|
||||
/** apesTasks descriptor id (the plugin-toggle blob lives under this key). */
|
||||
export const TASKS_TAB_ID = 'apesTasks'
|
||||
|
||||
/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */
|
||||
/** The plugin-local switch persisted in `pluginSettings['apesTasks']`. */
|
||||
export const TASKS_SHOW_INACTIVE_KEY = 'showInactive'
|
||||
|
||||
/** A small colored status dot (self-contained, not the builtin StateDot). */
|
||||
@@ -135,7 +135,7 @@ function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarS
|
||||
|
||||
/**
|
||||
* The `showInactive` filter state. The declarative settings row writes it into
|
||||
* the sidebar prefs document under `pluginSettings['apes:tasks']` (service
|
||||
* the sidebar prefs document under `pluginSettings['apesTasks']` (service
|
||||
* contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of
|
||||
* the public `SidebarPrefs` type — hence the narrow local cast. Default is
|
||||
* true: the panel is a task list, so showing idle sessions is the useful
|
||||
@@ -153,7 +153,7 @@ export function tasksShowInactive(store: TabComponentProps['store']): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* apes:tasks rows: every non-side-chat session, running ones first (then the
|
||||
* apesTasks rows: every non-side-chat session, running ones first (then the
|
||||
* current one), idle ones only when `showInactive`. PURE — unit-tested.
|
||||
*/
|
||||
export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] {
|
||||
@@ -194,7 +194,7 @@ function agentChildren(
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:tasks — the flat, whole-workspace session list. */
|
||||
/** apesTasks — the flat, whole-workspace session list. */
|
||||
export function ApesTasksView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const store = props.store
|
||||
@@ -244,7 +244,7 @@ export function ApesTasksView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */
|
||||
/** apesMessages — DSH exposes no message-stream feed; honest placeholder. */
|
||||
export function ApesMessagesView(_props: TabComponentProps) {
|
||||
return (
|
||||
<div style={S.body}>
|
||||
@@ -254,7 +254,7 @@ export function ApesMessagesView(_props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:agents — the subagent topology of the current session's tree. */
|
||||
/** apesAgents — the subagent topology of the current session's tree. */
|
||||
function ApesAgentsView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const { byId, subagentsByParent } = list
|
||||
@@ -322,7 +322,7 @@ function ApesAgentsView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:profile — the current session's identity (title, cwd, running state). */
|
||||
/** apesProfile — the current session's identity (title, cwd, running state). */
|
||||
export function ApesProfileView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
@@ -359,7 +359,7 @@ export function ApesProfileView(props: TabComponentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
|
||||
/** apesApps — no DSH host feed today; shows the current workspace (cwd). */
|
||||
function ApesAppsView(props: TabComponentProps) {
|
||||
const list = useSessionList(props.ctx, props.visible)
|
||||
const current = list.current
|
||||
@@ -383,7 +383,7 @@ function ApesAppsView(props: TabComponentProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The five apes:* tab descriptors (C7: `apes:tasks` restored — the native
|
||||
* The five apes:* tab descriptors (C7: `apesTasks` restored — the native
|
||||
* session tree focuses one lineage, this list covers the whole workspace).
|
||||
* Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in
|
||||
* reading order: tasks / agents / apps / messages / profile. Each is already
|
||||
@@ -410,7 +410,7 @@ export const apesTabs: TabDescriptor[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apes:agents',
|
||||
id: 'apesAgents',
|
||||
title: () => t('apesAgentsTitle'),
|
||||
icon: (size: number) => <IconAgentOutline16 size={size} />,
|
||||
order: 60,
|
||||
@@ -421,7 +421,7 @@ export const apesTabs: TabDescriptor[] = [
|
||||
return count > 0 ? count : null
|
||||
},
|
||||
},
|
||||
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||
{ id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
|
||||
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||
{ id: 'apesApps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||
{ id: 'apesMessages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
|
||||
{ id: 'apesProfile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user