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:
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user