diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 36917d48c1..8c4a6dc6a1 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -69,7 +69,12 @@ export function apply(ctx: Context): void { // ConversationRoot is the only component authorized to render the ring. slots.register({ name: 'conversation', - children: { 'conversation.view': { kind: 'list', scope: 'session' } }, + // The composer chain rides the same declaration table: takeover plugins + // register selector-routed replacements of the InputBar. + children: { + 'conversation.view': { kind: 'list', scope: 'session' }, + 'conversation.composer': { kind: 'chain', scope: 'session' }, + }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ConversationInjected => { // History pull is NOT triggered here: the runtime sessions service opens diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index c68cf98571..8023acddde 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -254,7 +254,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl ))} )} - {pending.map((item) => )} + {pending.map((item) => )} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index 56b886c9ad..b6825aed9a 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
{item.kind === 'approval' ? ( <> -
等待审批:{item.toolName}
- {item.reason !== undefined &&
{item.reason}
} +
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
} ) : ( <> -
等待回答({item.questions.length} 题)
- +
等待回答({item.payload.questions.length} 题)
+ )}
请在原客户端处理(web 端作答后续里程碑提供)
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index eabe747f8f..f1b825f90e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,7 +11,7 @@ * here. */ import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -33,6 +33,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * `fallback` for unregistered tools. */ 'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps } + /** + * The composer takeover chain: entries are selector-routed replacements + * of the default InputBar. Declared by this package's 'conversation' + * entry; the owner dispatches the {@link ComposerChainProps} currency and + * routing lives in entry selectors — new takeover kinds register with + * zero owner changes. + */ + 'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps } } } @@ -107,9 +115,22 @@ export interface ConversationInjected { open(id: SessionId): void } -/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */ +/** + * Composer chain currency: what ConversationRoot dispatches at its + * renderSlotChain site. The owner declares the currency only — never a + * per-entry contract; takeover packages narrow it in their own selectors + * (`interactions.find(i => i.kind === ...)`), so new takeover kinds register + * with zero owner changes. + */ +export interface ComposerChainProps { + /** The session's live pending waits, in arrival order (snapshot reference). */ + interactions: readonly PendingInteraction[] +} + +/** Full conversation-slot component props: runtime share & child-render share (view ring + composer chain) & store share & injected share. */ export type ConversationSlotProps = - PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationInjected + PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'> + & PropsStore & ConversationInjected /** * Injected share of the chat view entry: the two callbacks whose targets live diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index be37dfa9e8..9d5e1ac9e6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -3,7 +3,8 @@ // props: the framework standard kit (useSession/sessionId/useSessions), the // declared chat store's useStore/actions, the injected business face, and the // renderSlot share for the declared 'conversation.view' child slot (views are -// slot entries; the active one renders via the list `only` filter). +// slot entries; the active one renders via the list `only` filter) plus the +// renderSlotChain share for the 'conversation.composer' takeover chain. // Breadcrumbs derive from useSessions with a pure parentId walk; the active // view id lives in the chat store's `view` field (per-session by store scope). @@ -36,7 +37,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session } export function ConversationRoot({ - sessionId, useSession, useSessions, useStore, actions, renderSlot, + sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, views, send, stop, open, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) @@ -52,11 +53,27 @@ export function ConversationRoot({ const removed = useSession(s => s.removed) const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) + const pending = useSession(s => s.pending) const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } + // The default composer doubles as the chain's all-decline fallback: a + // pending wait with no registered takeover must still leave the input usable. + const composerBar = ( + { send(draft, mode) }} + onStop={stop} + /> + ) + return (
@@ -106,16 +123,7 @@ export function ConversationRoot({ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
- { send(draft, mode) }} - onStop={stop} - /> + {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a2ce22e4d0..be50356185 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -4,9 +4,11 @@ // 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 { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/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' @@ -44,7 +46,7 @@ describe('MessageItem arms', () => { describe('small branch tails', () => { it('PendingCard approval reason renders when present', () => { const view = render( - , + ['payload'], vi.fn())} />, ) expect(view.getByText('careful')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 4d5cd923d6..b82a1cd091 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -10,7 +10,8 @@ import type { AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' @@ -304,7 +305,8 @@ describe('ChatView', () => { it('pending interactions render placeholder cards', () => { const h = makeHarness({ - pending: [{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' }], + pending: [new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())], }) const view = render() expect(view.getByText(/等待审批/)).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 856b6c452c..7f98cdd6ed 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -8,7 +8,8 @@ import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' -import type { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as nodeApply } from '../src/index.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' @@ -34,7 +35,7 @@ describe('tails', () => { it('PendingCard renders the question arm with its count', () => { const view = render( - , + ['payload'], vi.fn())} />, ) expect(view.getByText(/等待回答(2 题)/)).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index ef76b391a4..6aefd34494 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -21,6 +21,9 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx' afterEach(cleanup) const SID = 's1' as SessionId +/** Fallback-only chain stub (no takeover registered in these benches). */ +const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = + (_key, _owner, opts) => opts?.fallback ?? null function snapshotBase(): ConversationSnapshot { return { @@ -73,6 +76,7 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} @@ -131,6 +135,7 @@ describe('ConversationRoot branches', () => { useStore={hookOf(chat)} actions={chat.actions} renderSlot={stubRenderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }} send={vi.fn()} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ca332ef741..c39d2723c3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 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-web-react' -import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx' // Export discipline: packages/client/AGENTS.md. @@ -36,11 +38,12 @@ interface FakeSnapshot { running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null + pending: readonly PendingInteraction[] } function fakeSession(init: Partial = {}) { const store = createSnapshotStore({ - nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init, + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init, }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -99,8 +102,11 @@ describe('EmptyState', () => { }) describe('ConversationRoot', () => { - function bench(tabs: ViewTab[], activeView?: string) { - const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] }) + function bench( + tabs: ViewTab[], activeView?: string, init: Partial = {}, + renderSlotChain?: ConversationRootProps['renderSlotChain'], + ) { + const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) const { useSessions } = fakeSessions([ { id: 'root', title: 'proj' }, { id: 's1', title: 'child', parentId: 'root' }, @@ -124,6 +130,7 @@ describe('ConversationRoot', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']} + renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)} SessionProvider={SessionProviderStub} views={{ list: () => tabs, @@ -179,6 +186,30 @@ describe('ConversationRoot', () => { fireEvent.keyDown(box, { key: 'Enter' }) expect(send).toHaveBeenCalledWith('hi', 'queue') }) + + it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => { + const wait = new PendingWait('question', RpcId('rq'), sid('s1'), + { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn()) + // A matching entry takes the composer over. + const renderSlotChain = vi.fn(() =>
question takeover
) as unknown as ConversationRootProps['renderSlotChain'] + bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain) + expect(screen.getByText('question takeover')).toBeTruthy() + expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull() + // The owner dispatches the raw pending list (chain currency); routing + // lives in entry selectors, not here. + expect(renderSlotChain).toHaveBeenCalledWith( + 'conversation.composer', + expect.objectContaining({ + interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]), + }), + expect.objectContaining({ fallback: expect.anything() }), + ) + cleanup() + // Zero registered entries (default all-decline stub): the fallback IS the + // default InputBar — behavior equals the pre-chain composer. + bench([tab('chat', 'Chat')], undefined, { pending: [wait] }) + expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy() + }) }) describe('DetailsPanel', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index b62e3efcfc..ab75da20b2 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -28,6 +28,9 @@ import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/ import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' const SID = 's1' as SessionId +/** Fallback-only chain stub (no composer takeover in these benches). */ +const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] = + (_key, _owner, opts) => opts?.fallback ?? null afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active @@ -120,6 +123,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES useStore={bindSnapshotSelector(chat)} actions={chat.actions} renderSlot={renderSlot} + renderSlotChain={fallbackRenderSlotChain} SessionProvider={SessionProviderStub} views={{ list: () => tabsOf(slots),