Files
kandev/apps/web/components/task/chat/messages/tool-edit-message.tsx
T
2026-02-06 23:53:58 +00:00

162 lines
5.6 KiB
TypeScript

'use client';
import { useState, useRef, memo, useCallback } from 'react';
import { IconCheck, IconX, IconEdit, IconFilePlus, IconExternalLink, IconCopy } from '@tabler/icons-react';
import { GridSpinner } from '@/components/grid-spinner';
import { transformPathsInText } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import { DiffViewBlock } from './diff-view-block';
import { ExpandableRow } from './expandable-row';
import { transformFileMutation, type FileMutation } from '@/lib/diff';
type ModifyFilePayload = {
file_path?: string;
mutations?: FileMutation[];
};
type NormalizedPayload = {
kind?: string;
modify_file?: ModifyFilePayload;
};
type ToolEditMetadata = {
tool_call_id?: string;
status?: 'pending' | 'running' | 'complete' | 'error';
normalized?: NormalizedPayload;
};
type ToolEditMessageProps = {
comment: Message;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktreePath, onOpenFile }: ToolEditMessageProps) {
const metadata = comment.metadata as ToolEditMetadata | undefined;
const [manualExpandState, setManualExpandState] = useState<boolean | null>(null);
const [copied, setCopied] = useState(false);
const prevStatusRef = useRef(metadata?.status);
const status = metadata?.status;
const normalized = metadata?.normalized;
const filePath = normalized?.modify_file?.file_path;
const mutation = normalized?.modify_file?.mutations?.[0];
const writeContent = mutation?.content; // For Write tool (create operations)
const isWriteOperation = mutation?.type === 'create';
// Transform mutation to FileDiffData using the new adapter
const diffData = filePath && mutation
? transformFileMutation(filePath, mutation)
: null;
const hasExpandableContent = !!(diffData?.diff || writeContent);
const isSuccess = status === 'complete';
const Icon = isWriteOperation ? IconFilePlus : IconEdit;
// Reset manual state when status changes (allows auto-expand behavior to resume)
if (prevStatusRef.current !== status) {
prevStatusRef.current = status;
if (manualExpandState !== null) {
setManualExpandState(null);
}
}
// Derive expanded state: manual override takes precedence, otherwise auto-expand when running
const autoExpanded = status === 'running';
const isExpanded = manualExpandState ?? autoExpanded;
const handleToggle = useCallback(() => {
setManualExpandState((prev) => !(prev ?? autoExpanded));
}, [autoExpanded]);
const handleCopyPath = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (filePath) {
navigator.clipboard?.writeText(filePath);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}, [filePath]);
const getStatusIcon = () => {
switch (status) {
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
case 'error':
return <IconX className="h-3.5 w-3.5 text-red-500" />;
case 'running':
return <GridSpinner className="text-muted-foreground" />;
default:
return null;
}
};
// Count lines for Write content
const lineCount = writeContent ? writeContent.split('\n').length : 0;
// Check if file is inside worktree (only these can be opened)
const isFileInWorktree = filePath && worktreePath && filePath.startsWith(worktreePath);
const getSummary = () => {
const baseSummary = transformPathsInText(comment.content, worktreePath);
if (isWriteOperation && lineCount > 0) {
return `${baseSummary} (${lineCount} line${lineCount !== 1 ? 's' : ''})`;
}
return baseSummary;
};
return (
<ExpandableRow
icon={<Icon className="h-4 w-4 text-muted-foreground" />}
header={
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap">
<span className="font-mono text-xs text-muted-foreground">{getSummary()}</span>
{!isSuccess && getStatusIcon()}
</span>
{filePath && onOpenFile && isFileInWorktree && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenFile(filePath);
}}
className="opacity-0 group-hover/expandable:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
title="Open file"
>
<IconExternalLink className="h-3.5 w-3.5" />
</button>
)}
{filePath && !isFileInWorktree && (
<button
type="button"
onClick={handleCopyPath}
className="opacity-0 group-hover/expandable:opacity-100 transition-opacity text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
title={copied ? 'Copied!' : 'Copy path'}
>
{copied ? (
<IconCheck className="h-3.5 w-3.5 text-green-500" />
) : (
<IconCopy className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
}
hasExpandableContent={hasExpandableContent}
isExpanded={isExpanded}
onToggle={handleToggle}
>
{diffData?.diff ? (
<DiffViewBlock data={diffData} className="mt-0 border-0 px-0" />
) : writeContent ? (
<div className="pl-4 border-l-2 border-border/30">
<pre className="text-xs bg-muted/30 rounded p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap">
{writeContent}
</pre>
</div>
) : null}
</ExpandableRow>
);
});