shelley: use Monaco diff view for patch tool

Philip Zeyliger created

Hey, you can leave comments too, why not.

Prompt: In a new worktree (reset to origin/main after fetching), work on
the UI of the patch tool. See if you can use the monaco diff view to
produce a side-by-side from that patch display. (Note that you can
modify the display output of the tool if you need to, within reason.) Do
side-by-side if there's space; not side-by-side if there isn't; theme
(light/dark) should follow the rest of the interface. There's a separate
diff view which can be leaned on, though the interfaces are quite
different. It would be nice to have commenting in both

- Modify patch tool backend to return structured display data with old/new content
- Update PatchTool.tsx to use Monaco diff editor
- Side-by-side view on desktop, inline view on mobile (<768px)
- Theme follows dark/light mode automatically via MutationObserver
- Commenting support: click on lines to add comments (works in both streaming and static views)
- Pass onCommentTextChange prop through Message.tsx for commenting after page reload
- Removed legacy text-based diff fallback (always use Monaco now)

Change summary

claudetool/patch.go                 |  19 +
ui/src/components/ChatInterface.tsx |   6 
ui/src/components/Message.tsx       |  20 +
ui/src/components/PatchTool.tsx     | 368 ++++++++++++++++++++++++++----
ui/src/styles.css                   | 130 ++++++++--
5 files changed, 455 insertions(+), 88 deletions(-)

Detailed changes

claudetool/patch.go 🔗

@@ -260,6 +260,14 @@ type PatchInputOneString struct {
 	Patches string `json:"patches"` // contains Patches as a JSON string 🤦
 }
 
+// PatchDisplayData is the structured data sent to the UI for display.
+type PatchDisplayData struct {
+	Path       string `json:"path"`
+	OldContent string `json:"oldContent"`
+	NewContent string `json:"newContent"`
+	Diff       string `json:"diff"`
+}
+
 // PatchRequest represents a single patch operation.
 type PatchRequest struct {
 	Operation     string    `json:"operation"`
@@ -543,10 +551,17 @@ func (p *PatchTool) patchRun(ctx context.Context, input *PatchInput) llm.ToolOut
 
 	diff := generateUnifiedDiff(input.Path, string(orig), string(patched))
 
-	// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
+	// Display data for the UI includes structured content for Monaco diff editor
+	displayData := PatchDisplayData{
+		Path:       input.Path,
+		OldContent: string(orig),
+		NewContent: string(patched),
+		Diff:       diff,
+	}
+
 	return llm.ToolOut{
 		LLMContent: llm.TextContent(response.String()),
-		Display:    diff,
+		Display:    displayData,
 	}
 }
 

ui/src/components/ChatInterface.tsx 🔗

@@ -124,6 +124,7 @@ interface CoalescedToolCallProps {
   toolEndTime?: string | null;
   hasResult?: boolean;
   display?: unknown;
+  onCommentTextChange?: (text: string) => void;
 }
 
 // Map tool names to their specialized components.
