diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..024a67a843 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -14,6 +14,7 @@ import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -30,6 +31,11 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through the turn containing this finalized message. */ onFork?: ((seq: number) => void) | undefined + /** Files the closing turn produced, listed under the body; omitted for a + * mid-turn assistant and for a turn that wrote nothing. */ + produced?: readonly string[] | undefined + /** Opens one produced file; omitted wherever `produced` is. */ + openFile?: ((path: string) => void) | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -69,7 +75,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, produced, openFile, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -107,6 +113,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}} + {showActions && produced !== undefined && openFile !== undefined && ( + + )} {showActions && ( assistantActionsSeqs(nodes), [nodes]) + // Produced files per closing assistant: derived from the mutation tools' + // locations, so a turn's output is listed whether or not the model named it. + const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -402,6 +405,8 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + produced={produced.get(node.seq)} + openFile={openFile} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css new file mode 100644 index 0000000000..2077de48ac --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css @@ -0,0 +1,44 @@ +/* Turn-tail produced-files row: a quiet label followed by wrapping file chips. + Sits between the assistant body and its IconActions footer, so it reads as + part of the answer rather than as another tool row. */ + +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 16px; + font-size: 13px; + line-height: 22px; +} + +.label { + color: var(--dsw-alias-label-tertiary); +} + +/* One produced file. A link by behavior (it opens the file), a chip by shape: + full paths are long and several may wrap onto one row. */ +.file { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0 8px; + border: none; + border-radius: 6px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font: inherit; + cursor: pointer; +} + +.file:hover { + color: var(--dsw-alias-label-primary); + text-decoration: underline; +} + +/* Overflow count: the row never silently drops files it did not show. */ +.more { + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx new file mode 100644 index 0000000000..0a0160b486 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -0,0 +1,54 @@ +// Deliverables: the produced-file row a finished turn ends with. The paths come +// from the mutation tools' follow-along locations (see turnDeliverables), 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 — in the browser that is a new tab served from the session +// workspace, and outside it the Host's own opener. + +import type { ChatViewSlotProps } from '../contract/slots.ts' +import css from './Deliverables.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * Render one turn's produced files as openable chips. + * @param props - the turn's paths (tool order, already deduped), the chat + * view's file opener, and the owning view's locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function Deliverables({ paths, openFile, t }: { + paths: readonly string[] + openFile: (path: string) => void + t: ChatViewSlotProps['t'] +}) { + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( +
+ {t('produced.label')} + {shown.map(path => ( + + ))} + {hidden > 0 && {t('produced.more', { count: String(hidden) })}} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..83ba5c463c 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -47,6 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Files each turn produced, keyed by the assistant seq that closes it — the + * same anchor {@link assistantActionsSeqs} elects, so the row lands under the + * message that reports the work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. Reads contribute nothing (looking at a file does not + * produce it) and a failed mutation contributes nothing (there is no file to + * open). Paths keep first-seen order and appear once, so a file written and + * then edited in the same turn is one entry. + * @param nodes - snapshot nodes (surface order). + * @returns Per-closing-seq produced paths; a turn that produced none is absent. + */ +export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap { + const closing = assistantActionsSeqs(nodes) + const byClosingSeq = new Map() + let pending: string[] = [] + const seen = new Set() + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError || node.callView?.card !== 'diff') continue + for (const location of node.callView.locations ?? []) { + if (seen.has(location.path)) continue + seen.add(location.path) + pending.push(location.path) + } + continue + } + if (node.kind !== 'assistant' || !closing.has(node.seq)) continue + if (pending.length > 0) byClosingSeq.set(node.seq, pending) + pending = [] + seen.clear() + } + return byClosingSeq +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1bda57660d..78114fa2a8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -53,6 +53,9 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', @@ -152,6 +155,9 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 110ab6a991..6f855231ac 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, turnDeliverables } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -211,6 +211,61 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + const produced = turnDeliverables([ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ]) + expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing is absent, not an empty row. + expect(produced.has(9)).toBe(false) + // Nothing at all written: no entries. + expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) + }) + + it('renders the produced files under the closing message and opens one on click', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + // Seven files: six chips plus an explicit remainder — the row bounds what + // 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 h = makeHarness({ + nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], + }) + 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' }) + expect(chip.textContent).toBe('a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(h.openFile).toHaveBeenCalledWith('deep/a.html') + }) }) describe('ChatView', () => {