refactor(gui): route the composer chain on PendingWait currency
This commit is contained in:
@@ -79,11 +79,11 @@ export function apply(ctx: Context): void {
|
||||
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
// Declaring the keyed composer slot here both creates it and authorizes
|
||||
// ConversationRoot (the takeover dispatch site) to render it; feature
|
||||
// plugins (ui-question) register their replacement composers into it.
|
||||
// Declaring the chain composer slot here both creates it and authorizes
|
||||
// ConversationRoot (the takeover dispatch site) to render it; takeover
|
||||
// plugins (ui-question) register selector-routed composer replacements.
|
||||
children: {
|
||||
'conversation.composer': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
},
|
||||
store: chat,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
|
||||
|
||||
@@ -271,7 +271,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => item.kind === 'approval'
|
||||
? <PendingCard key={item.rpcId} item={item} />
|
||||
? <PendingCard key={item.key} item={item} />
|
||||
: null)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
// PendingCard: approval placeholder card. Questions take over the composer.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import css from './PendingCard.module.css'
|
||||
|
||||
export interface PendingCardProps {
|
||||
item: Extract<PendingInteraction, { kind: 'approval' }>
|
||||
item: PendingWait<'approval'>
|
||||
}
|
||||
|
||||
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
|
||||
return (
|
||||
<div className={css.card}>
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.toolName}</span></div>
|
||||
{item.reason !== undefined && <div className={css.reason}>{item.reason}</div>}
|
||||
<div className={css.title}>等待审批:<span className={css.mono}>{item.payload.toolName}</span></div>
|
||||
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
|
||||
<div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* conversation.empty). Terminal slot design (§3): full component props are the
|
||||
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here. The conversation entry alone declares a child slot (the keyed
|
||||
* here. The conversation entry alone declares a child slot (the chain-kind
|
||||
* conversation.composer takeover), so only ConversationSlotProps carries the
|
||||
* renderSlot share.
|
||||
*/
|
||||
@@ -42,9 +42,16 @@ export interface ConversationInjected {
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Question-composer owner share supplied by ConversationRoot at its renderSlot site. */
|
||||
export interface QuestionComposerOwnerProps {
|
||||
interaction: Extract<PendingInteraction, { kind: 'question' }>
|
||||
/**
|
||||
* 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 & store share & injected share. */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
import type { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
import type { QuestionComposerOwnerProps } from './contract/slots.ts'
|
||||
import type { ComposerChainProps } from './contract/slots.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
@@ -22,8 +22,8 @@ export type {
|
||||
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
|
||||
} from './contract/toolview.ts'
|
||||
export type {
|
||||
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, QuestionComposerOwnerProps,
|
||||
ChatStore, ComposerChainProps, ConversationInjected, ConversationSlotProps, DetailsInjected,
|
||||
DetailsSlotProps, EmptyStateInjected, EmptyStateSlotProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
@@ -37,9 +37,9 @@ declare module 'cordis' {
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'conversation.composer': {
|
||||
kind: 'keyed'
|
||||
kind: 'chain'
|
||||
scope: 'session'
|
||||
owner: QuestionComposerOwnerProps
|
||||
owner: ComposerChainProps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open, renderSlot,
|
||||
views, send, stop, openDetails, loadOlder, open, renderSlotChain,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
@@ -51,7 +51,7 @@ export function ConversationRoot({
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const question = useSession(s => s.pending.find(item => item.kind === 'question'))
|
||||
const pending = useSession(s => s.pending)
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
@@ -78,8 +78,8 @@ export function ConversationRoot({
|
||||
)
|
||||
}
|
||||
|
||||
// The default composer doubles as the keyed slot's fallback: a pending
|
||||
// question with no registered takeover must still leave the input usable.
|
||||
// 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 = (
|
||||
<InputBar
|
||||
draft={draft}
|
||||
@@ -142,12 +142,7 @@ export function ConversationRoot({
|
||||
{active !== undefined && renderView(active)}
|
||||
</div>
|
||||
|
||||
{question !== undefined && question.kind === 'question'
|
||||
? renderSlot('conversation.composer', { interaction: question }, {
|
||||
entryKey: 'question',
|
||||
fallback: composerBar,
|
||||
})
|
||||
: composerBar}
|
||||
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -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() }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user