PatchTool.tsx

  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;