refactor(gui): route the composer chain on PendingWait currency

This commit is contained in:
imccyu
2026-07-23 17:23:17 +08:00
parent de36006956
commit 07bdfbce9b
16 changed files with 314 additions and 275 deletions

View File

@@ -8,7 +8,8 @@ import { afterEach, describe, expect, it, vi } 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 { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import { 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'
@@ -65,7 +66,7 @@ describe('MessageItem arms', () => {
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' }} />,
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
)
expect(view.getByText('careful')).toBeTruthy()
})

View File

@@ -9,6 +9,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
} 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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -288,11 +290,10 @@ describe('ChatView', () => {
it('renders approval cards while questions stay in the composer', () => {
const h = makeHarness({
pending: [
{ kind: 'approval', rpcId: 'r1' as never, approvalId: 'ap1', toolName: 'bash' },
{
kind: 'question', rpcId: 'r2' as never,
questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }],
},
new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
new PendingWait('question', RpcId('r2'), SID,
{ questions: [{ id: 'mode', question: 'Composer only?', options: [{ label: 'Yes' }] }] } as PendingWait<'question'>['payload'], vi.fn()),
],
})
const view = render(<h.ChatView {...h.props} />)

View File

@@ -21,9 +21,12 @@ import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Fallback-only renderSlot stub (no takeover registered in these benches). */
const fallbackRenderSlot: ConversationSlotProps['renderSlot'] =
/** Fallback-only chain stub (no takeover registered in these benches). */
const fallbackRenderSlotChain: ConversationSlotProps['renderSlotChain'] =
(_key, _owner, opts) => opts?.fallback ?? null
/** Non-chain renderSlot stub: ConversationRoot renders no non-chain child keys. */
const unusedRenderSlot: ConversationSlotProps['renderSlot'] =
(() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot']
/** Standard-seat stub: ConversationRoot never renders it, delivery is mandatory in the props type. */
const StubSessionProvider: ConversationSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
@@ -81,7 +84,8 @@ describe('ConversationRoot branches', () => {
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={open}
renderSlot={fallbackRenderSlot}
renderSlot={unusedRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={StubSessionProvider}
/>,
)
@@ -141,7 +145,8 @@ describe('ConversationRoot branches', () => {
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
renderSlot={fallbackRenderSlot}
renderSlot={unusedRenderSlot}
renderSlotChain={fallbackRenderSlotChain}
SessionProvider={StubSessionProvider}
/>,
)

View File

@@ -12,8 +12,9 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
@@ -102,7 +103,7 @@ describe('EmptyState', () => {
describe('ConversationRoot', () => {
function bench(
views: ViewEntry[], activeView?: string, init: Partial<FakeSnapshot> = {},
renderSlot?: ConversationSlotProps['renderSlot'],
renderSlotChain?: ConversationSlotProps['renderSlotChain'],
) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
const { useSessions } = fakeSessions([
@@ -133,7 +134,8 @@ describe('ConversationRoot', () => {
openDetails={openDetails}
loadOlder={loadOlder}
open={open}
renderSlot={renderSlot ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
renderSlot={(() => { throw new Error('no non-chain child keys') }) as unknown as ConversationSlotProps['renderSlot']}
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
SessionProvider={StubSessionProvider}
/>)
return { ui, chat, send, stop, open }
@@ -195,20 +197,22 @@ describe('ConversationRoot', () => {
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
it('dispatches a pending question to the composer slot instead of rendering InputBar', () => {
const renderSlot = vi.fn(() => <div>question takeover</div>) as unknown as ConversationSlotProps['renderSlot']
it('dispatches the pending list to the composer chain instead of rendering InputBar', () => {
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationSlotProps['renderSlotChain']
bench([view('chat', 'Chat')], undefined, {
pending: [{
kind: 'question', rpcId: 'rq' as never,
questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }],
}],
}, renderSlot)
pending: [new PendingWait('question', RpcId('rq'), sid('s1'),
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())],
}, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
expect(renderSlot).toHaveBeenCalledWith(
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(
'conversation.composer',
expect.objectContaining({ interaction: expect.objectContaining({ rpcId: 'rq' }) }),
expect.objectContaining({ entryKey: 'question' }),
expect.objectContaining({
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
}),
expect.objectContaining({ fallback: expect.anything() }),
)
})
})