refactor(web): consolidate chat task activity

This commit is contained in:
xiaoche-hub
2026-07-16 19:44:47 +08:00
parent 99731894cd
commit 36375bbeac
6 changed files with 326 additions and 44 deletions
+180 -3
View File
@@ -586,6 +586,16 @@ function AssistantMessageImpl({
},
);
}, [displayEvents, liveCodeBlocks, showConversationTodoCard, conversationTodoInput]);
// A task gets one execution-record disclosure. This keeps answer prose
// readable when an agent has alternated between many tools, while retaining
// every command, file operation, and streaming code preview for review.
// TodoWrite is deliberately left in place: ChatPane owns its single
// conversation-level progress card, which is a different user-facing
// contract from the per-turn execution record.
const { contentBlocks, taskActivity } = useMemo(
() => splitTaskActivity(blocks),
[blocks],
);
const fileOps = useMemo(() => deriveFileOps(displayEvents), [displayEvents]);
const produced = message.producedFiles ?? [];
const displayedProduced = useMemo(
@@ -821,7 +831,10 @@ function AssistantMessageImpl({
// files) the footer shimmers "Preparing…"; the moment content lands it
// flips to "Working". The elapsed clock stays anchored to the persisted run
// start so switching project tabs or remounting the message cannot restart it.
const hasContent = blocks.some((b) => b.kind !== "status") || turnFileOps.length > 0;
const hasContent =
contentBlocks.some((b) => b.kind !== "status") ||
taskActivity !== null ||
turnFileOps.length > 0;
const preparing = streaming && !hasContent;
const preparingStatus = preparing && events.some((e) => e.kind === "status" && e.label === "thinking")
? "thinking"
@@ -849,7 +862,7 @@ function AssistantMessageImpl({
<span className="role-name">{roleName}</span>
</div>
<div className="assistant-flow">
{blocks.map((b, i) => {
{contentBlocks.map((b, i) => {
if (b.kind === "text")
return (
<ProseBlock
@@ -899,7 +912,10 @@ function AssistantMessageImpl({
);
}
if (b.kind === "live-tool") {
return <LiveCodeBox key={b.id} name={b.name} raw={b.raw} />;
// splitTaskActivity moves live code into the same execution
// disclosure as settled tools. Keep this branch defensive in
// case a future block type opts out of that collection.
return null;
}
if (b.kind === "plugin-candidate") {
return (
@@ -925,6 +941,16 @@ function AssistantMessageImpl({
}
return null;
})}
{taskActivity ? (
<TaskActivityCard
items={taskActivity.items}
liveTools={taskActivity.liveTools}
runStreaming={streaming}
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
) : null}
{brandBrowserAssistFallbackCard ? (
<OdCardView
card={brandBrowserAssistFallbackCard}
@@ -3571,6 +3597,97 @@ function ToolGroupCard({
);
}
/**
* One compact, per-turn audit trail for every non-progress tool. The default
* view intentionally says what happened without making individual tool cards
* compete with the answer or the result card; opening it restores the full
* chronological evidence, including code that was still streaming.
*/
function TaskActivityCard({
items,
liveTools,
runStreaming,
runSucceeded,
projectFileNames,
onRequestOpenFile,
}: {
items: ToolItem[];
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
runStreaming: boolean;
runSucceeded: boolean;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const settledItems = dedupeSnapshotToolRetries(items);
const running =
runStreaming &&
(liveTools.length > 0 || settledItems.some((item) => !item.result));
const hasError = settledItems.some(
(item) => item.result?.isError || (!item.result && !runStreaming && !runSucceeded),
);
const summary =
settledItems.length > 0
? summarizeTaskActivity(settledItems, t, running, runSucceeded)
: t("assistant.verbRunning");
const count = settledItems.length + liveTools.length;
return (
<div className="action-card task-activity">
<button
type="button"
className={`action-card-toggle ${running ? "running" : ""}`}
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
data-testid="task-activity-toggle"
>
<span
className={`action-card-status ${
running ? "op-status-running" : hasError ? "op-status-error" : "op-status-ok"
}`}
aria-hidden
>
{running ? (
<Icon name="spinner" size={14} />
) : hasError ? (
<Icon name="close" size={14} />
) : (
<Icon name="check" size={14} />
)}
</span>
<span className={`summary${running ? " shimmer-text" : ""}`}>{summary}</span>
<span className="task-activity-count" aria-hidden>
{count}
</span>
<span className="chev" aria-hidden>
<Icon name={open ? "chevron-down" : "chevron-right"} size={11} />
</span>
</button>
<div className={`accordion-collapsible${open ? " open" : ""}`}>
<div className="accordion-collapsible-inner">
<div className="action-card-body">
{settledItems.map((item, index) => (
<ToolCard
key={item.use.id || index}
use={item.use}
result={item.result}
runStreaming={runStreaming}
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
))}
{liveTools.map((tool) => (
<LiveCodeBox key={tool.id} name={tool.name} raw={tool.raw} />
))}
</div>
</div>
</div>
</div>
);
}
function summarizeGroup(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
@@ -3592,6 +3709,28 @@ function summarizeGroup(
return { label: tail ? `${head}, ${tail}` : head, icon };
}
function summarizeTaskActivity(
items: ToolItem[],
t: (k: keyof Dict, vars?: Record<string, string | number>) => string,
running: boolean,
runSucceeded: boolean,
): string {
const familyCounts = new Map<string, number>();
for (const item of items) {
const family = toolFamily(item.use.name);
familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
}
const families = [...familyCounts.entries()];
const labels = families.slice(0, 3).map(([family, count]) => countLabel(family, count, t));
if (families.length > labels.length) labels.push(`+${families.length - labels.length}`);
const state = lastStateLabel(
items.map((item) => verbForState(item, t, running, runSucceeded)),
t,
);
return [...labels, ...(state ? [state] : [])].join(" · ");
}
function toolFamily(name: string): string {
if (name === "Edit" || name === "str_replace_edit") return "edit";
if (name === "Write" || name === "write" || name === "create_file") return "write";
@@ -3676,6 +3815,44 @@ type Block =
}
| { kind: "status"; label: string; detail?: string | undefined };
type TaskActivity = {
items: ToolItem[];
liveTools: Array<Extract<Block, { kind: "live-tool" }>>;
};
function splitTaskActivity(blocks: Block[]): {
contentBlocks: Block[];
taskActivity: TaskActivity | null;
} {
const contentBlocks: Block[] = [];
const items: ToolItem[] = [];
const liveTools: TaskActivity["liveTools"] = [];
for (const block of blocks) {
if (block.kind === "live-tool") {
liveTools.push(block);
continue;
}
if (block.kind === "tool-group") {
// The canonical TodoWrite display is kept in its anchored chat row. It
// remains the one task-progress surface, rather than becoming another
// item inside the execution audit trail.
if (block.items.every((item) => isTodoWriteToolName(item.use.name))) {
contentBlocks.push(block);
} else {
items.push(...block.items);
}
continue;
}
contentBlocks.push(block);
}
return {
contentBlocks,
taskActivity: items.length > 0 || liveTools.length > 0 ? { items, liveTools } : null,
};
}
/**
* Walk the event stream and build the rendering layout list. We additionally
* collapse runs of consecutive tool_uses sharing the same tool family into a
+52 -29
View File
@@ -1,10 +1,10 @@
/**
* "Files this turn" disclosure pinned to the top of an assistant message.
*
* The row appears as a compact summary with operation counters
* (Write 1 · Edit 2 · Read 3). File-level detail is available on demand,
* with per-file op badges and an "Open" button that lifts the basename up
* to ProjectView so FileWorkspace focuses the matching tab.
* The row stays compact with live counters (Write 1 · Edit 2 · Read 3).
* Opening its disclosure reveals the full file list with per-file op badges;
* a changed result file gets one direct "Open" action that lifts the basename
* up to ProjectView so FileWorkspace focuses the matching tab.
*
* The component is read-only over `events` — derivation lives in
* `runtime/file-ops.ts` so the same logic is reachable from tests and
@@ -23,8 +23,7 @@ import { Icon } from './Icon';
interface Props {
entries: FileOpEntry[];
/** True while the parent run is still streaming. Drives the live-pulse
* styling; file-level detail stays behind the disclosure in both states. */
/** True while the parent run is still streaming. Drives live-pulse styling. */
streaming: boolean;
/** Names that exist in the project folder. When set, the open button
* only shows for entries whose basename is in the set. Pass undefined
@@ -54,10 +53,9 @@ export function FileOpsSummary({
onRequestOpenFile,
}: Props) {
const t = useT();
// The header is the durable result summary. Keep the per-file operation
// list collapsed by default both while running and after completion, so a
// long task does not leave a second, competing result card in the chat.
// It remains one click away for review and manual toggles always win.
// File details are review material, not the result itself. Keep them
// collapsed in every terminal state so a completed task reads as answer →
// delivery, while preserving a single disclosure for audit.
const [open, setOpen] = useState(false);
if (entries.length === 0) return null;
@@ -69,30 +67,55 @@ export function FileOpsSummary({
if (counts.delete > 0) summaryParts.push(`${t('tool.delete')} ${counts.delete}`);
if (counts.read > 0) summaryParts.push(`${t('tool.read')} ${counts.read}`);
// Prefer the latest changed file as the result entry point. Read-only turns
// intentionally have no primary action: opening an arbitrary input would
// make the execution record look like a delivery card.
const primaryEntry = [...entries]
.reverse()
.find((entry) =>
!entry.ops.includes('delete') &&
(entry.ops.includes('write') || entry.ops.includes('edit')),
);
const canOpenPrimary =
!!primaryEntry &&
!!onRequestOpenFile &&
(projectFileNames ? projectFileNames.has(primaryEntry.path) : true);
return (
<div
className={`file-ops${streaming ? ' is-streaming' : ''}`}
data-testid="file-ops-summary"
>
<button
type="button"
className="file-ops-toggle"
onClick={() => {
setOpen((value) => !value);
}}
aria-expanded={open}
data-testid="file-ops-toggle"
>
<span className="file-ops-icon" aria-hidden>
<Icon name="file" size={13} />
</span>
<span className="file-ops-label">{t('assistant.producedFiles')}</span>
<span className="file-ops-summary-line">{summaryParts.join(' · ')}</span>
<span className="file-ops-count">{entries.length}</span>
<span className="file-ops-chev" aria-hidden>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
</button>
<div className="file-ops-head">
<button
type="button"
className="file-ops-toggle"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
data-testid="file-ops-toggle"
>
<span className="file-ops-icon" aria-hidden>
<Icon name="file" size={13} />
</span>
<span className="file-ops-label">{t('assistant.producedFiles')}</span>
<span className="file-ops-summary-line">{summaryParts.join(' · ')}</span>
<span className="file-ops-count">{entries.length}</span>
<span className="file-ops-chev" aria-hidden>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
</button>
{canOpenPrimary && primaryEntry ? (
<button
type="button"
className="file-ops-primary-action"
onClick={() => onRequestOpenFile?.(primaryEntry.path)}
title={t('tool.openInTab', { name: primaryEntry.path })}
data-testid={`file-ops-primary-open-${primaryEntry.path}`}
>
{t('assistant.openFile')}
</button>
) : null}
</div>
{open ? (
<ul className="file-ops-list" role="list">
{entries.map((entry) => (
+14
View File
@@ -1583,6 +1583,20 @@
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.task-activity-count {
flex: 0 0 auto;
min-width: 18px;
padding: 1px 5px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-pill);
color: var(--text-faint);
font-size: 10px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.task-activity .action-card-body {
padding-top: 4px;
}
.action-card-toggle .chev { color: var(--text-faint); font-size: 10px; flex-shrink: 0; transition: transform 120ms var(--ease-out); }
.action-card-body {
padding: 8px 0 8px 32px;
+24
View File
@@ -284,11 +284,18 @@
border-style: dashed;
background: var(--bg-subtle);
}
.file-ops-head {
display: flex;
align-items: center;
min-width: 0;
}
.file-ops-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
flex: 1 1 auto;
padding: 7px 10px;
background: transparent;
border: 0;
@@ -299,6 +306,23 @@
min-width: 0;
}
.file-ops-toggle:hover { background: var(--bg-subtle); }
.file-ops-primary-action {
flex: 0 0 auto;
margin: 4px 6px 4px 0;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text);
font-size: 11px;
cursor: pointer;
transition: color 120ms var(--ease-out), border-color 120ms var(--ease-out), background 120ms var(--ease-out);
}
.file-ops-primary-action:hover {
border-color: var(--accent);
background: var(--accent-tint);
color: var(--accent);
}
.file-ops-icon { display: inline-flex; color: var(--text-muted); }
.file-ops-label {
font-size: 10.5px;
@@ -50,7 +50,7 @@ describe('FileOpsSummary', () => {
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
it('keeps file-level detail collapsed after completion until the user asks for it', () => {
it('stays collapsed once the run is complete until the user asks for file details', () => {
render(
<FileOpsSummary
entries={[
@@ -63,15 +63,14 @@ describe('FileOpsSummary', () => {
expect(screen.queryByTestId('file-ops-row-a.ts')).toBeNull();
expect(screen.queryByTestId('file-ops-row-b.ts')).toBeNull();
const toggle = screen.getByTestId('file-ops-toggle');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId('file-ops-toggle'));
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
expect(screen.getByTestId('file-ops-row-b.ts')).toBeTruthy();
});
it('stays collapsed when streaming flips to false', () => {
it('remains collapsed when a streaming turn finishes', () => {
const { rerender } = render(
<FileOpsSummary
entries={[entry({ path: 'a.ts' })]}
@@ -111,6 +110,25 @@ describe('FileOpsSummary', () => {
expect(onRequestOpenFile).toHaveBeenCalledWith('a.ts');
});
it('offers one direct result entry point for the latest changed file', () => {
const onRequestOpenFile = vi.fn();
render(
<FileOpsSummary
entries={[
entry({ path: 'input.ts' }),
entry({ path: 'result.ts', ops: ['write'], opCounts: { read: 0, write: 1, edit: 0, delete: 0 } }),
]}
streaming={false}
projectFileNames={new Set(['input.ts', 'result.ts'])}
onRequestOpenFile={onRequestOpenFile}
/>,
);
fireEvent.click(screen.getByTestId('file-ops-primary-open-result.ts'));
expect(onRequestOpenFile).toHaveBeenCalledWith('result.ts');
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
});
it('does not show the open button for deleted files', () => {
const onRequestOpenFile = vi.fn();
render(
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { AgentEvent, ChatMessage } from '../../src/types';
@@ -125,16 +125,42 @@ describe('AssistantMessage tool status', () => {
/>,
);
const toggle = container.querySelector<HTMLButtonElement>('.action-card-toggle');
expect(toggle).not.toBeNull();
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector('.action-card .accordion-collapsible')?.className).not.toContain('open');
if (toggle) fireEvent.click(toggle);
expect(container.querySelector('.action-card .accordion-collapsible')?.className).toContain('open');
const activity = screen.getByTestId('task-activity-toggle');
expect(activity).toBeTruthy();
expect(activity.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(activity);
expect(activity.getAttribute('aria-expanded')).toBe('true');
expect(container.querySelectorAll('.op-card.op-file')).toHaveLength(1);
expect(container.querySelector('[data-testid="file-ops-toggle"]')?.textContent).toContain('Write 1');
expect(container.textContent).not.toContain('×2');
});
it('collapses mixed tool families into one execution record', () => {
const { container } = render(
<AssistantMessage
projectKind="prototype"
conversationId="conv-1"
message={messageWithEvents([
{ kind: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/repo/source.ts' } },
{ kind: 'tool_result', toolUseId: 'tool-1', content: 'source', isError: false },
{ kind: 'tool_use', id: 'tool-2', name: 'Write', input: { file_path: '/repo/result.ts', content: 'export {}' } },
{ kind: 'tool_result', toolUseId: 'tool-2', content: 'ok', isError: false },
{ kind: 'tool_use', id: 'tool-3', name: 'Bash', input: { command: 'pnpm typecheck' } },
{ kind: 'tool_result', toolUseId: 'tool-3', content: 'ok', isError: false },
])}
streaming={false}
projectId="project-1"
/>,
);
const activity = screen.getByTestId('task-activity-toggle');
expect(activity).toBeTruthy();
expect(activity.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(activity);
expect(activity.getAttribute('aria-expanded')).toBe('true');
expect(container.querySelectorAll('.op-card')).toHaveLength(3);
});
it('does not show Done when a failed run is missing a tool result', () => {
const { container } = render(
<AssistantMessage