import Editor, { type OnMount } from "@monaco-editor/react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { VirtualFile } from "../hooks/useVirtualFS"; type MonacoEditorInstance = Parameters[0]; interface CodeEditorProps { files: VirtualFile[]; onChange: (path: string, content: string) => void; /** * Partial file contents streamed from the agent while a `write_file` * tool call is in flight (`input-streaming` state). Keys are file * paths, values are the growing partial content. Overlays take * precedence over `files` for display purposes only - they are NOT * written to the VFS (the preview stays stable until the tool * completes and the final content is committed). */ streamingFiles?: Map; /** * Path of the file the agent is currently attending to via any * file-scoped tool (write_file streaming, edit_file, read_file, * delete_file). When this changes, the editor auto-switches to * that tab so the user can see what the agent is doing - covers * atomic edits that don't stream content, which `streamingFiles` * alone would miss. */ focusedFilePath?: string | null; } function guessLanguage(path: string): string { const ext = path.split(".").pop()?.toLowerCase() ?? ""; switch (ext) { case "ts": case "tsx": return "typescript"; case "js": case "jsx": return "javascript"; case "html": return "html"; case "css": return "css"; case "json": return "json"; case "md": return "markdown"; case "yml": case "yaml": return "yaml"; case "sh": return "shell"; default: return "plaintext"; } } interface DisplayFile { path: string; content: string; streaming: boolean; } export function CodeEditor({ files, onChange, streamingFiles, focusedFilePath, }: CodeEditorProps) { const [activePath, setActivePath] = useState(null); // Remember the path the user manually picked so we don't rip focus // away from it on every render. Only auto-follow the agent's focus // when the user hasn't made a manual selection yet, OR when the // agent moves to a different file (that's the interesting signal: // "the agent just switched its attention, show me"). const userPickedRef = useRef(false); const lastStreamingKeysRef = useRef(""); const lastFocusedPathRef = useRef(null); // Build the merged file list: committed files + streaming overlays // that aren't yet in the VFS. Overlays override committed content // for the same path. const displayFiles: DisplayFile[] = useMemo(() => { const byPath = new Map(); for (const f of files) { byPath.set(f.path, { path: f.path, content: f.content, streaming: false }); } if (streamingFiles) { for (const [path, content] of streamingFiles) { byPath.set(path, { path, content, streaming: true }); } } return Array.from(byPath.values()).sort((a, b) => a.path.localeCompare(b.path), ); }, [files, streamingFiles]); // Auto-follow logic. // // Priority order: // 1. A file is actively streaming (`write_file` in flight) - always // win, that's the most time-sensitive signal. // 2. The agent just focused a different file via a non-streaming // tool (`edit_file`, `read_file`, `delete_file`) - switch so // the user sees the edit happen / knows what the agent read. // 3. Otherwise, respect the user's manual selection or pick a // sensible default when nothing is active. useEffect(() => { const streamingPaths = streamingFiles ? [...streamingFiles.keys()] : []; const key = streamingPaths.sort().join("|"); const streamingStarted = streamingPaths.length > 0 && key !== lastStreamingKeysRef.current; lastStreamingKeysRef.current = key; if (streamingPaths.length > 0) { const target = streamingPaths[0]; if (activePath !== target && (streamingStarted || !userPickedRef.current)) { setActivePath(target); userPickedRef.current = false; } // Keep the focused-path ref in sync so we don't re-trigger when // streaming ends and the same path stays "focused". lastFocusedPathRef.current = focusedFilePath ?? null; return; } // No streaming. Did the agent just focus a new file? const focusChanged = focusedFilePath != null && focusedFilePath !== lastFocusedPathRef.current; lastFocusedPathRef.current = focusedFilePath ?? null; if ( focusChanged && focusedFilePath && displayFiles.some((f) => f.path === focusedFilePath) && activePath !== focusedFilePath ) { setActivePath(focusedFilePath); // Agent-driven focus overrides any prior manual pick. userPickedRef.current = false; return; } // Fallback: default selection + recovery. if (!activePath && displayFiles.length > 0) { const preferred = displayFiles.find( (f) => f.path === "index.html" || f.path === "main.js", ); setActivePath(preferred?.path ?? displayFiles[0].path); return; } if (activePath && !displayFiles.some((f) => f.path === activePath)) { setActivePath(displayFiles[0]?.path ?? null); } }, [displayFiles, activePath, streamingFiles, focusedFilePath]); const activeFile = useMemo( () => displayFiles.find((f) => f.path === activePath) ?? null, [displayFiles, activePath], ); // Auto-scroll Monaco to the tail of the streaming content so new // lines appear at the bottom as they arrive, mimicking a terminal // "tail -f". When the user scrolls up manually, `revealLine` would // fight them - we only auto-reveal while the file is streaming. const monacoRef = useRef(null); const handleMount: OnMount = (editor) => { monacoRef.current = editor; }; useEffect(() => { if (!activeFile?.streaming) return; const editor = monacoRef.current; if (!editor) return; const model = editor.getModel(); if (!model) return; const lastLine = model.getLineCount(); // `revealLine` with the `Smooth` scroll type is cheap (no-op when // already at the bottom) and doesn't move the cursor. editor.revealLine(lastLine); }, [activeFile?.content, activeFile?.streaming]); if (displayFiles.length === 0) { return (
No files yet.
The agent will write files here. Ask it to create an app.
); } return (
{displayFiles.map((f) => (
{ setActivePath(f.path); userPickedRef.current = true; }} > {f.streaming && ( {f.content.length.toLocaleString()} {f.streaming ? "…" : "B"}
))}
{activeFile ? ( { if (activeFile.streaming) return; onChange(activeFile.path, v ?? ""); }} onMount={handleMount} options={{ fontSize: 12, fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace', minimap: { enabled: false }, scrollBeyondLastLine: false, tabSize: 2, wordWrap: "on", automaticLayout: true, readOnly: activeFile.streaming, }} /> ) : (
Select a file from the tree.
)}
); }