feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -48,6 +48,7 @@ interface ActivityCounts {
|
||||
hasDiffStats: boolean;
|
||||
hasEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
hasDeletedFiles: boolean;
|
||||
primaryFilePath?: string;
|
||||
primaryFileTooltipPath?: string;
|
||||
primaryCliName?: string;
|
||||
@@ -66,6 +67,7 @@ interface FileEditSummary {
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
status: UIFileEdit["status"];
|
||||
operation?: UIFileEdit["operation"];
|
||||
pending: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -126,6 +128,7 @@ function countActivity(
|
||||
let hasDiffStats = false;
|
||||
let hasEditingFiles = false;
|
||||
let failedFileCount = 0;
|
||||
let deletedFileCount = 0;
|
||||
let primaryFilePath: string | undefined;
|
||||
let primaryFileTooltipPath: string | undefined;
|
||||
for (const edit of fileEdits) {
|
||||
@@ -137,6 +140,9 @@ function countActivity(
|
||||
if (edit.status === "error") {
|
||||
failedFileCount += 1;
|
||||
}
|
||||
if (edit.operation === "delete") {
|
||||
deletedFileCount += 1;
|
||||
}
|
||||
if (edit.status === "error" || edit.binary) {
|
||||
continue;
|
||||
}
|
||||
@@ -158,6 +164,7 @@ function countActivity(
|
||||
hasDiffStats,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
|
||||
hasDeletedFiles: fileEdits.length > 0 && deletedFileCount === fileEdits.length,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
primaryCliName,
|
||||
@@ -217,6 +224,7 @@ export function AgentActivityCluster({
|
||||
hasDiffStats,
|
||||
hasEditingFiles,
|
||||
hasFailedFiles,
|
||||
hasDeletedFiles,
|
||||
primaryFilePath,
|
||||
primaryFileTooltipPath,
|
||||
primaryCliName,
|
||||
@@ -245,6 +253,7 @@ export function AgentActivityCluster({
|
||||
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
|
||||
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasOnlyFileActivity = fileCount > 0 && messages.every(messageHasOnlyFileActivity);
|
||||
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
|
||||
const activityDuration = formatActivityDuration(durationMs);
|
||||
const thoughtLabel = isTurnStreaming
|
||||
@@ -263,13 +272,13 @@ export function AgentActivityCluster({
|
||||
? hasPendingFileEdit && !singleFilePath
|
||||
? t("message.fileActivityPreparing", { defaultValue: "Preparing edit…" })
|
||||
: singleFilePath
|
||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
|
||||
file: shortFileName(singleFilePath),
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{file}}`,
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
|
||||
count: fileCount,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
|
||||
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`,
|
||||
})
|
||||
: "";
|
||||
|
||||
@@ -410,6 +419,25 @@ export function AgentActivityCluster({
|
||||
|
||||
if (!hasVisibleActivity) return null;
|
||||
|
||||
if (hasOnlyFileActivity) {
|
||||
return (
|
||||
<FileEditFlatActivity
|
||||
edits={fileEdits}
|
||||
active={isTurnStreaming}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
summary={summary}
|
||||
singleFilePath={singleFilePath}
|
||||
singleFileTooltipPath={singleFileTooltipPath}
|
||||
hasLiveEditingFiles={hasLiveEditingFiles}
|
||||
hasFailedFiles={hasFailedFiles}
|
||||
hasDeletedFiles={hasDeletedFiles}
|
||||
added={added}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@@ -426,7 +454,7 @@ export function AgentActivityCluster({
|
||||
active={isTurnStreaming}
|
||||
className="min-w-0"
|
||||
>
|
||||
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles) : thoughtLabel}
|
||||
{singleFilePath ? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles) : thoughtLabel}
|
||||
</StreamingLabelSheen>
|
||||
{singleFilePath ? (
|
||||
<FileReferenceChip
|
||||
@@ -502,6 +530,77 @@ export function AgentActivityCluster({
|
||||
);
|
||||
}
|
||||
|
||||
function messageHasOnlyFileActivity(message: UIMessage): boolean {
|
||||
if (message.kind !== "trace" || !message.fileEdits?.length) return false;
|
||||
return traceLines(message).every((line) => !line.trim() || isFileEditTraceLine(line));
|
||||
}
|
||||
|
||||
function FileEditFlatActivity({
|
||||
edits,
|
||||
active,
|
||||
hasBodyBelow,
|
||||
summary,
|
||||
singleFilePath,
|
||||
singleFileTooltipPath,
|
||||
hasLiveEditingFiles,
|
||||
hasFailedFiles,
|
||||
hasDeletedFiles,
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
active: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
summary: string;
|
||||
singleFilePath?: string;
|
||||
singleFileTooltipPath?: string;
|
||||
hasLiveEditingFiles: boolean;
|
||||
hasFailedFiles: boolean;
|
||||
hasDeletedFiles: boolean;
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
}) {
|
||||
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-full items-center gap-1.5 px-1 py-1",
|
||||
"text-[12.5px] text-muted-foreground/72",
|
||||
)}
|
||||
>
|
||||
<StreamingLabelSheen active={active} className="min-w-0">
|
||||
{singleFilePath
|
||||
? fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)
|
||||
: summary}
|
||||
</StreamingLabelSheen>
|
||||
{singleFilePath ? (
|
||||
<FileReferenceChip
|
||||
path={singleFilePath}
|
||||
tooltipPath={singleFileTooltipPath}
|
||||
active={hasLiveEditingFiles}
|
||||
className="-my-0.5 min-w-0"
|
||||
textClassName="text-xs"
|
||||
testId="activity-header-file-reference"
|
||||
/>
|
||||
) : null}
|
||||
{hasDiffStats ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
|
||||
<DiffPair added={added} deleted={deleted} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{showRows ? (
|
||||
<div className="mt-0.5 pl-4">
|
||||
<FileEditGroup edits={edits} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortFileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
@@ -1039,6 +1138,10 @@ function isMcpRunTraceLine(line: string): boolean {
|
||||
return MCP_TOOL_NAME_RE.test(line.trim().split("(", 1)[0] ?? "");
|
||||
}
|
||||
|
||||
function isFileEditTraceLine(line: string): boolean {
|
||||
return /^(write_file|edit_file|apply_patch)\(/.test(line.trim());
|
||||
}
|
||||
|
||||
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
|
||||
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
|
||||
if (!match) return null;
|
||||
@@ -1365,18 +1468,21 @@ function mcpRunLabelDefault(run: McpRunSummary, active: boolean): string {
|
||||
return active && run.status === "running" ? "Using" : "Used";
|
||||
}
|
||||
|
||||
function fileActivityVerb(editing: boolean, failed: boolean): string {
|
||||
function fileActivityVerb(editing: boolean, failed: boolean, deleted: boolean): string {
|
||||
if (failed) return "Failed";
|
||||
if (deleted) return editing ? "Deleting" : "Deleted";
|
||||
return editing ? "Editing" : "Edited";
|
||||
}
|
||||
|
||||
function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
|
||||
function fileActivitySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedOne";
|
||||
if (deleted) return editing ? "message.fileActivityDeletingOne" : "message.fileActivityDeletedOne";
|
||||
return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
|
||||
}
|
||||
|
||||
function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
|
||||
function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted: boolean): string {
|
||||
if (failed) return "message.fileActivityFailedMany";
|
||||
if (deleted) return editing ? "message.fileActivityDeletingMany" : "message.fileActivityDeletedMany";
|
||||
return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
|
||||
}
|
||||
|
||||
@@ -1419,6 +1525,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
operation?: UIFileEdit["operation"];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -1440,6 +1547,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
hasSuccessfulChange: false,
|
||||
hasActiveEditing: false,
|
||||
hasFailed: false,
|
||||
operation: undefined,
|
||||
};
|
||||
byPath.set(key, summary);
|
||||
order.push(key);
|
||||
@@ -1451,6 +1559,9 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
if (edit.absolute_path) {
|
||||
summary.absolute_path = edit.absolute_path;
|
||||
}
|
||||
if (edit.operation === "delete") {
|
||||
summary.operation = "delete";
|
||||
}
|
||||
summary.pending = summary.pending || !!edit.pending || !edit.path;
|
||||
if (!edit.path && edit.pending) {
|
||||
if (active && edit.status === "editing") {
|
||||
@@ -1515,6 +1626,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
status,
|
||||
operation: summary.operation,
|
||||
pending: summary.pending && !summary.path,
|
||||
error: summary.error,
|
||||
}];
|
||||
@@ -1525,6 +1637,23 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
|
||||
return edit.added > 0 || edit.deleted > 0;
|
||||
}
|
||||
|
||||
function formatFileEditError(error?: string): string {
|
||||
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
||||
if (!firstLine) return "";
|
||||
const cleaned = firstLine
|
||||
.replace(/^Error applying patch:\s*/i, "")
|
||||
.replace(/^Error writing file:\s*/i, "")
|
||||
.replace(/^Error editing file:\s*/i, "")
|
||||
.replace(/^Error:\s*/i, "");
|
||||
|
||||
return cleaned
|
||||
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
||||
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
||||
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
||||
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function CliRunGroup({
|
||||
runs,
|
||||
active,
|
||||
@@ -1758,8 +1887,15 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||
const failureDetail = failed
|
||||
? formatFileEditError(edit.error)
|
||||
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
||||
: "";
|
||||
return (
|
||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs">
|
||||
<li
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-0.5 text-xs"
|
||||
title={failureDetail || edit.absolute_path || edit.path}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center text-muted-foreground/50">
|
||||
{failed ? (
|
||||
@@ -1789,13 +1925,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
|
||||
/>
|
||||
)}
|
||||
{failed ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10.5px] font-medium text-destructive/75">
|
||||
{t("message.fileEditFailed", { defaultValue: "Failed" })}
|
||||
</span>
|
||||
) : null}
|
||||
{edit.approximate && !failed ? (
|
||||
<span className="shrink-0 text-[10.5px] font-medium text-muted-foreground/55">
|
||||
{t("message.fileEditApproximate", { defaultValue: "estimated" })}
|
||||
<span className="min-w-0 truncate text-[11px] leading-4 text-destructive/75">
|
||||
{failureDetail}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user