mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): show small file outputs inline
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user