Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
735 lines
23 KiB
TypeScript
735 lines
23 KiB
TypeScript
import { useEffect, useMemo, useState, type JSX, type MouseEvent } from "react";
|
|
import {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
LoaderCircle,
|
|
Redo2,
|
|
Undo2
|
|
} from "lucide-react";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
toast
|
|
} from "@tutti-os/ui-system";
|
|
import { DirectLinedIcon } from "../../../app/renderer/components/icons/DirectLinedIcon";
|
|
import { translate } from "../../../i18n/index";
|
|
import { useOptionalAgentHostApi } from "../../../agentActivityHost";
|
|
import type { WorkspaceLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
|
|
import { resolveWorkspaceFileLinkAction } from "../../../contexts/workspace/presentation/renderer/actions/workspaceLinkActions";
|
|
import { CanvasNodeGhostIconButton } from "../../../contexts/workspace/presentation/renderer/components/shared/CanvasNodeGhostIconButton";
|
|
import type {
|
|
AgentTurnSummaryFileVM,
|
|
AgentTurnSummaryPatchBatchVM,
|
|
AgentTurnSummaryRowVM
|
|
} from "../contracts/agentTurnSummaryRowVM";
|
|
import { buildAgentTurnSummaryPatchDiff } from "../rules/agentTurnSummaryPatchDiff";
|
|
import {
|
|
fileCanBuildPatch,
|
|
patchBatchDirectoryCwd,
|
|
resolvePatchDiffCwd,
|
|
resolvePatchExecutionCwd
|
|
} from "../rules/agentTurnSummaryPatchRuntime";
|
|
import { AgentCodeBlock } from "./tool-renderers/code/AgentCodeBlock";
|
|
import { CollapsibleReveal } from "./CollapsibleReveal";
|
|
import { AgentMonacoDiffViewer } from "./tool-renderers/file-diff/AgentMonacoDiffViewer";
|
|
import {
|
|
agentFileChangeStats,
|
|
isAgentUnifiedDiff
|
|
} from "./tool-renderers/file-diff/agentUnifiedDiff";
|
|
import { AgentUnifiedPatchViewer } from "./tool-renderers/file-diff/AgentUnifiedPatchViewer";
|
|
|
|
interface AgentTurnSummaryRowProps {
|
|
row: AgentTurnSummaryRowVM;
|
|
workspaceRoot?: string | null;
|
|
basePath?: string | null;
|
|
label: string;
|
|
onLinkAction?: (action: WorkspaceLinkAction) => void;
|
|
}
|
|
|
|
type PatchSupportState =
|
|
| { key: string; status: "checking" }
|
|
| { key: string; status: "supported" }
|
|
| { key: string; status: "unsupported" };
|
|
|
|
export function AgentTurnSummaryRow({
|
|
row,
|
|
workspaceRoot,
|
|
basePath,
|
|
onLinkAction
|
|
}: AgentTurnSummaryRowProps): JSX.Element {
|
|
"use memo";
|
|
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>(
|
|
{}
|
|
);
|
|
const [showAllFiles, setShowAllFiles] = useState(false);
|
|
const [patchAction, setPatchAction] = useState<"undo" | "reapply">("undo");
|
|
const [patchPending, setPatchPending] = useState(false);
|
|
const [patchSupportState, setPatchSupportState] =
|
|
useState<PatchSupportState | null>(null);
|
|
const agentHostApi = useOptionalAgentHostApi();
|
|
const aggregateStats = useMemo(
|
|
() => summarizeRowDiff(row.files),
|
|
[row.files]
|
|
);
|
|
const patchBatches = useMemo(() => {
|
|
const canBuildEveryFileFallback = row.files.every(fileCanBuildPatch);
|
|
const fileFallbackBatches: AgentTurnSummaryPatchBatchVM[] =
|
|
canBuildEveryFileFallback
|
|
? row.files.map((file) => ({
|
|
changes: [
|
|
{
|
|
path: file.path,
|
|
changeType: file.changeType,
|
|
unifiedDiff: file.unifiedDiff,
|
|
oldString: file.oldString ?? null,
|
|
newString: file.newString ?? null,
|
|
content: file.content ?? null
|
|
}
|
|
],
|
|
cwd: fallbackPatchFileCwd(file.path, workspaceRoot),
|
|
toolCallId: `${row.id}:${file.messageId}:unified-diff`
|
|
}))
|
|
: [];
|
|
const sourceBatches = row.patchBatches?.length
|
|
? row.patchBatches
|
|
: fileFallbackBatches;
|
|
if (!hasCompletePatchCoverage(row.files, sourceBatches)) {
|
|
return [];
|
|
}
|
|
return buildExecutablePatchBatches(sourceBatches, workspaceRoot);
|
|
}, [row.files, row.id, row.patchBatches, workspaceRoot]);
|
|
const canRenderPatchButton = Boolean(
|
|
agentHostApi?.workspace.applyGitPatch && row.files.length > 0
|
|
);
|
|
const canApplyPatch = Boolean(
|
|
agentHostApi?.workspace.applyGitPatch && patchBatches.length > 0
|
|
);
|
|
const patchSupportCwds = useMemo(
|
|
() => Array.from(new Set(patchBatches.map((batch) => batch.cwd))).sort(),
|
|
[patchBatches]
|
|
);
|
|
const patchSupportKey = patchSupportCwds.join("\n");
|
|
const resolveGitPatchSupport = agentHostApi?.workspace.resolveGitPatchSupport;
|
|
useEffect(() => {
|
|
if (!resolveGitPatchSupport || patchSupportCwds.length === 0) {
|
|
setPatchSupportState(null);
|
|
return;
|
|
}
|
|
let disposed = false;
|
|
setPatchSupportState({ key: patchSupportKey, status: "checking" });
|
|
void (async () => {
|
|
try {
|
|
for (const cwd of patchSupportCwds) {
|
|
const result = await resolveGitPatchSupport({ cwd });
|
|
if (!result.supported) {
|
|
if (!disposed) {
|
|
setPatchSupportState({
|
|
key: patchSupportKey,
|
|
status: "unsupported"
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
if (!disposed) {
|
|
setPatchSupportState({ key: patchSupportKey, status: "supported" });
|
|
}
|
|
} catch {
|
|
if (!disposed) {
|
|
setPatchSupportState({ key: patchSupportKey, status: "supported" });
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [patchSupportCwds, patchSupportKey, resolveGitPatchSupport]);
|
|
const visibleFiles = row.files.slice(0, 3);
|
|
const hiddenFiles = row.files.slice(3);
|
|
const hiddenFileCount = hiddenFiles.length;
|
|
const patchActionLabel =
|
|
patchAction === "undo"
|
|
? translate("agentHost.agentGui.turnSummaryUndo")
|
|
: translate("agentHost.agentGui.turnSummaryReapply");
|
|
const isPatchSupportChecking = Boolean(
|
|
resolveGitPatchSupport &&
|
|
patchSupportCwds.length > 0 &&
|
|
(patchSupportState?.key !== patchSupportKey ||
|
|
patchSupportState.status === "checking")
|
|
);
|
|
const isPatchUnsupported = Boolean(
|
|
patchSupportState?.key === patchSupportKey &&
|
|
patchSupportState.status === "unsupported"
|
|
);
|
|
const patchDisabledReason = isPatchUnsupported
|
|
? translate("agentHost.agentGui.turnSummaryGitRequired")
|
|
: isPatchSupportChecking
|
|
? translate("agentHost.agentGui.turnSummaryCheckingGit")
|
|
: !canApplyPatch
|
|
? translate("agentHost.agentGui.turnSummaryPatchUnavailable")
|
|
: null;
|
|
const isPatchActionDisabled = Boolean(
|
|
patchPending ||
|
|
isPatchSupportChecking ||
|
|
isPatchUnsupported ||
|
|
!canApplyPatch
|
|
);
|
|
|
|
const handlePatchAction = async (event: MouseEvent<HTMLButtonElement>) => {
|
|
event.stopPropagation();
|
|
if (!agentHostApi?.workspace.applyGitPatch || isPatchActionDisabled) {
|
|
return;
|
|
}
|
|
const orderedBatches =
|
|
patchAction === "undo" ? [...patchBatches].reverse() : patchBatches;
|
|
if (orderedBatches.length === 0) {
|
|
return;
|
|
}
|
|
setPatchPending(true);
|
|
let changed = false;
|
|
const failureMessage =
|
|
patchAction === "undo"
|
|
? translate("agentHost.agentGui.turnSummaryUndoFailed")
|
|
: translate("agentHost.agentGui.turnSummaryReapplyFailed");
|
|
try {
|
|
for (const batch of orderedBatches) {
|
|
const result = await agentHostApi.workspace.applyGitPatch({
|
|
cwd: batch.cwd,
|
|
diff: batch.diff,
|
|
revert: patchAction === "undo"
|
|
});
|
|
if (
|
|
result.status === "success" ||
|
|
result.appliedPaths.length > 0 ||
|
|
result.conflictedPaths.length > 0
|
|
) {
|
|
changed = true;
|
|
}
|
|
if (result.status !== "success") {
|
|
showPatchFailureToast(
|
|
agentHostApi,
|
|
patchFailureMessage(result.errorCode, failureMessage)
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
setPatchAction((current) => (current === "undo" ? "reapply" : "undo"));
|
|
} catch {
|
|
showPatchFailureToast(agentHostApi, failureMessage);
|
|
} finally {
|
|
setPatchPending(false);
|
|
if (changed) {
|
|
window.dispatchEvent(new CustomEvent("tutti-agent-git-patch-applied"));
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="workspace-agents-status-panel__detail-turn-summary">
|
|
<div className="agent-turn-summary-card w-full overflow-hidden rounded-[8px] text-[var(--text-primary)]">
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div className="min-w-0 text-[15px] font-semibold leading-5 tracking-[0] text-[var(--text-primary)]">
|
|
{translate("agentHost.agentGui.turnSummaryFilesChanged", {
|
|
count: row.fileCount
|
|
})}
|
|
</div>
|
|
<div className="inline-flex shrink-0 items-center gap-2.5 text-[11px] font-semibold">
|
|
{aggregateStats.added > 0 ? (
|
|
<span className="workspace-agents-status-panel__detail-tool-diff-added">
|
|
+{aggregateStats.added}
|
|
</span>
|
|
) : null}
|
|
{aggregateStats.removed > 0 ? (
|
|
<span className="workspace-agents-status-panel__detail-tool-diff-removed">
|
|
-{aggregateStats.removed}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{canRenderPatchButton ? (
|
|
<TooltipProvider delayDuration={200}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className={`inline-flex shrink-0 ${
|
|
isPatchActionDisabled ? "cursor-not-allowed" : ""
|
|
}`}
|
|
>
|
|
<CanvasNodeGhostIconButton
|
|
aria-label={patchActionLabel}
|
|
className="w-auto min-w-0 gap-1.5 px-2 text-[12px] font-medium leading-4 disabled:pointer-events-none"
|
|
disabled={isPatchActionDisabled}
|
|
onClick={handlePatchAction}
|
|
>
|
|
{patchPending || isPatchSupportChecking ? (
|
|
<LoaderCircle
|
|
width={14}
|
|
height={14}
|
|
aria-hidden="true"
|
|
className="animate-spin text-[var(--text-secondary)]"
|
|
/>
|
|
) : patchAction === "undo" ? (
|
|
<Undo2
|
|
width={14}
|
|
height={14}
|
|
aria-hidden="true"
|
|
className="text-[var(--text-secondary)]"
|
|
/>
|
|
) : (
|
|
<Redo2
|
|
width={14}
|
|
height={14}
|
|
aria-hidden="true"
|
|
className="text-[var(--text-secondary)]"
|
|
/>
|
|
)}
|
|
<span>{patchActionLabel}</span>
|
|
</CanvasNodeGhostIconButton>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{patchDisabledReason ? (
|
|
<TooltipContent className="max-w-[260px] whitespace-normal text-left">
|
|
{patchDisabledReason}
|
|
</TooltipContent>
|
|
) : null}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="agent-turn-summary-card__list">
|
|
{visibleFiles.map((file) => {
|
|
const key = `${file.path}:${file.messageId}`;
|
|
return (
|
|
<TurnSummaryFileCard
|
|
key={key}
|
|
file={file}
|
|
workspaceRoot={workspaceRoot}
|
|
basePath={basePath}
|
|
expanded={Boolean(expandedFiles[key])}
|
|
onLinkAction={onLinkAction}
|
|
onToggle={() =>
|
|
setExpandedFiles((current) => ({
|
|
...current,
|
|
[key]: !current[key]
|
|
}))
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{hiddenFileCount > 0 ? (
|
|
<CollapsibleReveal expanded={showAllFiles} preMountOnIdle>
|
|
<div className="agent-turn-summary-card__overflow-list">
|
|
{hiddenFiles.map((file) => {
|
|
const key = `${file.path}:${file.messageId}`;
|
|
return (
|
|
<TurnSummaryFileCard
|
|
key={key}
|
|
file={file}
|
|
workspaceRoot={workspaceRoot}
|
|
basePath={basePath}
|
|
expanded={Boolean(expandedFiles[key])}
|
|
onLinkAction={onLinkAction}
|
|
onToggle={() =>
|
|
setExpandedFiles((current) => ({
|
|
...current,
|
|
[key]: !current[key]
|
|
}))
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</CollapsibleReveal>
|
|
) : null}
|
|
|
|
{hiddenFileCount > 0 && !showAllFiles ? (
|
|
<button
|
|
type="button"
|
|
className="agent-turn-summary-card__toggle-more flex w-full items-center gap-2 px-4 py-2.5 text-left text-[11px] font-semibold transition-colors"
|
|
onClick={() => setShowAllFiles(true)}
|
|
>
|
|
<span>
|
|
{translate("agentHost.agentGui.turnSummaryShowMoreFiles", {
|
|
count: hiddenFileCount
|
|
})}
|
|
</span>
|
|
<ChevronDown size={16} strokeWidth={2.2} aria-hidden="true" />
|
|
</button>
|
|
) : hiddenFileCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="agent-turn-summary-card__toggle-more flex w-full items-center gap-2 px-4 py-2.5 text-left text-[11px] font-semibold transition-colors"
|
|
onClick={() => setShowAllFiles(false)}
|
|
>
|
|
<span>
|
|
{translate("agentHost.agentGui.turnSummaryShowFewerFiles")}
|
|
</span>
|
|
<ChevronRight
|
|
size={16}
|
|
strokeWidth={2.2}
|
|
aria-hidden="true"
|
|
className="-rotate-90"
|
|
/>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function patchFailureMessage(
|
|
errorCode: string | undefined,
|
|
fallback: string
|
|
): string {
|
|
switch (errorCode) {
|
|
case "invalid-patch":
|
|
return translate("agentHost.agentGui.turnSummaryInvalidPatch");
|
|
case "patch-does-not-apply":
|
|
return translate("agentHost.agentGui.turnSummaryPatchDoesNotApply");
|
|
case "not-git-repo":
|
|
return translate("agentHost.agentGui.turnSummaryGitRequired");
|
|
default:
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function TurnSummaryFileCard({
|
|
file,
|
|
workspaceRoot,
|
|
basePath,
|
|
expanded,
|
|
onLinkAction,
|
|
onToggle
|
|
}: {
|
|
file: AgentTurnSummaryFileVM;
|
|
workspaceRoot?: string | null;
|
|
basePath?: string | null;
|
|
expanded: boolean;
|
|
onLinkAction?: (action: WorkspaceLinkAction) => void;
|
|
onToggle: () => void;
|
|
}): JSX.Element {
|
|
"use memo";
|
|
const action = resolveWorkspaceFileLinkAction({
|
|
path: file.path,
|
|
workspaceRoot: workspaceRoot,
|
|
basePath,
|
|
source: "agent-file-change"
|
|
});
|
|
const canOpen = Boolean(action && onLinkAction);
|
|
const stats = summarizeFileDiff(file);
|
|
const preview = filePreview(file);
|
|
|
|
return (
|
|
<div className="agent-turn-summary-card__file">
|
|
<div className="agent-turn-summary-card__file-row flex min-w-0 items-center gap-2.5 overflow-hidden px-4 py-2.5">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
<button
|
|
type="button"
|
|
className="group/file-toggle flex min-w-0 flex-1 items-center gap-1 overflow-hidden text-left"
|
|
aria-expanded={expanded}
|
|
onClick={onToggle}
|
|
>
|
|
<div className="min-w-0 flex-1 overflow-hidden">
|
|
<span
|
|
className={`agent-turn-summary-card__path flex min-w-0 max-w-full overflow-hidden whitespace-nowrap text-[13px] font-medium leading-5 text-[var(--text-secondary)] ${
|
|
file.changeType === "deleted" ? "line-through" : ""
|
|
}`}
|
|
title={file.path}
|
|
>
|
|
{file.directory ? (
|
|
<span className="agent-turn-summary-card__path-directory">
|
|
{file.directory}/
|
|
</span>
|
|
) : null}
|
|
<span className="agent-turn-summary-card__path-file">
|
|
{file.fileName}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<span className="shrink-0 text-[var(--text-tertiary)] opacity-0 transition-opacity group-hover/file-toggle:opacity-100 group-focus-visible/file-toggle:opacity-100">
|
|
<ChevronRight
|
|
size={14}
|
|
strokeWidth={2.2}
|
|
aria-hidden="true"
|
|
className={`transition-transform duration-150 ease-out ${expanded ? "rotate-90" : "rotate-0"}`}
|
|
/>
|
|
</span>
|
|
</button>
|
|
<span className="ml-auto inline-flex shrink-0 items-center gap-2 text-[11px] font-semibold">
|
|
{stats.added > 0 ? (
|
|
<span className="workspace-agents-status-panel__detail-tool-diff-added">
|
|
+{stats.added}
|
|
</span>
|
|
) : null}
|
|
{stats.removed > 0 ? (
|
|
<span className="workspace-agents-status-panel__detail-tool-diff-removed">
|
|
-{stats.removed}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
|
|
{canOpen ? (
|
|
<CanvasNodeGhostIconButton
|
|
aria-label={translate(
|
|
"agentHost.workspaceAgentSessionDetailOpenFile",
|
|
{
|
|
path: file.path
|
|
}
|
|
)}
|
|
onClick={() => {
|
|
onLinkAction?.(action as WorkspaceLinkAction);
|
|
}}
|
|
>
|
|
<DirectLinedIcon
|
|
width={14}
|
|
height={14}
|
|
aria-hidden="true"
|
|
className="text-[var(--text-secondary)]"
|
|
/>
|
|
</CanvasNodeGhostIconButton>
|
|
) : null}
|
|
</div>
|
|
|
|
{preview ? (
|
|
<CollapsibleReveal expanded={expanded} preMountOnIdle>
|
|
<div className="agent-turn-summary-card__preview rounded-none px-4 pb-3 pt-2">
|
|
{preview}
|
|
</div>
|
|
</CollapsibleReveal>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function filePreview(file: AgentTurnSummaryFileVM): JSX.Element | null {
|
|
if (
|
|
file.changeType === "created" &&
|
|
file.content !== null &&
|
|
file.content !== undefined
|
|
) {
|
|
return (
|
|
<AgentCodeBlock
|
|
path={file.path}
|
|
content={file.content}
|
|
showHeader={false}
|
|
collapsible
|
|
flat
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (file.unifiedDiff && isAgentUnifiedDiff(file.unifiedDiff)) {
|
|
return (
|
|
<AgentUnifiedPatchViewer
|
|
path={file.path}
|
|
diffText={file.unifiedDiff}
|
|
showHeader={false}
|
|
flat
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
file.changeType === "created" &&
|
|
file.newString !== null &&
|
|
file.newString !== undefined
|
|
) {
|
|
return (
|
|
<AgentCodeBlock
|
|
path={file.path}
|
|
content={file.newString}
|
|
showHeader={false}
|
|
collapsible
|
|
flat
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
(file.oldString !== null && file.oldString !== undefined) ||
|
|
(file.newString !== null && file.newString !== undefined)
|
|
) {
|
|
return (
|
|
<AgentMonacoDiffViewer
|
|
path={file.path}
|
|
oldValue={file.oldString ?? ""}
|
|
newValue={file.newString ?? ""}
|
|
flat
|
|
showHeader={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
file.changeType === "deleted" &&
|
|
((file.content !== null && file.content !== undefined) ||
|
|
(file.unifiedDiff !== null && file.unifiedDiff !== undefined))
|
|
) {
|
|
return (
|
|
<AgentMonacoDiffViewer
|
|
path={file.path}
|
|
oldValue={file.oldString ?? file.content ?? file.unifiedDiff ?? ""}
|
|
newValue=""
|
|
flat
|
|
showHeader={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (file.content !== null && file.content !== undefined) {
|
|
return (
|
|
<AgentCodeBlock
|
|
path={file.path}
|
|
content={file.content}
|
|
showHeader={false}
|
|
collapsible
|
|
flat
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (
|
|
file.unifiedDiff !== null &&
|
|
file.unifiedDiff !== undefined &&
|
|
file.changeType !== "deleted"
|
|
) {
|
|
return (
|
|
<AgentCodeBlock
|
|
path={file.path}
|
|
content={file.content ?? file.newString ?? file.unifiedDiff}
|
|
showHeader={false}
|
|
collapsible
|
|
flat
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function summarizeRowDiff(files: AgentTurnSummaryFileVM[]): {
|
|
added: number;
|
|
removed: number;
|
|
} {
|
|
return files.reduce(
|
|
(totals, file) => {
|
|
const stats = summarizeFileDiff(file);
|
|
return {
|
|
added: totals.added + stats.added,
|
|
removed: totals.removed + stats.removed
|
|
};
|
|
},
|
|
{ added: 0, removed: 0 }
|
|
);
|
|
}
|
|
|
|
function showPatchFailureToast(
|
|
agentHostApi: ReturnType<typeof useOptionalAgentHostApi>,
|
|
message: string
|
|
): void {
|
|
if (agentHostApi?.toast?.error) {
|
|
agentHostApi.toast.error(message);
|
|
return;
|
|
}
|
|
toast.error(message);
|
|
}
|
|
|
|
function buildExecutablePatchBatches(
|
|
sourceBatches: readonly AgentTurnSummaryPatchBatchVM[],
|
|
workspaceRoot?: string | null
|
|
): { cwd: string; diff: string }[] {
|
|
const batches: { cwd: string; diff: string }[] = [];
|
|
for (const batch of sourceBatches) {
|
|
if (
|
|
batch.changes.length === 0 ||
|
|
batch.changes.some(
|
|
(change) =>
|
|
!buildAgentTurnSummaryPatchDiff({
|
|
...batch,
|
|
changes: [change]
|
|
}).trim()
|
|
)
|
|
) {
|
|
return [];
|
|
}
|
|
const sourceCwd = patchBatchDirectoryCwd(
|
|
batch.cwd ?? workspaceRoot ?? null,
|
|
batch.changes,
|
|
workspaceRoot
|
|
);
|
|
const executionCwd = resolvePatchExecutionCwd(sourceCwd, workspaceRoot);
|
|
const diffCwd = resolvePatchDiffCwd({
|
|
executionCwd,
|
|
sourceCwd,
|
|
changes: batch.changes
|
|
});
|
|
const diff = buildAgentTurnSummaryPatchDiff({
|
|
...batch,
|
|
cwd: diffCwd
|
|
});
|
|
if (!executionCwd || !diff.trim()) {
|
|
return [];
|
|
}
|
|
batches.push({ cwd: executionCwd, diff });
|
|
}
|
|
return batches;
|
|
}
|
|
|
|
function hasCompletePatchCoverage(
|
|
files: readonly AgentTurnSummaryFileVM[],
|
|
batches: readonly AgentTurnSummaryPatchBatchVM[]
|
|
): boolean {
|
|
if (files.length === 0 || batches.length === 0) {
|
|
return false;
|
|
}
|
|
const visiblePaths = new Set(files.map((file) => file.path));
|
|
const patchPaths = batches.flatMap((batch) =>
|
|
batch.changes.map((change) => change.path)
|
|
);
|
|
return (
|
|
patchPaths.every((path) => visiblePaths.has(path)) &&
|
|
files.every((file) => patchPaths.includes(file.path))
|
|
);
|
|
}
|
|
|
|
function fallbackPatchFileCwd(
|
|
path: string,
|
|
workspaceRoot?: string | null
|
|
): string | null {
|
|
const root = workspaceRoot?.trim() ?? "";
|
|
if (root && root !== "/") {
|
|
return root;
|
|
}
|
|
const normalizedPath = path.trim().replaceAll("\\", "/").replace(/\/+$/, "");
|
|
if (!normalizedPath.startsWith("/")) {
|
|
return root || null;
|
|
}
|
|
const index = normalizedPath.lastIndexOf("/");
|
|
if (index <= 0) {
|
|
return "/";
|
|
}
|
|
return normalizedPath.slice(0, index);
|
|
}
|
|
|
|
function summarizeFileDiff(file: AgentTurnSummaryFileVM): {
|
|
added: number;
|
|
removed: number;
|
|
} {
|
|
return agentFileChangeStats({
|
|
changeType: file.changeType,
|
|
unifiedDiff: file.unifiedDiff,
|
|
content: file.content,
|
|
oldString: file.oldString,
|
|
newString: file.newString
|
|
});
|
|
}
|