@@ -155,6 +156,7 @@ function CoalescedToolCall({
   toolEndTime,
   hasResult,
   display,
+  onCommentTextChange,
 }: CoalescedToolCallProps) {
   // Calculate execution time if available
   let executionTime = "";
@@ -183,6 +185,8 @@ function CoalescedToolCall({
       ...(toolName === "browser_recent_console_logs" || toolName === "browser_clear_console_logs"
         ? { toolName }
         : {}),
+      // Patch tool can add comments
+      ...(toolName === "patch" && onCommentTextChange ? { onCommentTextChange } : {}),
     };
     return <ToolComponent {...props} />;
   }
@@ -932,6 +936,7 @@ function ChatInterface({
               setDiffViewerInitialCommit(commit);
               setShowDiffViewer(true);
             }}
+            onCommentTextChange={setDiffCommentText}
           />
         );
       } else if (item.type === "tool") {
@@ -946,6 +951,7 @@ function ChatInterface({
             toolEndTime={item.toolEndTime}
             hasResult={item.hasResult}
             display={item.display}
+            onCommentTextChange={setDiffCommentText}
           />
         );
       }

ui/src/components/Message.tsx 🔗

@@ -26,9 +26,10 @@ interface ToolDisplay {
 interface MessageProps {
   message: MessageType;
   onOpenDiffViewer?: (commit: string) => void;
+  onCommentTextChange?: (text: string) => void;
 }
 
-function Message({ message, onOpenDiffViewer }: MessageProps) {
+function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProps) {
   // Hide system messages from the UI
   if (message.type === "system") {
     return null;
@@ -352,7 +353,13 @@ function Message({ message, onOpenDiffViewer }: MessageProps) {
         }
         // Use specialized component for patch tool
         if (content.ToolName === "patch") {
-          return <PatchTool toolInput={content.ToolInput} isRunning={true} />;
+          return (
+            <PatchTool
+              toolInput={content.ToolInput}
+              isRunning={true}
+              onCommentTextChange={onCommentTextChange}
+            />
+          );
         }
         // Use specialized component for screenshot tool
         if (content.ToolName === "screenshot" || content.ToolName === "browser_take_screenshot") {
@@ -475,6 +482,7 @@ function Message({ message, onOpenDiffViewer }: MessageProps) {
               hasError={hasError}
               executionTime={executionTime}
               display={content.Display}
+              onCommentTextChange={onCommentTextChange}
             />
           );
         }
@@ -724,7 +732,13 @@ function Message({ message, onOpenDiffViewer }: MessageProps) {
       ];
 
       return (
-        <PatchTool toolInput={{}} isRunning={false} toolResult={mockToolResult} hasError={false} />
+        <PatchTool
+          toolInput={{}}
+          isRunning={false}
+          toolResult={mockToolResult}
+          hasError={false}
+          onCommentTextChange={onCommentTextChange}
+        />
       );
     }
 

ui/src/components/PatchTool.tsx 🔗

@@ -1,5 +1,15 @@
-import React, { useState } from "react";
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import type * as Monaco from "monaco-editor";
 import { LLMContent } from "../types";
+import { isDarkModeActive } from "../services/theme";
+
+// Display data structure from the patch tool
+interface PatchDisplayData {
+  path: string;
+  oldContent: string;
+  newContent: string;
+  diff: string;
+}
 
 interface PatchToolProps {
   // For tool_use (pending state)
@@ -10,7 +20,46 @@ interface PatchToolProps {
   toolResult?: LLMContent[];
   hasError?: boolean;
   executionTime?: string;
-  display?: unknown; // Display data from the tool_result Content (contains the diff)
+  display?: unknown; // Display data from the tool_result Content (contains the diff or structured data)
+  onCommentTextChange?: (text: string) => void;
+}
+
+// Global Monaco instance - loaded lazily
+let monacoInstance: typeof Monaco | null = null;
+let monacoLoadPromise: Promise<typeof Monaco> | null = null;
+
+function loadMonaco(): Promise<typeof Monaco> {
+  if (monacoInstance) {
+    return Promise.resolve(monacoInstance);
+  }
+  if (monacoLoadPromise) {
+    return monacoLoadPromise;
+  }
+
+  monacoLoadPromise = (async () => {
+    // Configure Monaco environment for web workers before importing
+    const monacoEnv: Monaco.Environment = {
+      getWorkerUrl: () => "/editor.worker.js",
+    };
+    (self as Window).MonacoEnvironment = monacoEnv;
+
+    // Load Monaco CSS if not already loaded
+    if (!document.querySelector('link[href="/monaco-editor.css"]')) {
+      const link = document.createElement("link");
+      link.rel = "stylesheet";
+      link.href = "/monaco-editor.css";
+      document.head.appendChild(link);
+    }
+
+    // Load Monaco from our local bundle (runtime URL, cast to proper types)
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore - dynamic runtime URL import
+    const monaco = (await import("/monaco-editor.js")) as typeof Monaco;
+    monacoInstance = monaco;
+    return monacoInstance;
+  })();
+
+  return monacoLoadPromise;
 }
 
 function PatchTool({
@@ -20,9 +69,31 @@ function PatchTool({
   hasError,
   executionTime,
   display,
+  onCommentTextChange,
 }: PatchToolProps) {
   // Default to collapsed for errors (since agents typically recover), expanded otherwise
   const [isExpanded, setIsExpanded] = useState(!hasError);
+  const [monacoLoaded, setMonacoLoaded] = useState(false);
+  const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
+  const [showCommentDialog, setShowCommentDialog] = useState<{
+    line: number;
+    selectedText?: string;
+  } | null>(null);
+  const [commentText, setCommentText] = useState("");
+
+  const editorContainerRef = useRef<HTMLDivElement>(null);
+  const editorRef = useRef<Monaco.editor.IStandaloneDiffEditor | null>(null);
+  const monacoRef = useRef<typeof Monaco | null>(null);
+  const commentInputRef = useRef<HTMLTextAreaElement>(null);
+
+  // Track viewport size
+  useEffect(() => {
+    const handleResize = () => {
+      setIsMobile(window.innerWidth < 768);
+    };
+    window.addEventListener("resize", handleResize);
+    return () => window.removeEventListener("resize", handleResize);
+  }, []);
 
   // Extract path from toolInput
   const path =
@@ -35,38 +106,213 @@ function PatchTool({
         ? toolInput
         : "";
 
-  // Extract diff from display (preferred) or fall back to toolResult
-  const diff =
-    typeof display === "string"
-      ? display
-      : toolResult && toolResult.length > 0 && toolResult[0].Text
-        ? toolResult[0].Text
-        : "";
+  // Parse display data (structured format from backend)
+  const displayData: PatchDisplayData | null =
+    display &&
+    typeof display === "object" &&
+    "path" in display &&
+    "oldContent" in display &&
+    "newContent" in display
+      ? (display as PatchDisplayData)
+      : null;
+
+  // Extract error message from toolResult if present
+  const errorMessage =
+    toolResult && toolResult.length > 0 && toolResult[0].Text ? toolResult[0].Text : "";
 
   const isComplete = !isRunning && toolResult !== undefined;
 
-  // Parse unified diff to extract filename and colorize lines
-  const parseDiff = (diffText: string) => {
-    if (!diffText) return { filename: path, lines: [] };
+  // Extract filename from path or diff headers
+  const filename = displayData?.path || path || "patch";
+
+  // Load Monaco when expanded and we have display data
+  useEffect(() => {
+    if (isExpanded && displayData && !monacoLoaded) {
+      loadMonaco()
+        .then((monaco) => {
+          monacoRef.current = monaco;
+          setMonacoLoaded(true);
+        })
+        .catch((err) => {
+          console.error("Failed to load Monaco:", err);
+        });
+    }
+  }, [isExpanded, displayData, monacoLoaded]);
+
+  // Create Monaco editor when data is ready
+  useEffect(() => {
+    if (
+      !monacoLoaded ||
+      !displayData ||
+      !editorContainerRef.current ||
+      !monacoRef.current ||
+      !isExpanded
+    ) {
+      return;
+    }
+
+    const monaco = monacoRef.current;
+
+    // Dispose previous editor
+    if (editorRef.current) {
+      editorRef.current.dispose();
+      editorRef.current = null;
+    }
+
+    // Get language from file extension
+    const ext = "." + (displayData.path.split(".").pop()?.toLowerCase() || "");
+    const languages = monaco.languages.getLanguages();
+    let language = "plaintext";
+    for (const lang of languages) {
+      if (lang.extensions?.includes(ext)) {
+        language = lang.id;
+        break;
+      }
+    }
+
+    // Create models with unique URIs (include timestamp to avoid conflicts)
+    const timestamp = Date.now();
+    const originalUri = monaco.Uri.file(`patch-original-${timestamp}-${displayData.path}`);
+    const modifiedUri = monaco.Uri.file(`patch-modified-${timestamp}-${displayData.path}`);
+
+    const originalModel = monaco.editor.createModel(displayData.oldContent, language, originalUri);
+    const modifiedModel = monaco.editor.createModel(displayData.newContent, language, modifiedUri);
+
+    // Create diff editor
+    const diffEditor = monaco.editor.createDiffEditor(editorContainerRef.current, {
+      theme: isDarkModeActive() ? "vs-dark" : "vs",
+      readOnly: true,
+      originalEditable: false,
+      automaticLayout: true,
+      renderSideBySide: !isMobile,
+      enableSplitViewResizing: true,
+      renderIndicators: true,
+      renderMarginRevertIcon: false,
+      lineNumbers: isMobile ? "off" : "on",
+      minimap: { enabled: false },
+      scrollBeyondLastLine: false,
+      wordWrap: "on",
+      glyphMargin: false,
+      lineDecorationsWidth: isMobile ? 0 : 10,
+      lineNumbersMinChars: isMobile ? 0 : 3,
+      quickSuggestions: false,
+      suggestOnTriggerCharacters: false,
+      lightbulb: { enabled: false },
+      codeLens: false,
+      contextmenu: false,
+      links: false,
+      folding: !isMobile,
+    });
+
+    diffEditor.setModel({
+      original: originalModel,
+      modified: modifiedModel,
+    });
 
-    const lines = diffText.split("\n");
-    let filename = path;
+    editorRef.current = diffEditor;
 
-    // Extract filename from diff header if present
-    for (const line of lines) {
-      if (line.startsWith("---")) {
-        // Format: --- a/path/to/file.txt
-        const match = line.match(/^---\s+(.+?)\s*$/);
-        if (match) {
-          filename = match[1].replace(/^[ab]\//, ""); // Remove a/ or b/ prefix
+    // Add click handler for commenting if callback is provided
+    if (onCommentTextChange) {
+      const modifiedEditor = diffEditor.getModifiedEditor();
+
+      const openCommentDialog = (lineNumber: number) => {
+        const model = modifiedEditor.getModel();
+        const selection = modifiedEditor.getSelection();
+        let selectedText = "";
+
+        if (selection && !selection.isEmpty() && model) {
+          selectedText = model.getValueInRange(selection);
+        } else if (model) {
+          selectedText = model.getLineContent(lineNumber) || "";
+        }
+
+        setShowCommentDialog({
+          line: lineNumber,
+          selectedText,
+        });
+      };
+
+      modifiedEditor.onMouseDown((e: Monaco.editor.IEditorMouseEvent) => {
+        const isLineClick =
+          e.target.type === monaco.editor.MouseTargetType.CONTENT_TEXT ||
+          e.target.type === monaco.editor.MouseTargetType.CONTENT_EMPTY;
+
+        if (isLineClick) {
+          const position = e.target.position;
+          if (position) {
+            openCommentDialog(position.lineNumber);
+          }
+        }
+      });
+    }
+
+    // Cleanup function
+    return () => {
+      if (editorRef.current) {
+        editorRef.current.dispose();
+        editorRef.current = null;
+      }
+    };
+  }, [monacoLoaded, displayData, isMobile, isExpanded, onCommentTextChange]);
+
+  // Update Monaco theme when dark mode changes
+  useEffect(() => {
+    if (!monacoRef.current) return;
+
+    const updateMonacoTheme = () => {
+      const theme = isDarkModeActive() ? "vs-dark" : "vs";
+      monacoRef.current?.editor.setTheme(theme);
+    };
+
+    const observer = new MutationObserver((mutations) => {
+      for (const mutation of mutations) {
+        if (mutation.attributeName === "class") {
+          updateMonacoTheme();
         }
       }
+    });
+
+    observer.observe(document.documentElement, { attributes: true });
+
+    return () => observer.disconnect();
+  }, [monacoLoaded]);
+
+  // Focus comment input when dialog opens
+  useEffect(() => {
+    if (showCommentDialog && commentInputRef.current) {
+      setTimeout(() => {
+        commentInputRef.current?.focus();
+      }, 50);
     }
+  }, [showCommentDialog]);
 
-    return { filename, lines };
-  };
+  // Handle adding a comment
+  const handleAddComment = useCallback(() => {
+    if (!showCommentDialog || !commentText.trim() || !onCommentTextChange) return;
+
+    const line = showCommentDialog.line;
+    const codeSnippet = showCommentDialog.selectedText?.split("\n")[0]?.trim() || "";
+    const truncatedCode =
+      codeSnippet.length > 60 ? codeSnippet.substring(0, 57) + "..." : codeSnippet;
 
-  const { filename, lines } = parseDiff(diff);
+    const commentBlock = `> ${filename}:${line}: ${truncatedCode}\n${commentText}\n\n`;
+
+    onCommentTextChange(commentBlock);
+    setShowCommentDialog(null);
+    setCommentText("");
+  }, [showCommentDialog, commentText, onCommentTextChange, filename]);
+
+  // Calculate editor height based on content
+  const getEditorHeight = () => {
+    if (!displayData) return "200px";
+    const lineCount = Math.max(
+      displayData.oldContent.split("\n").length,
+      displayData.newContent.split("\n").length,
+    );
+    // Clamp between 100px and 400px, with 18px per line
+    const height = Math.min(400, Math.max(100, lineCount * 18 + 20));
+    return `${height}px`;
+  };
 
   return (
     <div
@@ -76,7 +322,7 @@ function PatchTool({
       <div className="patch-tool-header" onClick={() => setIsExpanded(!isExpanded)}>
         <div className="patch-tool-summary">
           <span className={`patch-tool-emoji ${isRunning ? "running" : ""}`}>🖋️</span>
-          <span className="patch-tool-filename">{filename || "patch"}</span>
+          <span className="patch-tool-filename">{filename}</span>
           {isComplete && hasError && <span className="patch-tool-error">✗</span>}
           {isComplete && !hasError && <span className="patch-tool-success">✓</span>}
         </div>
@@ -109,7 +355,7 @@ function PatchTool({
 
       {isExpanded && (
         <div className="patch-tool-details">
-          {isComplete && !hasError && diff && (
+          {isComplete && !hasError && displayData && (
             <div className="patch-tool-section">
               {executionTime && (
                 <div className="patch-tool-label">
@@ -117,27 +363,13 @@ function PatchTool({
                   <span className="patch-tool-time">{executionTime}</span>
                 </div>
               )}
-              <pre className="patch-tool-diff">
-                {lines.map((line, idx) => {
-                  // Determine line type for styling
-                  let className = "patch-diff-line";
-                  if (line.startsWith("+") && !line.startsWith("+++")) {
-                    className += " patch-diff-addition";
-                  } else if (line.startsWith("-") && !line.startsWith("---")) {
-                    className += " patch-diff-deletion";
-                  } else if (line.startsWith("@@")) {
-                    className += " patch-diff-hunk";
-                  } else if (line.startsWith("---") || line.startsWith("+++")) {
-                    className += " patch-diff-header";
-                  }
-
-                  return (
-                    <div key={idx} className={className}>
-                      {line || " "}
-                    </div>
-                  );
-                })}
-              </pre>
+
+              {/* Monaco diff editor */}
+              <div
+                ref={editorContainerRef}
+                className="patch-tool-monaco-editor"
+                style={{ height: getEditorHeight(), width: "100%" }}
+              />
             </div>
           )}
 
@@ -147,7 +379,7 @@ function PatchTool({
                 <span>Error:</span>
                 {executionTime && <span className="patch-tool-time">{executionTime}</span>}
               </div>
-              <pre className="patch-tool-error-message">{diff || "Patch failed"}</pre>
+              <pre className="patch-tool-error-message">{errorMessage || "Patch failed"}</pre>
             </div>
           )}
 
@@ -158,6 +390,46 @@ function PatchTool({
           )}
         </div>
       )}
+
+      {/* Comment dialog */}
+      {showCommentDialog && onCommentTextChange && (
+        <div className="patch-tool-comment-dialog">
+          <h4>Add Comment (Line {showCommentDialog.line})</h4>
+          {showCommentDialog.selectedText && (
+            <pre className="patch-tool-selected-text">{showCommentDialog.selectedText}</pre>
+          )}
+          <textarea
+            ref={commentInputRef}
+            value={commentText}
+            onChange={(e) => setCommentText(e.target.value)}
+            placeholder="Enter your comment..."
+            className="patch-tool-comment-input"
+            autoFocus
+            onKeyDown={(e) => {
+              if (e.key === "Escape") {
+                setShowCommentDialog(null);
+              } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+                handleAddComment();
+              }
+            }}
+          />
+          <div className="patch-tool-comment-actions">
+            <button
+              onClick={() => setShowCommentDialog(null)}
+              className="patch-tool-btn patch-tool-btn-secondary"
+            >
+              Cancel
+            </button>
+            <button
+              onClick={handleAddComment}
+              className="patch-tool-btn patch-tool-btn-primary"
+              disabled={!commentText.trim()}
+            >
+              Add Comment
+            </button>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

ui/src/styles.css 🔗

@@ -1217,66 +1217,126 @@ button {
   font-weight: normal;
 }
 
-.patch-tool-diff {
+.patch-tool-error-message {
   font-family: var(--font-mono);
   font-size: 0.875rem;
-  background: var(--bg-base);
+  background: var(--bg-tertiary);
   border: 1px solid var(--border);
   border-radius: 0.25rem;
   padding: 0.75rem;
   margin: 0;
   overflow-x: auto;
-  line-height: 1.4;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--text-secondary);
 }
 
-.patch-diff-line {
-  white-space: pre;
-  display: block;
+/* Patch Tool Monaco Editor */
+.patch-tool-monaco-editor {
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  overflow: hidden;
 }
 
-.patch-diff-addition {
-  background: rgba(34, 197, 94, 0.1);
-  color: #16a34a;
+/* Patch Tool Comment Dialog */
+.patch-tool-comment-dialog {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  background: var(--bg-base);
+  border: 1px solid var(--border);
+  border-radius: 0.5rem;
+  padding: 1rem;
+  z-index: 1000;
+  max-width: 500px;
+  width: 90%;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
 }
 
-.dark .patch-diff-addition {
-  background: rgba(34, 197, 94, 0.15);
-  color: #86efac;
+.dark .patch-tool-comment-dialog {
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
 }
 
-.patch-diff-deletion {
-  background: rgba(239, 68, 68, 0.1);
-  color: #dc2626;
+.patch-tool-comment-dialog h4 {
+  margin: 0 0 0.75rem 0;
+  font-size: 0.875rem;
+  font-weight: 600;
+  color: var(--text-primary);
 }
 
-.dark .patch-diff-deletion {
-  background: rgba(239, 68, 68, 0.15);
-  color: #fca5a5;
+.patch-tool-selected-text {
+  font-family: var(--font-mono);
+  font-size: 0.75rem;
+  background: var(--bg-tertiary);
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  padding: 0.5rem;
+  margin: 0 0 0.75rem 0;
+  overflow-x: auto;
+  max-height: 100px;
+  white-space: pre;
+  color: var(--text-secondary);
 }
 
-.patch-diff-hunk {
-  color: var(--text-secondary);
-  background: var(--bg-tertiary);
-  font-weight: 500;
+.patch-tool-comment-input {
+  width: 100%;
+  min-height: 80px;
+  padding: 0.5rem;
+  border: 1px solid var(--border);
+  border-radius: 0.25rem;
+  background: var(--bg-base);
+  color: var(--text-primary);
+  font-family: inherit;
+  font-size: 0.875rem;
+  resize: vertical;
+  box-sizing: border-box;
 }
 
-.patch-diff-header {
-  color: var(--text-tertiary);
-  font-style: italic;
+.patch-tool-comment-input:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
 }
 
-.patch-tool-error-message {
-  font-family: var(--font-mono);
+.patch-tool-comment-actions {
+  display: flex;
+  gap: 0.5rem;
+  justify-content: flex-end;
+  margin-top: 0.75rem;
+}
+
+.patch-tool-btn {
+  padding: 0.5rem 1rem;
+  border-radius: 0.25rem;
   font-size: 0.875rem;
+  font-weight: 500;
+  cursor: pointer;
+  border: none;
+  transition: background-color 0.15s;
+}
+
+.patch-tool-btn-primary {
+  background: var(--primary);
+  color: white;
+}
+
+.patch-tool-btn-primary:hover:not(:disabled) {
+  background: var(--primary-dark);
+}
+
+.patch-tool-btn-primary:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.patch-tool-btn-secondary {
   background: var(--bg-tertiary);
-  border: 1px solid var(--border);
-  border-radius: 0.25rem;
-  padding: 0.75rem;
-  margin: 0;
-  overflow-x: auto;
-  white-space: pre-wrap;
-  word-break: break-word;
-  color: var(--text-secondary);
+  color: var(--text-primary);
+}
+
+.patch-tool-btn-secondary:hover {
+  background: var(--bg-secondary);
 }
 
 /* Screenshot Tool */