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
@@ -2,21 +2,24 @@
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* inside ConversationRoot without collapsing chat, chrome.header renders the
* span stats bar, and fiber disposal removes both tabs. Span derivation edge
* cases ride along.
* inside ConversationRoot (four-share props form; view rendering is
* in-component now) without collapsing chat, chrome.header renders the span
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
* ride along.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, Fragment, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createElement, type FC } from '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 { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
import type { ConvViewProps, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import type { ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
@@ -27,6 +30,11 @@ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
const NODES = [
@@ -38,7 +46,25 @@ const NODES = [
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return store.useSelector
}
/** Chat-view stand-in props for standalone view mounts. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
const chat = createChatStore().create()
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: chat.useSelector,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
} as unknown as ConvViewProps
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
@@ -52,45 +78,29 @@ async function bench() {
return { ctx, svc, fiber }
}
/** Mount ConversationRoot over the service's registry face, rendering chrome like the conversation apply does. */
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
const { useSession } = fakeSession(nodes)
const activeStore = createSnapshotStore<string | undefined>(undefined)
const ancestry: SessionSummary[] = [{ id: SID, title: 'self', running: false, updatedAt: 1 }]
const viewProps = {
sessionId: SID, useSession,
useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
slots: undefined,
} as unknown as ConvViewProps
const renderView = (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'h', sessionId: SID, useSession }))
}
children.push(createElement(entry.component, { key: 'b', ...viewProps }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'f', sessionId: SID, useSession }))
}
return createElement(Fragment, null, children)
}
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
running: false, removed: false, promptError: null, nodes,
})
const chat = createChatStore().create()
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession}
useAncestry={() => ancestry}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSessions={emptySessions()}
useStore={chat.useSelector}
actions={chat.actions}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
}}
useActiveView={() => activeStore.useSelector((s) => s) as ViewId | undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: ((v: string) => { activeStore.set(v) }) as (v: never) => void, open: vi.fn() }}
renderView={renderView}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
}
@@ -167,28 +177,22 @@ describe('span derivation', () => {
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>, {
sessionId: SID, useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps))
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
})
})
describe('WaterfallView standalone branches', () => {
const props = (nodes: ConversationSnapshot['nodes']) => ({
sessionId: SID, useSession: fakeSession(nodes).useSession, useSelection: () => null,
actions: { openDetails: vi.fn(), loadOlder: vi.fn() }, slots: undefined,
} as unknown as ConvViewProps)
it('empty window renders the placeholder copy', () => {
render(createElement(WaterfallView as FC<ConvViewProps>, props([] as unknown as ConversationSnapshot['nodes'])))
render(createElement(WaterfallView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
})
it('a turn without tool calls renders the node bar only', () => {
const nodes = [{ kind: 'user', seq: 1 }] as unknown as ConversationSnapshot['nodes']
render(createElement(WaterfallView as FC<ConvViewProps>, props(nodes)))
render(createElement(WaterfallView as FC<ConvViewProps>, standaloneProps(nodes)))
expect(screen.getByTitle('1 nodes')).toBeTruthy()
expect(screen.queryByTitle(/tool calls/)).toBeNull()
})