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

@@ -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(<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', () => {