refactor(gui): todos ride the generic projection pair; ConversationSnapshot evacuated

TodoDock reads useProjection('todos') (whole list or null pre-first-write;
absent renders nothing) and declare-merges the todos key through the pure-type
outlet — the identical member the tool-todo host unit owns, drift rejected by
any program holding both. The core Session's todos field, its todo/write case,
and the snapshot member retire; the client folds nothing. Session specs for
the retired client fold move to the value-store spec's seq coverage; snapshot
literals across component specs drop the field.
This commit is contained in:
imccyu
2026-07-27 22:02:36 +08:00
parent 913125294b
commit d9d7e523f9
18 changed files with 43 additions and 97 deletions

View File

@@ -49,6 +49,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",

View File

@@ -9,6 +9,17 @@ import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
// Client-side view of the todos projection key. The authoritative merge lives
// with the domain host unit (tool-todo), whose program never overlaps the
// client's, so this consumer restates the identical member through the same
// pure-type outlet (any program holding both merges rejects drift).
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** The agent's current whole todo list (latest `todo/write` snapshot), or `null` before the first write. */
todos: TodoItem[] | null
}
}
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css'
@@ -115,10 +126,10 @@ export function TodoPanel({ todos }: TodoPanelProps) {
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
export function TodoDock({ useSession }: TodoDockProps) {
const todos = useSession(s => s.todos)
return <TodoPanel todos={todos} />
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useProjection }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} />
}
/**

View File

@@ -56,7 +56,7 @@ function snapshotWith(
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}

View File

@@ -27,7 +27,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -40,7 +40,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -30,7 +30,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -21,7 +21,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active',
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
})

View File

@@ -110,7 +110,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
})

View File

@@ -19,7 +19,7 @@ const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}

View File

@@ -48,7 +48,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false,
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,

View File

@@ -64,20 +64,23 @@ describe('TodoPanel', () => {
})
})
/** Dock props stub: the adapter reads useSession only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ todos: readonly TodoItem[] }>>): TodoDockProps {
return { useSession: bindSnapshotSelector(store) } as unknown as TodoDockProps
/** Dock props stub: the adapter reads the 'todos' projection only; the rest of the owner share is unused. */
function dockProps(store: ReturnType<typeof createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>>): TodoDockProps {
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
return { useProjection } as unknown as TodoDockProps
}
describe('TodoDock', () => {
it('selects the plan off the session snapshot and follows later writes', () => {
const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos: [] })
it('reads the host-computed todos projection and follows pushed updates', () => {
const store = createSnapshotStore<{ value: readonly TodoItem[] | null | undefined }>({ value: undefined })
render(<TodoDock {...dockProps(store)} />)
// Capability absent (no baseline/frame yet) renders nothing.
expect(screen.queryByTestId('todo-panel')).toBeNull()
act(() => { store.set({ todos: LIST }) })
act(() => { store.set({ value: LIST }) })
expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy()
// A rollback to the empty list retires the strip (the panel owns no data).
act(() => { store.set({ todos: [] }) })
// The pre-first-write whole value (null) retires the strip (the panel owns no data).
act(() => { store.set({ value: null }) })
expect(screen.queryByTestId('todo-panel')).toBeNull()
})

View File

@@ -23,6 +23,9 @@
{
"path": "../runtime"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../ui-slash"
},