Ver Fonte

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

imccyu há 2 meses atrás
pai
commit
c18be97f17

+ 6 - 1
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<typeof chatStore>): ConversationInjected => {
       // History pull is NOT triggered here: the runtime sessions service opens

+ 1 - 1
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -254,7 +254,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
             ))}
           </div>
         )}
-        {pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
+        {pending.map((item) => <PendingCard key={item.key} item={item} />)}
         </div>
       </div>
       <StatsLine useSession={useSession} />

+ 4 - 4
packages/client/ui-conversation/src/client/chat/PendingCard.tsx

@@ -16,13 +16,13 @@ export const PendingCard = memo(function PendingCard({ item }: PendingCardProps)
     <div className={css.card}>
       {item.kind === 'approval' ? (
         <>
-          <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.title}>等待回答({item.questions.length} 题)</div>
-          <JsonBlock label="问题内容" payload={item.questions} />
+          <div className={css.title}>等待回答({item.payload.questions.length} 题)</div>
+          <JsonBlock label="问题内容" payload={item.payload.questions} />
         </>
       )}
       <div className={css.hint}>请在原客户端处理(web 端作答后续里程碑提供)</div>

+ 24 - 3
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<ChatStore> & ConversationInjected
+  PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
+  & PropsStore<ChatStore> & ConversationInjected
 
 /**
  * Injected share of the chat view entry: the two callbacks whose targets live

+ 20 - 12
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 = (
+    <InputBar
+      draft={draft}
+      running={running}
+      disabled={removed}
+      error={error}
+      variant="composer"
+      onDraftChange={actions.setDraft}
+      onSend={(mode) => { send(draft, mode) }}
+      onStop={stop}
+    />
+  )
+
   return (
     <div className={css.root}>
       <header className={css.header}>
@@ -106,16 +123,7 @@ export function ConversationRoot({
         {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
       </div>
 
-      <InputBar
-        draft={draft}
-        running={running}
-        disabled={removed}
-        error={error}
-        variant="composer"
-        onDraftChange={actions.setDraft}
-        onSend={(mode) => { send(draft, mode) }}
-        onStop={stop}
-      />
+      {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
     </div>
   )
 }

+ 5 - 3
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(
-      <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()
   })

+ 4 - 2
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(<h.ChatView {...h.props} />)
     expect(view.getByText(/等待审批/)).toBeTruthy()

+ 3 - 2
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(
-      <PendingCard item={{ kind: 'question', rpcId: 'r1' as RpcId, questions: [{}, {}] }} />,
+      <PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
     )
     expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
   })

+ 5 - 0
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()}

+ 35 - 4
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<FakeSnapshot> = {}) {
   const store = createSnapshotStore<FakeSnapshot>({
-    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<ConversationSnapshot> }
 }
@@ -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<FakeSnapshot> = {},
+    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(() => <div>question takeover</div>) 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', () => {

+ 4 - 0
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),