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:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
@@ -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>