mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): implement file operations summary in assistant messages
- Added a new `FileOpsSummary` component to display a summary of file operations (read, write, edit) performed during an agent's run, enhancing user visibility into file interactions. - Integrated the `FileOpsSummary` into the `AssistantMessage` component, allowing it to show a compact view while streaming and expand to a detailed list once the run completes. - Created a new `file-ops` CSS style to manage the presentation of the file operations summary, including hover effects and status indicators. - Developed utility functions in `runtime/file-ops.ts` to derive and count file operations from agent events, ensuring accurate aggregation of file interactions. - Added tests for the `FileOpsSummary` component and the file operations logic to ensure functionality and prevent regressions. This update improves the user experience by providing clear insights into file operations related to agent activities.
This commit is contained in:
parent
070b8b07c6
commit
4983fc3ac4
7 changed files with 874 additions and 71 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { FileOpsSummary } from "./FileOpsSummary";
|
||||
import { renderMarkdown } from "../runtime/markdown";
|
||||
import { projectFileUrl } from "../providers/registry";
|
||||
import {
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
import { QuestionFormView, parseSubmittedAnswers } from "./QuestionForm";
|
||||
import { Icon } from "./Icon";
|
||||
import { useT } from "../i18n";
|
||||
import { deriveFileOps } from "../runtime/file-ops";
|
||||
import { unfinishedTodosFromEvents, type TodoItem } from "../runtime/todos";
|
||||
import type { Dict } from "../i18n/types";
|
||||
import { agentDisplayName, exactAgentDisplayName } from "../utils/agentLabels";
|
||||
|
|
@ -67,6 +69,7 @@ export function AssistantMessage({
|
|||
const t = useT();
|
||||
const events = message.events ?? [];
|
||||
const blocks = buildBlocks(events);
|
||||
const fileOps = useMemo(() => deriveFileOps(events), [events]);
|
||||
const usage = events.find((e) => e.kind === "usage") as
|
||||
| Extract<AgentEvent, { kind: "usage" }>
|
||||
| undefined;
|
||||
|
|
@ -97,6 +100,14 @@ export function AssistantMessage({
|
|||
latestStatus={latestStatusLabel(events)}
|
||||
/>
|
||||
) : null}
|
||||
{fileOps.length > 0 ? (
|
||||
<FileOpsSummary
|
||||
entries={fileOps}
|
||||
streaming={streaming}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
{blocks.map((b, i) => {
|
||||
if (b.kind === "text")
|
||||
return (
|
||||
|
|
|
|||
179
apps/web/src/components/FileOpsSummary.tsx
Normal file
179
apps/web/src/components/FileOpsSummary.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* "Files this turn" disclosure pinned to the top of an assistant message.
|
||||
*
|
||||
* While the run streams, the row appears as a compact pill with live
|
||||
* counters (Write 1 · Edit 2 · Read 3). Once the run finishes, the row
|
||||
* expands to a full file list with per-file op badges and an "Open"
|
||||
* button 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
|
||||
* future surfaces (sidebar, log export, etc.) without coupling to
|
||||
* AssistantMessage's render shape.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import {
|
||||
countFileOps,
|
||||
type FileOpEntry,
|
||||
type FileOpKind,
|
||||
} from '../runtime/file-ops';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface Props {
|
||||
entries: FileOpEntry[];
|
||||
/** True while the parent run is still streaming. Drives default-open
|
||||
* state (collapsed when active, expanded once done) and the 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
|
||||
* to opt out of the existence check (button always shown). */
|
||||
projectFileNames?: Set<string> | undefined;
|
||||
onRequestOpenFile?: ((name: string) => void) | undefined;
|
||||
}
|
||||
|
||||
const OP_LABEL_KEY: Record<FileOpKind, keyof Dict> = {
|
||||
read: 'tool.read',
|
||||
write: 'tool.write',
|
||||
edit: 'tool.edit',
|
||||
};
|
||||
|
||||
const OP_BADGE_GLYPH: Record<FileOpKind, string> = {
|
||||
read: 'R',
|
||||
write: 'W',
|
||||
edit: 'E',
|
||||
};
|
||||
|
||||
export function FileOpsSummary({
|
||||
entries,
|
||||
streaming,
|
||||
projectFileNames,
|
||||
onRequestOpenFile,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
// Collapsed while streaming so the running pill stays compact; once
|
||||
// the run finishes we open it so the user lands on the full file list
|
||||
// without an extra click. Manual toggles win after that.
|
||||
const [open, setOpen] = useState<boolean>(!streaming);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!userToggled && !streaming) setOpen(true);
|
||||
}, [streaming, userToggled]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const counts = countFileOps(entries);
|
||||
const summaryParts: string[] = [];
|
||||
if (counts.write > 0) summaryParts.push(`${t('tool.write')} ${counts.write}`);
|
||||
if (counts.edit > 0) summaryParts.push(`${t('tool.edit')} ${counts.edit}`);
|
||||
if (counts.read > 0) summaryParts.push(`${t('tool.read')} ${counts.read}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`file-ops${streaming ? ' is-streaming' : ''}`}
|
||||
data-testid="file-ops-summary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="file-ops-toggle"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
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>
|
||||
{open ? (
|
||||
<ul className="file-ops-list" role="list">
|
||||
{entries.map((entry) => (
|
||||
<FileOpRow
|
||||
key={entry.fullPath}
|
||||
entry={entry}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileOpRow({
|
||||
entry,
|
||||
projectFileNames,
|
||||
onRequestOpenFile,
|
||||
}: {
|
||||
entry: FileOpEntry;
|
||||
projectFileNames?: Set<string> | undefined;
|
||||
onRequestOpenFile?: ((name: string) => void) | undefined;
|
||||
}) {
|
||||
const t = useT();
|
||||
const canOpen =
|
||||
!!onRequestOpenFile &&
|
||||
(projectFileNames ? projectFileNames.has(entry.path) : true);
|
||||
return (
|
||||
<li
|
||||
className={`file-ops-row file-ops-row--${entry.status}`}
|
||||
data-testid={`file-ops-row-${entry.path}`}
|
||||
>
|
||||
<div className="file-ops-row-badges" aria-hidden>
|
||||
{entry.ops.map((op) => {
|
||||
const count = entry.opCounts[op];
|
||||
return (
|
||||
<span
|
||||
key={op}
|
||||
className={`file-ops-badge file-ops-badge--${op}`}
|
||||
title={
|
||||
count > 1
|
||||
? `${t(OP_LABEL_KEY[op])} ×${count}`
|
||||
: t(OP_LABEL_KEY[op])
|
||||
}
|
||||
>
|
||||
{OP_BADGE_GLYPH[op]}
|
||||
{count > 1 ? (
|
||||
<span className="file-ops-badge-count">×{count}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<code className="file-ops-row-path" title={entry.fullPath}>
|
||||
{entry.path}
|
||||
</code>
|
||||
{entry.status === 'running' ? (
|
||||
<span className="file-ops-row-status file-ops-row-status--running">
|
||||
{t('tool.running')}
|
||||
</span>
|
||||
) : entry.status === 'error' ? (
|
||||
<span className="file-ops-row-status file-ops-row-status--error">
|
||||
{t('tool.error')}
|
||||
</span>
|
||||
) : null}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="file-ops-row-open"
|
||||
onClick={() => onRequestOpenFile?.(entry.path)}
|
||||
title={t('tool.openInTab', { name: entry.path })}
|
||||
data-testid={`file-ops-row-open-${entry.path}`}
|
||||
>
|
||||
{t('assistant.openFile')}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
// Plan §3.F5 / spec §8 — composable Plugins section.
|
||||
//
|
||||
// Bundles the four Phase 2A primitives (InlinePluginsRail,
|
||||
// ContextChipStrip, PluginInputsForm, the renderPluginBriefTemplate
|
||||
// helper) into one reusable widget. NewProjectPanel and ChatComposer
|
||||
// can drop this in with one line and treat the rest of the composer
|
||||
// state as untouched.
|
||||
// Bundles the Phase 2A primitives (InlinePluginsRail, ContextChipStrip,
|
||||
// PluginInputsForm, the renderPluginBriefTemplate helper) into one
|
||||
// reusable widget. NewProjectPanel and ChatComposer can drop this in
|
||||
// with one line and treat the rest of the composer state as untouched.
|
||||
//
|
||||
// API contract:
|
||||
// - `onApplied(brief, applied)` fires every time the section's brief
|
||||
|
|
@ -15,19 +14,31 @@
|
|||
// clearing the active plugin.
|
||||
// - `onValidityChange(valid)` mirrors the inputs-form validity so the
|
||||
// host can disable Send while required inputs are missing.
|
||||
//
|
||||
// The section is purely additive: it never reaches into the host's
|
||||
// state. Hosts may choose to ignore the callbacks entirely; the
|
||||
// section will still render and apply plugins. This minimises the
|
||||
// intrusion into the existing 2000-line composer files.
|
||||
// - `showRail` controls whether the in-section InlinePluginsRail is
|
||||
// rendered. Defaults to true (NewProjectPanel keeps the wide rail).
|
||||
// ChatComposer passes `false` because plugins moved to the
|
||||
// composer's tools-menu and the @-mention picker — leaving the
|
||||
// section as a pure context-bar that hosts the active plugin chip.
|
||||
// - The forwarded ref exposes `applyById(pluginId)` so external entry
|
||||
// points (the tools-menu Plugins tab, the @-mention picker, future
|
||||
// keyboard shortcuts) can apply a plugin without re-implementing
|
||||
// the request lifecycle.
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
ApplyResult,
|
||||
ContextItem,
|
||||
InstalledPluginRecord,
|
||||
} from '@open-design/contracts';
|
||||
import { renderPluginBriefTemplate } from '../state/projects';
|
||||
import {
|
||||
applyPlugin,
|
||||
renderPluginBriefTemplate,
|
||||
} from '../state/projects';
|
||||
import { ContextChipStrip } from './ContextChipStrip';
|
||||
import { InlinePluginsRail } from './InlinePluginsRail';
|
||||
import { PluginInputsForm } from './PluginInputsForm';
|
||||
|
|
@ -49,77 +60,128 @@ interface Props {
|
|||
kinds?: string[];
|
||||
pluginIds?: string[];
|
||||
};
|
||||
// When false, the in-section rail is omitted. Hosts that source
|
||||
// plugins from another surface (ChatComposer's tools-menu / @-picker)
|
||||
// pass false so the section behaves as a pure context-bar.
|
||||
showRail?: boolean;
|
||||
// Optional hooks — see file header.
|
||||
onApplied?: (brief: string, applied: ApplyResult) => void;
|
||||
onCleared?: () => void;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
// Forwarded to ContextChipStrip so chips can open the plugin details
|
||||
// modal when the user clicks one (kind === 'plugin').
|
||||
onChipDetails?: (item: ContextItem) => void;
|
||||
}
|
||||
|
||||
export function PluginsSection(props: Props) {
|
||||
const [applied, setApplied] = useState<ApplyResult | null>(null);
|
||||
const [activeRecord, setActiveRecord] = useState<InstalledPluginRecord | null>(null);
|
||||
const [pluginInputs, setPluginInputs] = useState<Record<string, unknown>>({});
|
||||
export interface PluginsSectionHandle {
|
||||
// Imperatively apply a plugin by id. Mirrors what InlinePluginsRail
|
||||
// does on click but lets ChatComposer drive the apply from the
|
||||
// tools-menu Plugins tab and the @-mention popover. Resolves with
|
||||
// the ApplyResult on success or null on failure (matching applyPlugin).
|
||||
applyById: (pluginId: string, record?: InstalledPluginRecord | null) => Promise<ApplyResult | null>;
|
||||
// Imperatively clear the active plugin (drops the context chips +
|
||||
// inputs form, fires onCleared). Used by tools-menu's "Replace" /
|
||||
// "Clear" affordance and by chip remove paths that bypass the strip.
|
||||
clear: () => void;
|
||||
// Read the currently active plugin record (or null). Lets the
|
||||
// tools-menu reflect the active state without duplicating the
|
||||
// section's internal state.
|
||||
getActiveRecord: () => InstalledPluginRecord | null;
|
||||
}
|
||||
|
||||
const onApplied = useCallback(
|
||||
(record: InstalledPluginRecord, result: ApplyResult) => {
|
||||
setActiveRecord(record);
|
||||
setApplied(result);
|
||||
const initialInputs: Record<string, unknown> = {};
|
||||
for (const field of result.inputs ?? []) {
|
||||
if (field.default !== undefined) initialInputs[field.name] = field.default;
|
||||
}
|
||||
setPluginInputs(initialInputs);
|
||||
const brief = renderPluginBriefTemplate(result.query ?? '', initialInputs);
|
||||
props.onApplied?.(brief, result);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
|
||||
function PluginsSection(props, ref) {
|
||||
const [applied, setApplied] = useState<ApplyResult | null>(null);
|
||||
const [activeRecord, setActiveRecord] = useState<InstalledPluginRecord | null>(null);
|
||||
const [pluginInputs, setPluginInputs] = useState<Record<string, unknown>>({});
|
||||
|
||||
const onInputsChange = useCallback(
|
||||
(next: Record<string, unknown>) => {
|
||||
setPluginInputs(next);
|
||||
if (applied) {
|
||||
const brief = renderPluginBriefTemplate(applied.query ?? '', next);
|
||||
props.onApplied?.(brief, applied);
|
||||
}
|
||||
},
|
||||
[applied, props],
|
||||
);
|
||||
const handleApplied = useCallback(
|
||||
(record: InstalledPluginRecord | null, result: ApplyResult) => {
|
||||
setActiveRecord(record);
|
||||
setApplied(result);
|
||||
const initialInputs: Record<string, unknown> = {};
|
||||
for (const field of result.inputs ?? []) {
|
||||
if (field.default !== undefined) initialInputs[field.name] = field.default;
|
||||
}
|
||||
setPluginInputs(initialInputs);
|
||||
const brief = renderPluginBriefTemplate(result.query ?? '', initialInputs);
|
||||
props.onApplied?.(brief, result);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const onChipRemove = useCallback(
|
||||
(_item: ContextItem) => {
|
||||
const onInputsChange = useCallback(
|
||||
(next: Record<string, unknown>) => {
|
||||
setPluginInputs(next);
|
||||
if (applied) {
|
||||
const brief = renderPluginBriefTemplate(applied.query ?? '', next);
|
||||
props.onApplied?.(brief, applied);
|
||||
}
|
||||
},
|
||||
[applied, props],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setApplied(null);
|
||||
setActiveRecord(null);
|
||||
setPluginInputs({});
|
||||
props.onCleared?.();
|
||||
},
|
||||
[props],
|
||||
);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<div className="plugins-section" data-testid="plugins-section">
|
||||
{applied ? (
|
||||
<div className="plugins-section__active" data-active-plugin-id={activeRecord?.id}>
|
||||
<ContextChipStrip
|
||||
items={applied.contextItems ?? []}
|
||||
onRemove={onChipRemove}
|
||||
/>
|
||||
{applied.inputs && applied.inputs.length > 0 ? (
|
||||
<PluginInputsForm
|
||||
fields={applied.inputs}
|
||||
values={pluginInputs}
|
||||
onChange={onInputsChange}
|
||||
onValidityChange={props.onValidityChange ?? (() => undefined)}
|
||||
const onChipRemove = useCallback(
|
||||
(_item: ContextItem) => {
|
||||
clear();
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
applyById: async (pluginId, record = null) => {
|
||||
const result = await applyPlugin(pluginId, {
|
||||
...(props.projectId ? { projectId: props.projectId } : {}),
|
||||
});
|
||||
if (!result) return null;
|
||||
handleApplied(record, result);
|
||||
return result;
|
||||
},
|
||||
clear,
|
||||
getActiveRecord: () => activeRecord,
|
||||
}),
|
||||
[props.projectId, handleApplied, clear, activeRecord],
|
||||
);
|
||||
|
||||
const showRail = props.showRail ?? true;
|
||||
|
||||
return (
|
||||
<div className="plugins-section" data-testid="plugins-section">
|
||||
{applied ? (
|
||||
<div className="plugins-section__active" data-active-plugin-id={activeRecord?.id}>
|
||||
<ContextChipStrip
|
||||
items={applied.contextItems ?? []}
|
||||
onRemove={onChipRemove}
|
||||
{...(props.onChipDetails ? { onSelect: props.onChipDetails } : {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<InlinePluginsRail
|
||||
{...(props.projectId !== undefined ? { projectId: props.projectId } : {})}
|
||||
variant={props.variant ?? 'wide'}
|
||||
{...(props.filter ? { filter: props.filter } : {})}
|
||||
onApplied={onApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{applied.inputs && applied.inputs.length > 0 ? (
|
||||
<PluginInputsForm
|
||||
fields={applied.inputs}
|
||||
values={pluginInputs}
|
||||
onChange={onInputsChange}
|
||||
onValidityChange={props.onValidityChange ?? (() => undefined)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showRail ? (
|
||||
<InlinePluginsRail
|
||||
{...(props.projectId !== undefined ? { projectId: props.projectId } : {})}
|
||||
variant={props.variant ?? 'wide'}
|
||||
{...(props.filter ? { filter: props.filter } : {})}
|
||||
onApplied={(record, result) => handleApplied(record, result)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9248,6 +9248,176 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
|||
padding: 3px 9px;
|
||||
}
|
||||
|
||||
/* "Files this turn" summary — derived from Read/Write/Edit 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. */
|
||||
.file-ops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-ops.is-streaming {
|
||||
border-style: dashed;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.file-ops-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
min-width: 0;
|
||||
}
|
||||
.file-ops-toggle:hover { background: var(--bg-subtle); }
|
||||
.file-ops-icon { display: inline-flex; color: var(--text-muted); }
|
||||
.file-ops-label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-ops-summary-line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.file-ops-count {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.file-ops.is-streaming .file-ops-count {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
|
||||
}
|
||||
.file-ops-chev { display: inline-flex; color: var(--text-muted); }
|
||||
.file-ops-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 6px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border-top: 1px solid var(--border-soft, var(--border));
|
||||
}
|
||||
.file-ops-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-ops-row:hover { background: var(--bg-subtle); }
|
||||
.file-ops-row--running { color: var(--text); }
|
||||
.file-ops-row--error { color: var(--text); }
|
||||
.file-ops-row-badges { display: inline-flex; gap: 3px; flex-shrink: 0; }
|
||||
.file-ops-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.file-ops-badge-count {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.file-ops-badge--read {
|
||||
color: color-mix(in oklab, var(--accent) 80%, var(--text));
|
||||
border-color: color-mix(in oklab, var(--accent) 30%, var(--border));
|
||||
}
|
||||
.file-ops-badge--write {
|
||||
color: #2e7d32;
|
||||
border-color: color-mix(in oklab, #2e7d32 35%, var(--border));
|
||||
background: color-mix(in oklab, #2e7d32 8%, var(--bg-subtle));
|
||||
}
|
||||
.file-ops-badge--edit {
|
||||
color: #b26500;
|
||||
border-color: color-mix(in oklab, #b26500 35%, var(--border));
|
||||
background: color-mix(in oklab, #b26500 8%, var(--bg-subtle));
|
||||
}
|
||||
.file-ops-row-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.file-ops-row-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-ops-row-status {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-ops-row-status--running {
|
||||
color: var(--accent);
|
||||
background: color-mix(in oklab, var(--accent) 10%, transparent);
|
||||
}
|
||||
.file-ops-row-status--error {
|
||||
color: #c0392b;
|
||||
background: color-mix(in oklab, #c0392b 12%, transparent);
|
||||
}
|
||||
.file-ops-row-open {
|
||||
font-size: 11px;
|
||||
padding: 3px 9px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-ops-row-open:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.op-bash .op-command,
|
||||
.op-bash .op-output {
|
||||
margin: 0;
|
||||
|
|
|
|||
117
apps/web/src/runtime/file-ops.ts
Normal file
117
apps/web/src/runtime/file-ops.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Aggregates Read/Write/Edit tool_use events into one row per file path.
|
||||
*
|
||||
* The chat surface renders individual `FileReadCard` / `FileWriteCard` /
|
||||
* `FileEditCard` cards inline (and collapses runs of the same family
|
||||
* behind a `Editing ×3, Done` disclosure). This module powers the
|
||||
* complementary "files this turn" summary that lives at the top of the
|
||||
* assistant message — visible while the run streams and persisting once
|
||||
* it finishes — so users can scan every file the agent touched without
|
||||
* expanding tool-group disclosures.
|
||||
*/
|
||||
import type { AgentEvent } from '../types';
|
||||
|
||||
export type FileOpKind = 'read' | 'write' | 'edit';
|
||||
export type FileOpStatus = 'running' | 'done' | 'error';
|
||||
|
||||
export interface FileOpEntry {
|
||||
/** Basename — used as both display label and the lookup key passed to
|
||||
* `onRequestOpenFile`, since the project-file API keys on basenames. */
|
||||
path: string;
|
||||
/** Original full path the agent passed; kept for tooltips. */
|
||||
fullPath: string;
|
||||
/** Distinct ops applied to this file, in encounter order. */
|
||||
ops: FileOpKind[];
|
||||
/** Per-op tool_use count for this file. Sum across ops equals total. */
|
||||
opCounts: Record<FileOpKind, number>;
|
||||
/** Total tool_use count for this file (>= ops.length when an op repeats). */
|
||||
total: number;
|
||||
/** Worst status across all calls for this file: error > running > done. */
|
||||
status: FileOpStatus;
|
||||
}
|
||||
|
||||
const READ_NAMES = new Set(['Read', 'read_file']);
|
||||
const WRITE_NAMES = new Set(['Write', 'create_file']);
|
||||
const EDIT_NAMES = new Set(['Edit', 'str_replace_edit', 'MultiEdit', 'multi_edit']);
|
||||
|
||||
function classify(name: string): FileOpKind | null {
|
||||
if (READ_NAMES.has(name)) return 'read';
|
||||
if (WRITE_NAMES.has(name)) return 'write';
|
||||
if (EDIT_NAMES.has(name)) return 'edit';
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPath(input: unknown): string | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const obj = input as { file_path?: unknown; path?: unknown };
|
||||
if (typeof obj.file_path === 'string' && obj.file_path) return obj.file_path;
|
||||
if (typeof obj.path === 'string' && obj.path) return obj.path;
|
||||
return null;
|
||||
}
|
||||
|
||||
function basename(input: string): string {
|
||||
const segments = input.split(/[\\/]/).filter((segment) => segment.length > 0);
|
||||
return segments[segments.length - 1] ?? input;
|
||||
}
|
||||
|
||||
function mergeStatus(a: FileOpStatus, b: FileOpStatus): FileOpStatus {
|
||||
if (a === 'error' || b === 'error') return 'error';
|
||||
if (a === 'running' || b === 'running') return 'running';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
export function deriveFileOps(events: AgentEvent[] | undefined): FileOpEntry[] {
|
||||
if (!events || events.length === 0) return [];
|
||||
const resultByToolId = new Map<
|
||||
string,
|
||||
Extract<AgentEvent, { kind: 'tool_result' }>
|
||||
>();
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'tool_result') resultByToolId.set(ev.toolUseId, ev);
|
||||
}
|
||||
|
||||
const byPath = new Map<string, FileOpEntry>();
|
||||
for (const ev of events) {
|
||||
if (ev.kind !== 'tool_use') continue;
|
||||
const kind = classify(ev.name);
|
||||
if (!kind) continue;
|
||||
const fullPath = extractPath(ev.input);
|
||||
if (!fullPath || fullPath === '(unnamed)') continue;
|
||||
const result = resultByToolId.get(ev.id);
|
||||
const status: FileOpStatus =
|
||||
result == null ? 'running' : result.isError ? 'error' : 'done';
|
||||
const existing = byPath.get(fullPath);
|
||||
if (existing) {
|
||||
if (!existing.ops.includes(kind)) existing.ops.push(kind);
|
||||
existing.opCounts[kind] += 1;
|
||||
existing.total += 1;
|
||||
existing.status = mergeStatus(existing.status, status);
|
||||
} else {
|
||||
const opCounts: Record<FileOpKind, number> = { read: 0, write: 0, edit: 0 };
|
||||
opCounts[kind] = 1;
|
||||
byPath.set(fullPath, {
|
||||
path: basename(fullPath),
|
||||
fullPath,
|
||||
ops: [kind],
|
||||
opCounts,
|
||||
total: 1,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values());
|
||||
}
|
||||
|
||||
export type FileOpCounts = Record<FileOpKind, number>;
|
||||
|
||||
/** Total tool_use count per op family across `entries`. */
|
||||
export function countFileOps(entries: FileOpEntry[]): FileOpCounts {
|
||||
const counts: FileOpCounts = { read: 0, write: 0, edit: 0 };
|
||||
for (const entry of entries) {
|
||||
counts.read += entry.opCounts.read;
|
||||
counts.write += entry.opCounts.write;
|
||||
counts.edit += entry.opCounts.edit;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
123
apps/web/tests/components/FileOpsSummary.test.tsx
Normal file
123
apps/web/tests/components/FileOpsSummary.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileOpsSummary } from '../../src/components/FileOpsSummary';
|
||||
import type { FileOpEntry } from '../../src/runtime/file-ops';
|
||||
|
||||
function entry(partial: Partial<FileOpEntry> & { path: string }): FileOpEntry {
|
||||
return {
|
||||
fullPath: `/repo/${partial.path}`,
|
||||
ops: ['read'],
|
||||
opCounts: { read: 1, write: 0, edit: 0 },
|
||||
total: 1,
|
||||
status: 'done',
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileOpsSummary', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders nothing when there are no entries', () => {
|
||||
const { container } = render(
|
||||
<FileOpsSummary entries={[]} streaming={false} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('starts collapsed while streaming and surfaces per-op totals in the header', () => {
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
entry({ path: 'a.ts', ops: ['read'], opCounts: { read: 2, write: 0, edit: 0 }, total: 2 }),
|
||||
entry({ path: 'b.ts', ops: ['write'], opCounts: { read: 0, write: 1, edit: 0 } }),
|
||||
entry({ path: 'c.ts', ops: ['edit'], opCounts: { read: 0, write: 0, edit: 3 }, total: 3 }),
|
||||
]}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Write 1/)).toBeTruthy();
|
||||
expect(screen.getByText(/Edit 3/)).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();
|
||||
const toggle = screen.getByTestId('file-ops-toggle');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('opens by default once the run is no longer streaming and lists every touched file', () => {
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
entry({ path: 'a.ts', ops: ['read', 'edit'], opCounts: { read: 1, write: 0, edit: 1 }, total: 2 }),
|
||||
entry({ path: 'b.ts', ops: ['write'], opCounts: { read: 0, write: 1, edit: 0 } }),
|
||||
]}
|
||||
streaming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('file-ops-row-a.ts')).toBeTruthy();
|
||||
expect(screen.getByTestId('file-ops-row-b.ts')).toBeTruthy();
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('reopens once streaming flips to false unless the user collapsed it manually', () => {
|
||||
const { rerender } = render(
|
||||
<FileOpsSummary
|
||||
entries={[entry({ path: 'a.ts' })]}
|
||||
streaming
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
rerender(
|
||||
<FileOpsSummary
|
||||
entries={[entry({ path: 'a.ts' })]}
|
||||
streaming={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('file-ops-toggle').getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows the open button only for files that are present in the project file set', () => {
|
||||
const onRequestOpenFile = vi.fn();
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
entry({ path: 'a.ts' }),
|
||||
entry({ path: 'missing.ts' }),
|
||||
]}
|
||||
streaming={false}
|
||||
projectFileNames={new Set(['a.ts'])}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('file-ops-row-open-a.ts')).toBeTruthy();
|
||||
expect(screen.queryByTestId('file-ops-row-open-missing.ts')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('file-ops-row-open-a.ts'));
|
||||
expect(onRequestOpenFile).toHaveBeenCalledWith('a.ts');
|
||||
});
|
||||
|
||||
it('flags a row as running when its status is running and as error when isError', () => {
|
||||
render(
|
||||
<FileOpsSummary
|
||||
entries={[
|
||||
entry({ path: 'pending.ts', status: 'running' }),
|
||||
entry({ path: 'broken.ts', status: 'error' }),
|
||||
]}
|
||||
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');
|
||||
expect(broken.className).toContain('file-ops-row--error');
|
||||
});
|
||||
});
|
||||
141
apps/web/tests/runtime/file-ops.test.ts
Normal file
141
apps/web/tests/runtime/file-ops.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { countFileOps, deriveFileOps } from '../../src/runtime/file-ops';
|
||||
import type { AgentEvent } from '../../src/types';
|
||||
|
||||
type ToolUse = Extract<AgentEvent, { kind: 'tool_use' }>;
|
||||
type ToolResult = Extract<AgentEvent, { kind: 'tool_result' }>;
|
||||
|
||||
function use(name: string, input: unknown, id: string): ToolUse {
|
||||
return { kind: 'tool_use', id, name, input };
|
||||
}
|
||||
|
||||
function ok(id: string, content = ''): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: false };
|
||||
}
|
||||
|
||||
function fail(id: string, content = 'boom'): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: true };
|
||||
}
|
||||
|
||||
describe('deriveFileOps', () => {
|
||||
it('returns an empty list for an empty event stream', () => {
|
||||
expect(deriveFileOps(undefined)).toEqual([]);
|
||||
expect(deriveFileOps([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips tool_use events that are not file CRUD families', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Bash', { command: 'ls' }, 't1'),
|
||||
use('TodoWrite', { todos: [] }, 't2'),
|
||||
use('WebSearch', { query: 'foo' }, 't3'),
|
||||
];
|
||||
expect(deriveFileOps(events)).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates Read/Write/Edit by full path with basename + ops list', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Read', { file_path: '/repo/a.ts' }, 't1'),
|
||||
ok('t1'),
|
||||
use('Write', { file_path: '/repo/b.ts', content: 'hi' }, 't2'),
|
||||
ok('t2'),
|
||||
use('Edit', { file_path: '/repo/a.ts', old_string: 'x', new_string: 'y' }, 't3'),
|
||||
ok('t3'),
|
||||
];
|
||||
const rows = deriveFileOps(events);
|
||||
expect(rows).toHaveLength(2);
|
||||
const a = rows.find((row) => row.fullPath === '/repo/a.ts');
|
||||
expect(a).toMatchObject({
|
||||
path: 'a.ts',
|
||||
fullPath: '/repo/a.ts',
|
||||
ops: ['read', 'edit'],
|
||||
total: 2,
|
||||
status: 'done',
|
||||
});
|
||||
const b = rows.find((row) => row.fullPath === '/repo/b.ts');
|
||||
expect(b).toMatchObject({
|
||||
path: 'b.ts',
|
||||
ops: ['write'],
|
||||
total: 1,
|
||||
status: 'done',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a missing tool_result as running and an isError result as error', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Read', { file_path: '/repo/a.ts' }, 't1'),
|
||||
use('Edit', { file_path: '/repo/b.ts' }, 't2'),
|
||||
fail('t2'),
|
||||
];
|
||||
const rows = deriveFileOps(events);
|
||||
expect(rows.find((row) => row.path === 'a.ts')?.status).toBe('running');
|
||||
expect(rows.find((row) => row.path === 'b.ts')?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('worst status wins when one file gets multiple results', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Read', { file_path: '/repo/a.ts' }, 't1'),
|
||||
ok('t1'),
|
||||
use('Edit', { file_path: '/repo/a.ts' }, 't2'),
|
||||
fail('t2'),
|
||||
];
|
||||
const [row] = deriveFileOps(events);
|
||||
expect(row?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('accepts the legacy `path` argument and the snake_case tool aliases', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('read_file', { path: '/repo/a.ts' }, 't1'),
|
||||
ok('t1'),
|
||||
use('create_file', { path: '/repo/b.ts' }, 't2'),
|
||||
ok('t2'),
|
||||
use('str_replace_edit', { path: '/repo/a.ts' }, 't3'),
|
||||
ok('t3'),
|
||||
];
|
||||
const rows = deriveFileOps(events);
|
||||
expect(rows.map((row) => row.path).sort()).toEqual(['a.ts', 'b.ts']);
|
||||
expect(rows.find((row) => row.path === 'a.ts')?.ops).toEqual(['read', 'edit']);
|
||||
});
|
||||
|
||||
it('drops events whose path is missing or "(unnamed)"', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Write', { file_path: '' }, 't1'),
|
||||
use('Read', { file_path: '(unnamed)' }, 't2'),
|
||||
use('Edit', {}, 't3'),
|
||||
];
|
||||
expect(deriveFileOps(events)).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats Windows-style paths and trailing slashes the same as POSIX', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Read', { file_path: 'C:\\repo\\sub\\file.ts' }, 't1'),
|
||||
ok('t1'),
|
||||
];
|
||||
const [row] = deriveFileOps(events);
|
||||
expect(row?.path).toBe('file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countFileOps', () => {
|
||||
it('totals tool_use counts by op family across all entries', () => {
|
||||
const events: AgentEvent[] = [
|
||||
use('Read', { file_path: '/a.ts' }, 't1'),
|
||||
ok('t1'),
|
||||
use('Read', { file_path: '/a.ts' }, 't2'),
|
||||
ok('t2'),
|
||||
use('Write', { file_path: '/b.ts' }, 't3'),
|
||||
ok('t3'),
|
||||
use('Edit', { file_path: '/a.ts' }, 't4'),
|
||||
ok('t4'),
|
||||
];
|
||||
const rows = deriveFileOps(events);
|
||||
const counts = countFileOps(rows);
|
||||
expect(counts.read).toBe(2);
|
||||
expect(counts.write).toBe(1);
|
||||
expect(counts.edit).toBe(1);
|
||||
});
|
||||
|
||||
it('returns zeros when there are no entries', () => {
|
||||
expect(countFileOps([])).toEqual({ read: 0, write: 0, edit: 0 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue