refactor(gui): dissolve the tool ring into per-view keyed slots

Four rounds of structural rework on the conversation surface, converging
on one registration model for the whole client:

- Review fixes: open() leaves the inject factory (SessionsService owns
  the semantic); ConversationService mounts via ctx.plugin(); the
  bespoke view registry retires into the 'conversation.view' list slot.
- Ring alignment: createChatView factory retired (components get
  everything through checkable shares at the register call site); the
  hand-rolled t/i18n threading is deleted wholesale — a future
  framework-level i18n will supply t as a standard prop keyed by slot
  name, so no interim manual channel.
- Toolview dissolution: ToolViewRegistry / ToolViewResolver /
  ToolViewOutlet / ctx.toolviews retire. Tool rows are entries of the
  'conversation.chat.toolview' keyed slot (scope: session) declared by
  the chat entry; ToolRowOwnerProps is the unified owner payload;
  GenericToolCard becomes the call-site fallback; registrants are plain
  plugins (inject ['slots','conversation'] as the load-order seam);
  session-dimension dispatch moves into components (useSessions reads
  parentId); trajectory/waterfall gain same-shape slots the day they
  render tool rows (RendersCheck rejects empty declarations). Slot
  names mirror the composition path (<domain>.<entry>.<hole>).
- Staging follows current: cell()/binding() are pure resolution
  (render-safe); the constructor subscribes to the list store and
  followCurrent opens the event window when the current session
  changes — staging IS the open signal, business verbs are the timing,
  React render/commit is decoupled from window lifecycle. A masked
  current (projection gap) keeps the stage untouched so deferred
  teardown semantics survive reconnects.

Agent Note: .agents/notes/implemented/architecture/
2026-07-23-toolview-dissolution.md (bilingual pair) records the
decision, the four rejected alternatives, and the accepted semantic
changes; the web client architecture note and packages/client/AGENTS.md
carry the current-state narrative.

Verified: typecheck 0, duplication 0 clones (478 files), full coverage
run 6190 passed with zero threshold errors, knip 0, doc-sync 24/24,
client aggregate tsc 0, render-count checks (one commit per chunk, zero
row re-renders under streaming) green.
This commit is contained in:
imccyu
2026-07-23 17:09:32 +08:00
parent fcd9af2033
commit bbde18caff
56 changed files with 1569 additions and 1847 deletions

View File

@@ -3,14 +3,14 @@
* Real tsdown artifact shape: lib/client.js hands off through
* window.DSHClientProxy.loadPlugin, resolves externals through the injected
* require, returns the export surface (apply + inject), and a mounted apply
* registers both views into a real ConversationService. Skips when dist/ is
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
@@ -59,18 +59,23 @@ describe('tsdown client artifact', () => {
const { handoff, surface } = await loadArtifact()
expect(handoff.id).toBe(PLUGIN_ID)
expect(surface.apply).toBeTypeOf('function')
expect(surface.inject).toEqual(['conversation'])
expect(surface.inject).toEqual(['slots'])
})
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both views on the real service', async () => {
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
const { surface } = await loadArtifact()
const ctx = new Context()
const svc = new ConversationService(ctx)
const slots = new SlotsService(ctx)
// The conversation entry's role: the ring must be declared before riders land.
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
expect(svc.views().map(v => v.id)).toEqual(['trajectory', 'waterfall'])
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
await fiber.dispose()
expect(svc.views()).toHaveLength(0)
expect(slots.entries('conversation.view')).toHaveLength(0)
})
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {

View File

@@ -1,25 +1,25 @@
// @vitest-environment jsdom
/**
* View registration acceptance on the real framework stack: the plugin fiber
* registers trajectory/waterfall into a real ConversationService, tabs switch
* 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.
* registers trajectory/waterfall into a real SlotsService view ring, tabs
* switch inside ConversationRoot (renderSlot share driven by the same tab
* projection apply uses) without collapsing chat, the span stats header
* renders inside both view bodies, and fiber disposal removes both tabs.
* Span derivation edge cases ride along.
*/
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement, type FC } from 'react'
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
import { createElement, type FC, type ReactNode } from 'react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } 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'
import type { ConvViewProps, ViewTab } 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 { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
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'
@@ -49,88 +49,116 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
/** Chat-view stand-in props for standalone view mounts. */
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
/** Standalone view props: the session-scope standard kit the outlet would bake. */
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
const chat = createChatStore().create()
return {
sessionId: SID,
useSession: fakeSession(nodes).useSession,
useStore: bindSnapshotSelector(chat),
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
useSessions: emptySessions(),
} as unknown as ConvViewProps
}
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
async function bench() {
const ctx = new Context()
const svc = new ConversationService(ctx)
const slots = new SlotsService(ctx)
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
svc.registerView({ id: 'chat' as ViewId, label: 'Chat', order: 0, component: chatBody as unknown as FC<ConvViewProps> })
slots.register(
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, svc, fiber }
return { ctx, slots, fiber }
}
/** 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) {
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
function tabsOf(slots: SlotsService): ViewTab[] {
return slots.entries('conversation.view')
.map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
}
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
running: false, removed: false, promptError: null, nodes,
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()
// Minimal outlet twin: resolve the ring entry by the `only` filter and
// render it with the session standard kit (what SlotOutlet does for a
// list-kind session slot, minus machinery).
const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
if (entry === undefined) return null
const View = entry.component as FC<ConvViewProps>
return (
<View
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
key={key}
/>
)
}) as unknown as ConversationRootProps['renderSlot']
return render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
useSession={useSession}
useSessions={emptySessions()}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot}
SessionProvider={SessionProviderStub}
views={{
list: () => svc.views(),
subscribe: (fn) => svc.subscribeViews(fn),
version: () => svc.viewsVersion(),
list: () => tabsOf(slots),
subscribe: (fn) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
}
describe('plugin registration', () => {
it('registers trajectory and waterfall after chat, both with header chrome', async () => {
it('registers trajectory and waterfall after chat on the ring', async () => {
const b = await bench()
const views = b.svc.views()
expect(views.map((v) => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(views[1]?.chrome?.header).toBeDefined()
expect(views[2]?.chrome?.header).toBeDefined()
expect(views[1]?.chrome?.footer).toBeUndefined()
expect(tabsOf(b.slots)).toEqual([
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'waterfall', label: 'Waterfall' },
])
})
it('fiber disposal removes both tabs and leaves chat standing', async () => {
const b = await bench()
await b.fiber.dispose()
expect(b.svc.views().map((v) => v.id)).toEqual(['chat'])
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
})
})
describe('tab switching in ConversationRoot', () => {
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
const b = await bench()
mount(b.svc)
mount(b.slots)
expect(screen.getByTestId('chat-body')).toBeTruthy()
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
// chrome.header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
// In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
expect(screen.getByText('turn 0')).toBeTruthy()
expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
@@ -139,7 +167,7 @@ describe('tab switching in ConversationRoot', () => {
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
const b = await bench()
mount(b.svc)
mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
expect(screen.getByTitle('2 nodes')).toBeTruthy()
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
@@ -148,9 +176,9 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getByTestId('chat-body')).toBeTruthy()
})
it('empty window: placeholder copy in the body, header chrome renders nothing', async () => {
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
const b = await bench()
mount(b.svc, [] as unknown as ConversationSnapshot['nodes'])
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
expect(screen.queryByText(/turns ·/)).toBeNull()
@@ -175,7 +203,7 @@ describe('span derivation', () => {
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
expect(container.firstChild).toBeNull()
render(createElement(TrajectoryView as FC<ConvViewProps>,
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))