fix(web): show small file outputs inline

This commit is contained in:
xiaoche-hub
2026-07-16 19:44:47 +08:00
parent 36375bbeac
commit 83c6044634
3 changed files with 90 additions and 48 deletions
+48 -26
View File
@@ -1,10 +1,11 @@
/**
* "Files this turn" disclosure pinned to the top of an assistant message.
*
* 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.
* Up to four files stay visible so an artifact is presented as a result, not
* hidden inside execution history. Larger batches start as a disclosure to
* keep the response compact; a changed result file then 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
@@ -46,6 +47,8 @@ const OP_BADGE_GLYPH: Record<FileOpKind, string> = {
delete: 'D',
};
const COLLAPSE_AFTER_ENTRY_COUNT = 4;
export function FileOpsSummary({
entries,
streaming,
@@ -53,13 +56,16 @@ export function FileOpsSummary({
onRequestOpenFile,
}: Props) {
const t = useT();
// 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);
const [expanded, setExpanded] = useState(false);
if (entries.length === 0) return null;
// Small result sets should be immediately legible. Once a run touches more
// than four files, preserve a compact answer and let the user opt into the
// audit list. A manually expanded large batch stays open as it streams.
const isCollapsible = entries.length > COLLAPSE_AFTER_ENTRY_COUNT;
const open = !isCollapsible || expanded;
const counts = countFileOps(entries);
const summaryParts: string[] = [];
if (counts.write > 0) summaryParts.push(`${t('tool.write')} ${counts.write}`);
@@ -80,6 +86,21 @@ export function FileOpsSummary({
!!primaryEntry &&
!!onRequestOpenFile &&
(projectFileNames ? projectFileNames.has(primaryEntry.path) : true);
const header = (
<>
<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>
{isCollapsible ? (
<span className="file-ops-chev" aria-hidden>
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={11} />
</span>
) : null}
</>
);
return (
<div
@@ -87,24 +108,25 @@ export function FileOpsSummary({
data-testid="file-ops-summary"
>
<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 ? (
{isCollapsible ? (
<button
type="button"
className="file-ops-toggle"
onClick={() => setExpanded((value) => !value)}
aria-expanded={open}
data-testid="file-ops-toggle"
>
{header}
</button>
) : (
<div
className="file-ops-toggle file-ops-toggle--static"
data-testid="file-ops-toggle"
>
{header}
</div>
)}
{canOpenPrimary && primaryEntry && !open ? (
<button
type="button"
className="file-ops-primary-action"
+5 -3
View File
@@ -269,9 +269,9 @@
word-break: break-all;
}
/* "Files this turn" summary — derived from file operation tool_use events
so users can scan every file the agent touched without expanding tool-
group disclosures. Compact pill while streaming, full list once done. */
/* "Files this turn" summary — derived from file operation tool_use events.
Show up to four files as part of the answer; larger batches stay compact
until the user opens the disclosure. */
.file-ops {
display: flex;
flex-direction: column;
@@ -306,6 +306,8 @@
min-width: 0;
}
.file-ops-toggle:hover { background: var(--bg-subtle); }
.file-ops-toggle--static { cursor: default; }
.file-ops-toggle--static:hover { background: transparent; }
.file-ops-primary-action {
flex: 0 0 auto;
margin: 4px 6px 4px 0;
@@ -27,7 +27,7 @@ describe('FileOpsSummary', () => {
expect(container.firstChild).toBeNull();
});
it('starts collapsed while streaming and surfaces per-op totals in the header', () => {
it('shows up to four files directly, including while the run is streaming', () => {
render(
<FileOpsSummary
entries={[
@@ -44,13 +44,13 @@ describe('FileOpsSummary', () => {
expect(screen.getByText(/Edit 3/)).toBeTruthy();
expect(screen.getByText(/Delete 1/)).toBeTruthy();
expect(screen.getByText(/Read 2/)).toBeTruthy();
// While streaming we collapse the file list so the running pill stays compact.
expect(screen.queryByTestId('file-ops-row-a.ts')).toBeNull();
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
expect(screen.getByTestId('file-ops-row-b.ts')).toBeTruthy();
const toggle = screen.getByTestId('file-ops-toggle');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.getAttribute('aria-expanded')).toBeNull();
});
it('stays collapsed once the run is complete until the user asks for file details', () => {
it('shows small completed result sets without a disclosure click', () => {
render(
<FileOpsSummary
entries={[
@@ -61,23 +61,18 @@ describe('FileOpsSummary', () => {
/>,
);
expect(screen.queryByTestId('file-ops-row-a.ts')).toBeNull();
expect(screen.queryByTestId('file-ops-row-b.ts')).toBeNull();
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
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('remains collapsed when a streaming turn finishes', () => {
it('keeps a small result set visible when a streaming turn finishes', () => {
const { rerender } = render(
<FileOpsSummary
entries={[entry({ path: 'a.ts' })]}
streaming
/>,
);
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
rerender(
<FileOpsSummary
@@ -85,7 +80,31 @@ describe('FileOpsSummary', () => {
streaming={false}
/>,
);
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
});
it('collapses batches larger than four files until the user expands them', () => {
render(
<FileOpsSummary
entries={[
entry({ path: 'a.ts' }),
entry({ path: 'b.ts' }),
entry({ path: 'c.ts' }),
entry({ path: 'd.ts' }),
entry({ path: 'e.ts' }),
]}
streaming={false}
/>,
);
const toggle = screen.getByTestId('file-ops-toggle');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(screen.queryByTestId('file-ops-row-a.ts')).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
expect(screen.getByTestId('file-ops-row-e.ts')).toBeTruthy();
});
it('shows the open button only for files that are present in the project file set', () => {
@@ -102,7 +121,6 @@ describe('FileOpsSummary', () => {
/>,
);
fireEvent.click(screen.getByTestId('file-ops-toggle'));
expect(screen.getByTestId('file-ops-row-open-a.ts')).toBeTruthy();
expect(screen.queryByTestId('file-ops-row-open-missing.ts')).toBeNull();
@@ -110,16 +128,19 @@ describe('FileOpsSummary', () => {
expect(onRequestOpenFile).toHaveBeenCalledWith('a.ts');
});
it('offers one direct result entry point for the latest changed file', () => {
it('offers one direct result entry point while a large batch is collapsed', () => {
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 } }),
entry({ path: 'third.ts' }),
entry({ path: 'fourth.ts' }),
entry({ path: 'fifth.ts' }),
]}
streaming={false}
projectFileNames={new Set(['input.ts', 'result.ts'])}
projectFileNames={new Set(['input.ts', 'result.ts', 'third.ts', 'fourth.ts', 'fifth.ts'])}
onRequestOpenFile={onRequestOpenFile}
/>,
);
@@ -142,7 +163,6 @@ describe('FileOpsSummary', () => {
/>,
);
fireEvent.click(screen.getByTestId('file-ops-toggle'));
expect(screen.getByTestId('file-ops-row-gone.ts')).toBeTruthy();
expect(screen.queryByTestId('file-ops-row-open-gone.ts')).toBeNull();
});
@@ -157,8 +177,6 @@ describe('FileOpsSummary', () => {
streaming
/>,
);
fireEvent.click(screen.getByTestId('file-ops-toggle'));
const pending = screen.getByTestId('file-ops-row-pending.ts');
const broken = screen.getByTestId('file-ops-row-broken.ts');
expect(pending.className).toContain('file-ops-row--running');