refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,16 +1,19 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows): breadcrumb ancestry rendering + error strip in
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
// error-only results, EmptyState failure surface and custom-directory swap.
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and custom-directory swap with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react/src/store/index.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
@@ -35,37 +38,52 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, title, running: false, updatedAt: 1 })
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return store.useSelector
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => null,
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
} as unknown as ViewEntry
function rootProps(over?: {
ancestry?: readonly SessionSummary[]
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
useAncestry={() => over?.ancestry ?? []}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={chat.useSelector}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open }}
renderView={() => <div data-testid="view-body" />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={open}
/>,
)
return { view, open }
return { view, open, chat }
}
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
@@ -76,6 +94,14 @@ describe('ConversationRoot branches', () => {
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
@@ -91,31 +117,41 @@ describe('ConversationRoot branches', () => {
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
})
it('an unknown active view id falls back to the first registered view', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone' as never)
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => 'gone' as never}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open: vi.fn() }}
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
@@ -142,13 +178,16 @@ describe('DetailsPanel branches', () => {
return () => subs.delete(fn)
},
}
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={chat.useSelector}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
@@ -192,18 +231,10 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
// getSnapshot must return a stable reference (uSES contract) — a fresh
// array per call loops the selector forever.
const CWDS: readonly string[] = ['/proj']
const NO_CWDS: readonly string[] = []
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -215,10 +246,7 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -226,15 +254,20 @@ describe('EmptyState branches', () => {
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
/>,
)
const select = view.container.querySelector('select')!
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/proj', '::new-directory'])
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })