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 (
+