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,
|
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||||
|
import { Deliverables } from './Deliverables.tsx'
|
||||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||||
import { ToolRow } from './ToolRow.tsx'
|
import { ToolRow } from './ToolRow.tsx'
|
||||||
import css from './AssistantMarkdown.module.css'
|
import css from './AssistantMarkdown.module.css'
|
||||||
@@ -30,6 +31,11 @@ export interface AssistantMarkdownProps {
|
|||||||
seq?: number | undefined
|
seq?: number | undefined
|
||||||
/** Fork the session through the turn containing this finalized message. */
|
/** Fork the session through the turn containing this finalized message. */
|
||||||
onFork?: ((seq: number) => void) | undefined
|
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. */
|
/** The owning view's locale seat, passed down as a plain prop. */
|
||||||
t: ChatViewSlotProps['t']
|
t: ChatViewSlotProps['t']
|
||||||
}
|
}
|
||||||
@@ -69,7 +75,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
blocks, streaming, interrupted, time, seq, onFork, t,
|
blocks, streaming, interrupted, time, seq, onFork, produced, openFile, t,
|
||||||
}: AssistantMarkdownProps) {
|
}: AssistantMarkdownProps) {
|
||||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||||
// per render would rebuild MarkdownText's component table every chunk.
|
// 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>}
|
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{showActions && produced !== undefined && openFile !== undefined && (
|
||||||
|
<Deliverables paths={produced} openFile={openFile} t={t} />
|
||||||
|
)}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<MessageIconActions
|
<MessageIconActions
|
||||||
text={copyText(blocks)}
|
text={copyText(blocks)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import type {
|
|||||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
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 { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||||
import { GenericToolCard } from './GenericToolCard.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
|
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
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 listRef = useRef<HTMLDivElement | null>(null)
|
||||||
const atBottomRef = useRef(true)
|
const atBottomRef = useRef(true)
|
||||||
@@ -402,6 +405,8 @@ export function ChatView({
|
|||||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||||
seq={node.seq}
|
seq={node.seq}
|
||||||
onFork={forkAt}
|
onFork={forkAt}
|
||||||
|
produced={produced.get(node.seq)}
|
||||||
|
openFile={openFile}
|
||||||
t={t}
|
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())
|
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.
|
* Group finalized nodes into the step-summary flow.
|
||||||
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
|
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const zh = {
|
|||||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||||
'message.unknownBlock': '未知内容块',
|
'message.unknownBlock': '未知内容块',
|
||||||
'message.stopped': '已停止',
|
'message.stopped': '已停止',
|
||||||
|
'produced.label': '产物',
|
||||||
|
'produced.more': '还有 {count} 个',
|
||||||
|
'produced.open': '打开 {name}',
|
||||||
'message.branch': '在新对话中分支',
|
'message.branch': '在新对话中分支',
|
||||||
'message.retry.active': '正在重试模型请求',
|
'message.retry.active': '正在重试模型请求',
|
||||||
'message.retry.cancelled': '模型请求重试已取消',
|
'message.retry.cancelled': '模型请求重试已取消',
|
||||||
@@ -152,6 +155,9 @@ export const en = {
|
|||||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||||
'message.unknownBlock': 'Unknown content block',
|
'message.unknownBlock': 'Unknown content block',
|
||||||
'message.stopped': 'Stopped',
|
'message.stopped': 'Stopped',
|
||||||
|
'produced.label': 'Produced',
|
||||||
|
'produced.more': '{count} more',
|
||||||
|
'produced.open': 'Open {name}',
|
||||||
'message.branch': 'Branch into a new conversation',
|
'message.branch': 'Branch into a new conversation',
|
||||||
'message.retry.active': 'Retrying model request',
|
'message.retry.active': 'Retrying model request',
|
||||||
'message.retry.cancelled': 'Model request retry cancelled',
|
'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 { createChatStore } from '../src/client/stores.ts'
|
||||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||||
import { zh } from '../src/client/locales.ts'
|
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)
|
afterEach(cleanup)
|
||||||
// Keyless create() persists under the bare declared key; clear between cases
|
// 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])
|
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', () => {
|
describe('ChatView', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user