1import React, { useState, useEffect, useCallback } from "react";
2import { MultiFileDiff } from "@pierre/diffs/react";
3import type { FileContents, SupportedLanguages, ThemeTypes, ThemesType } from "@pierre/diffs";
4import { LLMContent } from "../types";
5import { isDarkModeActive } from "../services/theme";
6
7// LocalStorage key for side-by-side preference
8const STORAGE_KEY_SIDE_BY_SIDE = "shelley-diff-side-by-side";
9
10// Get saved side-by-side preference (default: true for desktop)
11function getSideBySidePreference(): boolean {
12 try {
13 const stored = localStorage.getItem(STORAGE_KEY_SIDE_BY_SIDE);
14 if (stored !== null) {
15 return stored === "true";
16 }
17 // Default to side-by-side on desktop, inline on mobile
18 return window.innerWidth >= 768;
19 } catch {
20 return window.innerWidth >= 768;
21 }
22}
23
24function setSideBySidePreference(value: boolean): void {
25 try {
26 localStorage.setItem(STORAGE_KEY_SIDE_BY_SIDE, value ? "true" : "false");
27 } catch {
28 // Ignore storage errors
29 }
30}
31
32// Display data structure from the patch tool
33interface PatchDisplayData {
34 path: string;
35 oldContent: string;
36 newContent: string;
37 diff: string;
38}
39
40interface PatchToolProps {
41 // For tool_use (pending state)
42 toolInput?: unknown;
43 isRunning?: boolean;
44
45 // For tool_result (completed state)
46 toolResult?: LLMContent[];
47 hasError?: boolean;
48 executionTime?: string;
49 display?: unknown; // Display data from the tool_result Content (contains the diff or structured data)
50 onCommentTextChange?: (text: string) => void;
51}
52
53// Map file extension to language for syntax highlighting
54function getLanguageFromPath(path: string): SupportedLanguages {
55 const ext = path.split(".").pop()?.toLowerCase() || "";
56 const langMap: Record<string, SupportedLanguages> = {
57 ts: "typescript",
58 tsx: "tsx",
59 js: "javascript",
60 jsx: "jsx",
61 py: "python",
62 rb: "ruby",
63 go: "go",
64 rs: "rust",
65 java: "java",
66 c: "c",
67 cpp: "cpp",
68 h: "c",
69 hpp: "cpp",
70 cs: "csharp",
71 php: "php",
72 swift: "swift",
73 kt: "kotlin",
74 scala: "scala",
75 sh: "bash",
76 bash: "bash",
77 zsh: "bash",
78 fish: "fish",
79 ps1: "powershell",
80 sql: "sql",
81 html: "html",
82 htm: "html",
83 css: "css",
84 scss: "scss",
85 sass: "sass",
86 less: "less",
87 json: "json",
88 xml: "xml",
89 yaml: "yaml",
90 yml: "yaml",
91 toml: "toml",
92 ini: "ini",
93 md: "markdown",
94 markdown: "markdown",
95 txt: "text",
96 dockerfile: "dockerfile",
97 makefile: "makefile",
98 cmake: "cmake",
99 lua: "lua",
100 perl: "perl",
101 r: "r",
102 vue: "vue",
103 svelte: "svelte",
104 astro: "astro",
105 };
106 return langMap[ext] || "text";
107}
108
109// Diff view component using @pierre/diffs
110function DiffView({
111 displayData,
112 sideBySide,
113}: {
114 displayData: PatchDisplayData;
115 sideBySide: boolean;
116}) {
117 const [themeType, setThemeType] = useState<ThemeTypes>(isDarkModeActive() ? "dark" : "light");
118
119 // Listen for theme changes
120 useEffect(() => {
121 const updateTheme = () => {
122 setThemeType(isDarkModeActive() ? "dark" : "light");
123 };
124
125 const observer = new MutationObserver((mutations) => {
126 for (const mutation of mutations) {
127 if (mutation.attributeName === "class") {
128 updateTheme();
129 }
130 }
131 });
132
133 observer.observe(document.documentElement, { attributes: true });
134 return () => observer.disconnect();
135 }, []);
136
137 const lang = getLanguageFromPath(displayData.path);
138
139 const oldFile: FileContents = {
140 name: displayData.path,
141 contents: displayData.oldContent,
142 lang,
143 };
144
145 const newFile: FileContents = {
146 name: displayData.path,
147 contents: displayData.newContent,
148 lang,
149 };
150
151 const theme: ThemesType = {
152 dark: "github-dark",
153 light: "github-light",
154 };
155
156 return (
157 <div className="patch-tool-diffs-container">
158 <MultiFileDiff
159 oldFile={oldFile}
160 newFile={newFile}
161 options={{
162 diffStyle: sideBySide ? "split" : "unified",
163 theme,
164 themeType,
165 disableFileHeader: true,
166 }}
167 />
168 </div>
169 );
170}
171
172// Side-by-side toggle icon component
173function DiffModeToggle({ sideBySide, onToggle }: { sideBySide: boolean; onToggle: () => void }) {
174 return (
175 <button
176 className="patch-tool-diff-mode-toggle"
177 onClick={(e) => {
178 e.stopPropagation();
179 onToggle();
180 }}
181 title={sideBySide ? "Switch to inline diff" : "Switch to side-by-side diff"}
182 >
183 <svg
184 width="14"
185 height="14"
186 viewBox="0 0 14 14"
187 fill="none"
188 xmlns="http://www.w3.org/2000/svg"
189 >
190 {sideBySide ? (
191 // Side-by-side icon (two columns)
192 <>
193 <rect
194 x="1"
195 y="2"
196 width="5"
197 height="10"
198 rx="1"
199 stroke="currentColor"
200 strokeWidth="1.5"
201 fill="none"
202 />
203 <rect
204 x="8"
205 y="2"
206 width="5"
207 height="10"
208 rx="1"
209 stroke="currentColor"
210 strokeWidth="1.5"
211 fill="none"
212 />
213 </>
214 ) : (
215 // Inline icon (single column with horizontal lines)
216 <>
217 <rect
218 x="2"
219 y="2"
220 width="10"
221 height="10"
222 rx="1"
223 stroke="currentColor"
224 strokeWidth="1.5"
225 fill="none"
226 />
227 <line x1="4" y1="5" x2="10" y2="5" stroke="currentColor" strokeWidth="1.5" />
228 <line x1="4" y1="9" x2="10" y2="9" stroke="currentColor" strokeWidth="1.5" />
229 </>
230 )}
231 </svg>
232 </button>
233 );
234}
235
236function PatchTool({ toolInput, isRunning, toolResult, hasError, display }: PatchToolProps) {
237 // Default to collapsed for errors (since agents typically recover), expanded otherwise
238 const [isExpanded, setIsExpanded] = useState(!hasError);
239 const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
240 const [sideBySide, setSideBySide] = useState(() => !isMobile && getSideBySidePreference());
241
242 // Track viewport size
243 useEffect(() => {
244 const handleResize = () => {
245 const mobile = window.innerWidth < 768;
246 setIsMobile(mobile);
247 // Force unified view on mobile
248 if (mobile) {
249 setSideBySide(false);
250 }
251 };
252 window.addEventListener("resize", handleResize);
253 return () => window.removeEventListener("resize", handleResize);
254 }, []);
255
256 // Toggle side-by-side mode
257 const toggleSideBySide = useCallback(() => {
258 const newValue = !sideBySide;
259 setSideBySide(newValue);
260 setSideBySidePreference(newValue);
261 }, [sideBySide]);
262
263 // Extract path from toolInput
264 const path =
265 typeof toolInput === "object" &&
266 toolInput !== null &&
267 "path" in toolInput &&
268 typeof toolInput.path === "string"
269 ? toolInput.path
270 : typeof toolInput === "string"
271 ? toolInput
272 : "";
273
274 // Parse display data (structured format from backend)
275 const displayData: PatchDisplayData | null =
276 display &&
277 typeof display === "object" &&
278 "path" in display &&
279 "oldContent" in display &&
280 "newContent" in display
281 ? (display as PatchDisplayData)
282 : null;
283
284 // Extract error message from toolResult if present
285 const errorMessage =
286 toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
287
288 const isComplete = !isRunning && toolResult !== undefined;
289
290 // Extract filename from path or diff headers
291 const filename = displayData?.path || path || "patch";
292
293 // Show toggle only on desktop when expanded and complete with diff data
294 const showDiffToggle = !isMobile && isExpanded && isComplete && !hasError && displayData;
295
296 return (
297 <div
298 className="patch-tool"
299 data-testid={isComplete ? "tool-call-completed" : "tool-call-running"}
300 >
301 <div className="patch-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
302 <div className="patch-tool-summary">
303 <span className={`patch-tool-emoji ${isRunning ? "running" : ""}`}>🖋️</span>
304 <span className="patch-tool-filename">{filename}</span>
305 {isComplete && hasError && <span className="patch-tool-error">✗</span>}
306 {isComplete && !hasError && <span className="patch-tool-success">✓</span>}
307 </div>
308 <div className="patch-tool-header-controls">
309 {showDiffToggle && <DiffModeToggle sideBySide={sideBySide} onToggle={toggleSideBySide} />}
310 <button
311 className="patch-tool-toggle"
312 aria-label={isExpanded ? "Collapse" : "Expand"}
313 aria-expanded={isExpanded}
314 >
315 <svg
316 width="12"
317 height="12"
318 viewBox="0 0 12 12"
319 fill="none"
320 xmlns="http://www.w3.org/2000/svg"
321 style={{
322 transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
323 transition: "transform 0.2s",
324 }}
325 >
326 <path
327 d="M4.5 3L7.5 6L4.5 9"
328 stroke="currentColor"
329 strokeWidth="1.5"
330 strokeLinecap="round"
331 strokeLinejoin="round"
332 />
333 </svg>
334 </button>
335 </div>
336 </div>
337
338 {isExpanded && (
339 <div className="patch-tool-details">
340 {isComplete && !hasError && displayData && (
341 <div className="patch-tool-section">
342 <DiffView displayData={displayData} sideBySide={sideBySide} />
343 </div>
344 )}
345
346 {isComplete && hasError && (
347 <div className="patch-tool-section">
348 <pre className="patch-tool-error-message">{errorMessage || "Patch failed"}</pre>
349 </div>
350 )}
351
352 {isRunning && (
353 <div className="patch-tool-section">
354 <div className="patch-tool-label">Applying patch...</div>
355 </div>
356 )}
357 </div>
358 )}
359 </div>
360 );
361}
362
363export default PatchTool;