File size: 8,654 Bytes
b1c196f
 
d5079dd
 
b1c196f
 
d5079dd
 
 
b1c196f
 
 
 
 
 
 
 
 
d9f7bbf
 
 
 
 
 
 
 
 
d5079dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1c196f
 
 
 
 
 
 
 
 
 
d9f7bbf
b1c196f
d5079dd
b1c196f
d9f7bbf
 
 
 
b1c196f
 
d9f7bbf
b1c196f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5079dd
b1c196f
d9f7bbf
 
 
 
 
 
 
 
 
d5079dd
b1c196f
 
 
 
 
 
 
 
 
 
 
 
d9f7bbf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1c196f
 
 
d9f7bbf
b1c196f
 
 
d5079dd
b1c196f
d5079dd
 
b1c196f
 
d5079dd
d9f7bbf
d5079dd
 
b1c196f
 
d5079dd
 
b1c196f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5079dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b1c196f
d5079dd
 
b1c196f
 
 
 
 
 
 
d5079dd
b1c196f
 
 
 
 
 
 
 
 
 
d5079dd
 
 
 
 
 
 
 
 
 
 
b1c196f
 
 
 
 
d5079dd
 
 
 
 
 
 
 
 
b1c196f
d5079dd
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import Editor, { type OnMount } from "@monaco-editor/react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { VirtualFile } from "../hooks/useVirtualFS";

type MonacoEditorInstance = Parameters<OnMount>[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<string, string>;
  /**
   * 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<string | null>(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<string>("");
  const lastFocusedPathRef = useRef<string | null>(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<string, DisplayFile>();
    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<MonacoEditorInstance | null>(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 (
      <div className="code-layout">
        <div className="file-tree">
          <div className="file-tree-empty">No files yet.</div>
        </div>
        <div className="editor-area">
          <div className="editor-placeholder">
            The agent will write files here. Ask it to create an app.
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="code-layout">
      <div className="file-tree">
        {displayFiles.map((f) => (
          <div
            key={f.path}
            className={`file-tree-item ${activePath === f.path ? "active" : ""} ${
              f.streaming ? "streaming" : ""
            }`}
            onClick={() => {
              setActivePath(f.path);
              userPickedRef.current = true;
            }}
          >
            <span className="file-tree-path">
              {f.streaming && (
                <span className="file-tree-spinner" aria-hidden="true" />
              )}
              {f.path}
            </span>
            <span className="file-tree-size">
              {f.content.length.toLocaleString()}
              {f.streaming ? "…" : "B"}
            </span>
          </div>
        ))}
      </div>
      <div className="editor-area">
        {activeFile ? (
          <Editor
            key={activeFile.path}
            height="100%"
            theme="vs-dark"
            language={guessLanguage(activeFile.path)}
            value={activeFile.content}
            onChange={(v) => {
              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,
            }}
          />
        ) : (
          <div className="editor-placeholder">Select a file from the tree.</div>
        )}
      </div>
    </div>
  );
}