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>( {} ); const [showAllFiles, setShowAllFiles] = useState(false); const [patchAction, setPatchAction] = useState<"undo" | "reapply">("undo"); const [patchPending, setPatchPending] = useState(false); const [patchSupportState, setPatchSupportState] = useState(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) => { 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 (
{translate("agentHost.agentGui.turnSummaryFilesChanged", { count: row.fileCount })}
{aggregateStats.added > 0 ? ( +{aggregateStats.added} ) : null} {aggregateStats.removed > 0 ? ( -{aggregateStats.removed} ) : null}
{canRenderPatchButton ? ( {patchPending || isPatchSupportChecking ? ( {patchDisabledReason ? ( {patchDisabledReason} ) : null} ) : null}
{visibleFiles.map((file) => { const key = `${file.path}:${file.messageId}`; return ( setExpandedFiles((current) => ({ ...current, [key]: !current[key] })) } /> ); })} {hiddenFileCount > 0 ? (
{hiddenFiles.map((file) => { const key = `${file.path}:${file.messageId}`; return ( setExpandedFiles((current) => ({ ...current, [key]: !current[key] })) } /> ); })}
) : null} {hiddenFileCount > 0 && !showAllFiles ? ( ) : hiddenFileCount > 0 ? ( ) : null}
); } 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 (
{stats.added > 0 ? ( +{stats.added} ) : null} {stats.removed > 0 ? ( -{stats.removed} ) : null}
{canOpen ? ( { onLinkAction?.(action as WorkspaceLinkAction); }} > ) : null}
{preview ? (
{preview}
) : null}
); } function filePreview(file: AgentTurnSummaryFileVM): JSX.Element | null { if ( file.changeType === "created" && file.content !== null && file.content !== undefined ) { return ( ); } if (file.unifiedDiff && isAgentUnifiedDiff(file.unifiedDiff)) { return ( ); } if ( file.changeType === "created" && file.newString !== null && file.newString !== undefined ) { return ( ); } if ( (file.oldString !== null && file.oldString !== undefined) || (file.newString !== null && file.newString !== undefined) ) { return ( ); } if ( file.changeType === "deleted" && ((file.content !== null && file.content !== undefined) || (file.unifiedDiff !== null && file.unifiedDiff !== undefined)) ) { return ( ); } if (file.content !== null && file.content !== undefined) { return ( ); } if ( file.unifiedDiff !== null && file.unifiedDiff !== undefined && file.changeType !== "deleted" ) { return ( ); } 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, 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 }); }