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:
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user