diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8eb78139c4..24325c714e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/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): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 687e1ae86c..bc1c6c7e32 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/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, '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 && {t('message.stopped')}} - {showActions && tail} + {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} {showActions && ( 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} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1246433a33..89f7986dba 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/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' diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f2bb8709d7..b8cd94de52 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -130,6 +130,8 @@ function makeHarness(init?: Partial) { 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) { 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
diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 609a688586..ab85869de2 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/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 +/** Matched paths plus the opener and locale seats needed to present them. */ +export type ProducedFilesProps = Pick & { + matched: readonly string[] +} & PropsLocale /** * 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 ( diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 536c019b01..6dc7bc4b84 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/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), ) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index faa0455b37..a3ddf40b59 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/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 +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index e5d92424a3..49e41ebd86 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/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() + const view = render() 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( {}} 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()