fix(web): make file output disclosure interactive

This commit is contained in:
xiaoche-hub
2026-07-16 19:44:48 +08:00
parent ebb86284ef
commit 2ee81afcdb
3 changed files with 13 additions and 55 deletions
+6 -32
View File
@@ -3,9 +3,8 @@
*
* The first four files stay visible so artifacts are presented as results,
* not hidden inside execution history. In larger batches only the remaining
* files start collapsed; a changed result file also gets one direct "Open"
* action that lifts the basename up to ProjectView so FileWorkspace focuses
* the matching tab.
* files start collapsed. Each visible file row owns its own Open action so the
* header remains a pure disclosure target without a second, ambiguous button.
*
* The component is read-only over `events` — derivation lives in
* `runtime/file-ops.ts` so the same logic is reachable from tests and
@@ -64,6 +63,9 @@ export function FileOpsSummary({
// files, only rows after the fourth start hidden; expanding reveals the
// remainder without making the entire result set disappear by default.
const isCollapsible = entries.length > COLLAPSE_AFTER_ENTRY_COUNT;
const visibleEntries = isCollapsible && !expanded
? entries.slice(0, COLLAPSE_AFTER_ENTRY_COUNT)
: entries;
const counts = countFileOps(entries);
const summaryParts: string[] = [];
@@ -72,19 +74,6 @@ 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);
const header = (
<>
<span className="file-ops-icon" aria-hidden>
@@ -125,24 +114,12 @@ export function FileOpsSummary({
{header}
</div>
)}
{canOpenPrimary && primaryEntry && isCollapsible && !expanded ? (
<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>
<ul className="file-ops-list" role="list">
{entries.map((entry, index) => (
{visibleEntries.map((entry) => (
<FileOpRow
key={entry.fullPath}
entry={entry}
hidden={isCollapsible && !expanded && index >= COLLAPSE_AFTER_ENTRY_COUNT}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
/>
@@ -154,12 +131,10 @@ export function FileOpsSummary({
function FileOpRow({
entry,
hidden,
projectFileNames,
onRequestOpenFile,
}: {
entry: FileOpEntry;
hidden?: boolean;
projectFileNames?: Set<string> | undefined;
onRequestOpenFile?: ((name: string) => void) | undefined;
}) {
@@ -172,7 +147,6 @@ function FileOpRow({
<li
className={`file-ops-row file-ops-row--${entry.status}`}
data-testid={`file-ops-row-${entry.path}`}
hidden={hidden}
>
<div className="file-ops-row-badges" aria-hidden>
{entry.ops.map((op) => {
-17
View File
@@ -313,23 +313,6 @@
.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;
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;
@@ -99,13 +99,13 @@ describe('FileOpsSummary', () => {
const toggle = screen.getByTestId('file-ops-toggle');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(screen.getByTestId('file-ops-row-a.ts')).not.toHaveAttribute('hidden');
expect(screen.getByTestId('file-ops-row-d.ts')).not.toHaveAttribute('hidden');
expect(screen.getByTestId('file-ops-row-e.ts')).toHaveAttribute('hidden');
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
expect(screen.getByTestId('file-ops-row-d.ts')).toBeTruthy();
expect(screen.queryByTestId('file-ops-row-e.ts')).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByTestId('file-ops-row-e.ts')).not.toHaveAttribute('hidden');
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', () => {
@@ -129,7 +129,7 @@ describe('FileOpsSummary', () => {
expect(onRequestOpenFile).toHaveBeenCalledWith('a.ts');
});
it('offers one direct result entry point while a large batch is collapsed', () => {
it('keeps the header free of a redundant open action', () => {
const onRequestOpenFile = vi.fn();
render(
<FileOpsSummary
@@ -146,7 +146,8 @@ describe('FileOpsSummary', () => {
/>,
);
fireEvent.click(screen.getByTestId('file-ops-primary-open-result.ts'));
expect(screen.queryByTestId('file-ops-primary-open-result.ts')).toBeNull();
fireEvent.click(screen.getByTestId('file-ops-row-open-result.ts'));
expect(onRequestOpenFile).toHaveBeenCalledWith('result.ts');
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
});