ソースを参照

fix(client): route turn-tail through chain selector

imccyu 2 ヶ月 前
親
コミット
2efce69d79

+ 1 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -303,7 +303,7 @@ export function apply(ctx: Context): void {
     children: {
       'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
       'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
-      'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
+      'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
     },
     store: chatStore,
     inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {

+ 7 - 7
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx

@@ -9,12 +9,13 @@
 // their branch action is enabled only when the node is also the completed
 // turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
 
-import { memo, useMemo, type ReactNode } from 'react'
+import { memo, useMemo } from 'react'
 import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   IconThinkOutline14, JsonBlock, MarkdownText,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import { hasContentText } from './chat-flow.ts'
 import { MessageIconActions } from './MessageIconActions.tsx'
 import { ToolRow } from './ToolRow.tsx'
@@ -40,9 +41,8 @@ export interface AssistantMarkdownProps {
   seq?: number | undefined
   /** Fork the session through this finalized message's completed turn when eligible. */
   onFork?: ((seq: number) => void) | undefined
-  /** Turn-tail content (the chat view's turnTail hole, rendered by the
-   *  owner); omitted for a mid-turn assistant. */
-  tail?: ReactNode | undefined
+  /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
+  turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
   /** The message is not the transcript tail of a completed turn. */
   forkUnavailable?: boolean | undefined
   /** The owning view's locale seat, passed down as a plain prop. */
@@ -86,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
 }
 
 export const AssistantMarkdown = memo(function AssistantMarkdown({
-  blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t,
+  blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
@@ -124,7 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
         })}
         {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
       </div>
-      {showActions && tail}
+      {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
       {showActions && (
         <MessageIconActions
           text={copyText(blocks)}

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

@@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: {
  * render through the declared keyed hole's renderSlot share).
  */
 export function ChatView({
-  useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
+  useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
 }: ChatViewSlotProps) {
   const nodes = useSession(s => s.nodes)
   const turnTimings = useSession(s => s.turnTimings)
@@ -600,8 +600,8 @@ export function ChatView({
           seq={node.seq}
           onFork={forkAt}
           forkUnavailable={!branchSeqs.has(node.seq)}
-          tail={actionSeqs.has(node.seq)
-            ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile })
+          turnTail={actionSeqs.has(node.seq)
+            ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
             : undefined}
           t={t}
         />

+ 5 - 6
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -47,14 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      */
     'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
     /**
-     * The chat view's turn-tail hole: rendered between a closing assistant
+     * The chat view's turn-tail chain: rendered between a closing assistant
      * message's body and its IconActions footer, once per turn (the render
-     * site elects the closing seq). Declared by the chat view entry; feature
-     * plugins (ui-deliverables' produced-files row) derive what they show
-     * from the owner currency, and an unregistered hole renders nothing —
-     * composing such a plugin out of cordis.yml turns its surface off.
+     * site elects the closing seq). Entries derive a match from the owner
+     * currency before mounting, so presentation components never mount only
+     * to return null; an all-declined chain renders nothing.
      */
-    'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps }
+    'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
     /**
      * The composer takeover chain: entries are selector-routed replacements
      * of the default InputBar. Declared by this package's 'conversation'

+ 3 - 1
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const chat = createChatStore().create()
   const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
     opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
+  const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
+    opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
   // SessionProvider seat arrives with the session-scope child declaration;
   // ChatView never invokes it (render-prop pass-through stub).
   const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
@@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     useStore: bindSnapshotSelector(chat),
     actions: chat.actions,
     renderSlot,
+    renderSlotChain,
     SessionProvider: SessionProviderStub,
     openDetails,
     openFile,
@@ -733,7 +736,6 @@ describe('ChatView', () => {
     // not re-render, so the row's renderSlot call count freezes during chunks.
     let rowRenders = 0
     h.props.renderSlot = ((key: string, _owner: object) => {
-      // The turnTail hole renders through the same share; only tool rows count here.
       if (key !== 'conversation.chat.toolview') return null
       rowRenders += 1
       return <div data-testid="counting-row" />

+ 11 - 18
packages/client/ui-deliverables/src/client/ProducedFiles.tsx

@@ -1,14 +1,11 @@
 // ProducedFiles: the produced-file row a finished turn ends with. The paths
-// come from the mutation tools' follow-along locations (see
-// producedForClosing), never from the closing prose, so the answer carries
-// its own output whether or not the model remembered to name it. Clicking one
-// goes through the same openFile the tool rows use — the Host's own opener,
-// on the Host machine.
+// come pre-matched by the turn-tail chain from the mutation tools'
+// follow-along locations, never from the closing prose. Clicking one goes
+// through the same openFile the tool rows use — the Host's own opener, on the
+// Host machine.
 
-import { useMemo } from 'react'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { producedForClosing } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
 import css from './ProducedFiles.module.css'
 
@@ -21,21 +18,17 @@ function basename(path: string): string {
   return at === -1 ? path : path.slice(at + 1)
 }
 
-/** Full props: the turn-tail owner currency plus this plugin's locale seat. */
-export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS>
+/** Matched paths plus the opener and locale seats needed to present them. */
+export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
+  matched: readonly string[]
+} & PropsLocale<typeof NS>
 
 /**
  * Render one turn's produced files as openable chips.
- * @param props - the tail hole's owner currency (snapshot nodes, the closing
- * assistant's seq, the chat view's file opener) and the locale seat.
- * @returns The row, or `null` when the turn produced nothing.
+ * @param props - selector-matched paths, the chat view's file opener, and the locale seat.
+ * @returns The produced-files row.
  */
-export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) {
-  // Per-closing-message derivation over the windowed snapshot: O(nodes) on
-  // node-identity change only, which is the same cadence the owning view
-  // re-derives its own flow at.
-  const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq])
-  if (paths.length === 0) return null
+export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
   const shown = paths.slice(0, SHOWN)
   const hidden = paths.length - shown.length
   return (

+ 2 - 2
packages/client/ui-deliverables/src/client/index.ts

@@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import { ProducedFiles } from './ProducedFiles.tsx'
 import { en, NS, zh, type DeliverablesKey } from './locales.ts'
+import { selectProducedFiles } from './turn-deliverables.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
@@ -34,8 +35,7 @@ export function apply(ctx: ClientContext): void {
     'conversation.chat.turnTail',
     () => ctx.slots.register({
       name: 'conversation.chat.turnTail',
-      id: 'produced-files',
-      order: 0,
+      select: selectProducedFiles,
       locale: NS,
     }, ProducedFiles),
   )

+ 11 - 0
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -4,6 +4,7 @@
  * own follow-along `locations`, never the closing prose.
  */
 import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 /**
  * Paths a call view reports having created or changed, by render intent rather
@@ -76,3 +77,13 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb
   }
   return []
 }
+
+/**
+ * Claim the turn-tail chain only when its closing turn produced files.
+ * @param owner - Turn-tail owner currency for the closing assistant.
+ * @returns Produced paths as the component's match, or null to decline before mount.
+ */
+export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null {
+  const paths = producedForClosing(nodes, seq)
+  return paths.length === 0 ? null : paths
+}

+ 5 - 10
packages/client/ui-deliverables/tests/produced-files.spec.tsx

@@ -15,7 +15,7 @@ import type {
 import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
-import { producedForClosing } from '../src/client/turn-deliverables.ts'
+import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as applyNode } from '../src/index.ts'
 import { apply as applyInvariant } from '../src/invariant.ts'
@@ -64,6 +64,8 @@ describe('producedForClosing derivation', () => {
       assistant(9, 'second turn', 2),
     ]
     expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css'])
+    expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css'])
+    expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull()
     // A turn that produced nothing yields the empty list, and so does an
     // anchor the window does not contain.
     expect(producedForClosing(nodes, 9)).toEqual([])
@@ -126,8 +128,7 @@ describe('ProducedFiles row', () => {
     // it shows and says so rather than dropping the rest silently.
     const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
     const openFile = vi.fn<(path: string) => void>()
-    const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)]
-    const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />)
+    const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
     expect(view.getByText('产物')).toBeTruthy()
     // Chips carry the basename; the full path stays reachable as the title.
     const chip = view.getByRole('button', { name: '打开 deep/a.html' })
@@ -138,12 +139,6 @@ describe('ProducedFiles row', () => {
     fireEvent.click(chip)
     expect(openFile).toHaveBeenCalledWith('deep/a.html')
   })
-
-  it('a turn that produced nothing renders no row at all', () => {
-    const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)]
-    const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />)
-    expect(view.container.firstChild).toBeNull()
-  })
 })
 
 describe('package shells', () => {
@@ -169,7 +164,7 @@ describe('plugin registration', () => {
     // The owning view's child declaration, stood up by a bench root entry.
     ctx.slots.register({
       name: 'root',
-      children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } },
+      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
     } as never, () => null)
     await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()