feat(web): list a turn's produced files under its closing message

The paths come from the mutation tools' follow-along locations, not from the
closing prose, so a turn's output is listed whether or not the model named
it. Each chip opens through the same openFile the tool rows use.

Reads contribute nothing (looking at a file does not produce it), a failed
mutation contributes nothing, a file touched twice is one entry, and the row
shows six with an explicit remainder rather than burying the answer.
This commit is contained in:
ZiyaZhang
2026-07-31 22:13:34 -07:00
parent 00390ae851
commit 35e9122a65
7 changed files with 213 additions and 3 deletions

View File

@@ -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 && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && produced !== undefined && openFile !== undefined && (
<Deliverables paths={produced} openFile={openFile} t={t} />
)}
{showActions && (
<MessageIconActions
text={copyText(blocks)}

View File

@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, turnDeliverables, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -252,6 +252,9 @@ export function ChatView({
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => 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<HTMLDivElement | null>(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}
/>
)

View File

@@ -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);
}

View File

@@ -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 (
<div className={css.root}>
<span className={css.label}>{t('produced.label')}</span>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
</button>
))}
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
</div>
)
}

View File

@@ -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<number, readonly string[]> {
const closing = assistantActionsSeqs(nodes)
const byClosingSeq = new Map<number, readonly string[]>()
let pending: string[] = []
const seen = new Set<string>()
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.

View File

@@ -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',

View File

@@ -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 turns 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(<h.ChatView {...h.props} />)
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', () => {