@@ -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,
}
}
@@ -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}
/>
);
}
@@ -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}
+ />
);
}
@@ -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>
);
}
@@ -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 */