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.
72 lines
3.1 KiB
TypeScript
72 lines
3.1 KiB
TypeScript
// @vitest-environment jsdom
|
||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||
// 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 } from 'vitest'
|
||
import { cleanup, render } from '@testing-library/react'
|
||
import type { RpcId } from '@deepseek-ai/dsh-client-connection/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, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||
|
||
afterEach(cleanup)
|
||
|
||
describe('MessageItem arms', () => {
|
||
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
|
||
const view = render(
|
||
<MessageItem node={{
|
||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||
} as never}
|
||
/>,
|
||
)
|
||
expect(view.getByText('插话')).toBeTruthy()
|
||
expect(view.getByText('steer!')).toBeTruthy()
|
||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||
})
|
||
|
||
it('context and unknown nodes render their JSON rows', () => {
|
||
const ctxView = render(
|
||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null, meta: { k: 1 } } as never} />,
|
||
)
|
||
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
|
||
const unknownView = render(
|
||
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||
)
|
||
expect(unknownView.getByText(/未知 surface 事件:surface\/next/)).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
describe('small branch tails', () => {
|
||
it('PendingCard approval reason renders when present', () => {
|
||
const view = render(
|
||
<PendingCard item={{ kind: 'approval', rpcId: 'r1' as RpcId, approvalId: 'a1', toolName: 'rm', reason: 'careful' }} />,
|
||
)
|
||
expect(view.getByText('careful')).toBeTruthy()
|
||
})
|
||
|
||
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {
|
||
const view = render(
|
||
<AssistantMarkdown blocks={[{ kind: 'reasoning', text: 'one-liner' }]} streaming={false} />,
|
||
)
|
||
expect(view.getByText('one-liner')).toBeTruthy()
|
||
})
|
||
|
||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||
// output accounting) — any input makes it a real 0%.
|
||
const snap = {
|
||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||
}
|
||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||
const view = render(
|
||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||
)
|
||
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||
})
|
||
})
|