agent-ecosystem/src/renderer/hooks/useToolApprovalDiff.ts
iliya a6fc7f82d3 feat: tool approval improvements — diff preview, error feedback, provisioning fix
- Add diff preview for Write/Edit tools: reads current file via new IPC
  (TEAM_TOOL_APPROVAL_READ_FILE), shows unified diff using existing DiffViewer
- Fix Allow button doing nothing: re-throw errors from store instead of
  silently swallowing, show error message in UI, add 10s safety timeout
- Fix "No active process" during provisioning: use getTrackedRunId() to
  find process in both provisioning and alive maps
- Add 5s stdin.write timeout to prevent hanging when process dies
- Add syntax highlighting for tool input preview (JSON, bash, etc.)
- Add team color/name badge from ToolApprovalRequest (works during provisioning)
- Conditionally show team badge only when user is on a different team page
- Format elapsed time as Xm Ys when over 60 seconds
- Replace native <select> with Radix UI Select in settings panel
2026-03-20 18:41:01 +02:00

212 lines
6.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import type { ToolApprovalFileContent } from '@shared/types';
// =============================================================================
// Types
// =============================================================================
export interface ToolApprovalDiffData {
/** Whether this tool type supports diff preview */
hasDiff: boolean;
/** Loading state (file read in progress) */
loading: boolean;
/** Error message */
error: string | null;
/** File name for syntax highlighting */
fileName: string;
/** Original file content */
oldString: string;
/** New file content after tool execution */
newString: string;
/** File doesn't exist yet */
isNewFile: boolean;
/** File content was truncated at size limit */
truncated: boolean;
/** File is binary */
isBinary: boolean;
}
const DIFF_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
const INITIAL_STATE: ToolApprovalDiffData = {
hasDiff: false,
loading: false,
error: null,
fileName: '',
oldString: '',
newString: '',
isNewFile: false,
truncated: false,
isBinary: false,
};
// =============================================================================
// Helpers
// =============================================================================
function getFilePath(toolInput: Record<string, unknown>): string {
const fp = toolInput.file_path ?? toolInput.notebook_path;
return typeof fp === 'string' ? fp : '';
}
function computeEditResult(
currentContent: string,
toolInput: Record<string, unknown>
): { newString: string; error: string | null } {
const oldStr = typeof toolInput.old_string === 'string' ? toolInput.old_string : '';
const newStr = typeof toolInput.new_string === 'string' ? toolInput.new_string : '';
const replaceAll = toolInput.replace_all === true;
if (!oldStr) {
return { newString: currentContent, error: 'Edit: old_string is empty' };
}
if (!currentContent.includes(oldStr)) {
return { newString: currentContent, error: 'Fragment not found in current file' };
}
const result = replaceAll
? currentContent.replaceAll(oldStr, newStr)
: currentContent.replace(oldStr, newStr);
return { newString: result, error: null };
}
// =============================================================================
// Hook
// =============================================================================
/**
* Lazy-loading hook that reads file content from disk and computes diff data
* for Write/Edit/NotebookEdit tool approvals.
*
* @param toolName - The tool requesting approval
* @param toolInput - The tool's input parameters
* @param requestId - Unique approval request ID (used for cancellation)
* @param enabled - Only fetch when true (lazy — user expanded the diff section)
*/
export function useToolApprovalDiff(
toolName: string,
toolInput: Record<string, unknown>,
requestId: string,
enabled: boolean
): ToolApprovalDiffData {
const [state, setState] = useState<ToolApprovalDiffData>(INITIAL_STATE);
const activeRef = useRef<string | null>(null);
const hasDiff = DIFF_TOOLS.has(toolName);
const filePath = getFilePath(toolInput);
useEffect(() => {
// Reset when approval changes
setState(INITIAL_STATE);
activeRef.current = null;
}, [requestId]);
useEffect(() => {
if (!hasDiff || !enabled || !filePath) return;
// NotebookEdit: no file read needed, just show new_source
if (toolName === 'NotebookEdit') {
const newSource = typeof toolInput.new_source === 'string' ? toolInput.new_source : '';
setState({
hasDiff: true,
loading: false,
error: null,
fileName: filePath,
oldString: '',
newString: newSource,
isNewFile: false,
truncated: false,
isBinary: false,
});
return;
}
// Write / Edit: need to read current file from disk
const currentRequestId = requestId;
activeRef.current = currentRequestId;
setState((prev) => ({ ...prev, hasDiff: true, loading: true, error: null }));
void (async () => {
let result: ToolApprovalFileContent;
try {
result = await api.teams.readFileForToolApproval(filePath);
} catch (err) {
if (activeRef.current !== currentRequestId) return;
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : String(err),
}));
return;
}
if (activeRef.current !== currentRequestId) return;
if (result.error) {
setState((prev) => ({
...prev,
loading: false,
error: result.error ?? 'Unknown read error',
fileName: filePath,
}));
return;
}
if (result.isBinary) {
setState((prev) => ({
...prev,
loading: false,
isBinary: true,
fileName: filePath,
}));
return;
}
const isNewFile = !result.exists;
const currentContent = result.content;
if (toolName === 'Write') {
const newContent = typeof toolInput.content === 'string' ? toolInput.content : '';
setState({
hasDiff: true,
loading: false,
error: null,
fileName: filePath,
oldString: currentContent,
newString: newContent,
isNewFile,
truncated: result.truncated,
isBinary: false,
});
} else if (toolName === 'Edit') {
const { newString, error } = computeEditResult(currentContent, toolInput);
setState({
hasDiff: true,
loading: false,
error,
fileName: filePath,
oldString: currentContent,
newString,
isNewFile: false,
truncated: result.truncated,
isBinary: false,
});
}
})();
return () => {
activeRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- toolInput is a fresh object each render, use requestId for identity
}, [hasDiff, enabled, filePath, requestId, toolName]);
if (!hasDiff) return INITIAL_STATE;
return { ...state, hasDiff: true };
}