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:
@@ -1,41 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
|
||||
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
|
||||
// ChatView view-body fallbacks, and apply's action lambdas.
|
||||
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { act } from '@testing-library/react'
|
||||
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { hookOf } from './hook.ts'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
|
||||
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"x"}' },
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
const viewProps = (): ToolViewProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
|
||||
actions: { openDetails: vi.fn() },
|
||||
t: ((k: string) => k) as Translate,
|
||||
})
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
|
||||
const view = render(
|
||||
@@ -85,71 +64,8 @@ describe('small branch tails', () => {
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession} />,
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
)
|
||||
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolViewOutlet dispatch', () => {
|
||||
it('caches the inject factory per (registration x session) and merges its props', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
|
||||
registry.register('bash',
|
||||
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
|
||||
{ inject })
|
||||
// Pure props machinery: the outlet feeds its own sessionId to the
|
||||
// factory — no provider/context needed (terminal channel form).
|
||||
const view = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
// Remount under the SAME session: cache hit, factory not re-run.
|
||||
view.unmount()
|
||||
const second = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
// A different session is a distinct cache key: factory runs once more.
|
||||
second.unmount()
|
||||
const other = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(other.getByTestId('row').textContent).toBe('injected:s2')
|
||||
expect(inject).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
// React dev builds re-dispatch boundary-caught errors as window 'error'
|
||||
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
|
||||
const swallow = (e: Event): void => { e.preventDefault() }
|
||||
window.addEventListener('error', swallow)
|
||||
try {
|
||||
const Bomb = () => { throw new Error('row bomb') }
|
||||
registry.register('bash', Bomb as never)
|
||||
const view = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
// Crash caught: generic row rendered instead.
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
// A new registration bumps the version; the boundary retries the custom row.
|
||||
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
|
||||
expect(view.getByTestId('fixed')).toBeTruthy()
|
||||
} finally {
|
||||
window.removeEventListener('error', swallow)
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('registry miss renders the generic row directly', () => {
|
||||
const registry = new ToolViewRegistry()
|
||||
const view = render(
|
||||
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
|
||||
)
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user