tfrere HF Staff commited on
Commit
b1c196f
·
1 Parent(s): ed2309d

feat(editor): live-stream file contents into Monaco during write_file

Browse files

Mirror partial `input.content` from streaming `write_file` tool calls
into a dedicated overlay map, so users see the file fill up line by
line (v0/Lovable-style) instead of just a char counter that can plateau
during model token stalls.

- useAgentChat: derive `streamingFiles` Map from chat.messages, keyed
by path. Signature-based dedup avoids re-renders when content length
is unchanged.
- CodeEditor: merge committed files + streaming overlays; auto-follow
the streaming file unless the user manually picked another;
auto-scroll Monaco to the tail on each chunk; readOnly while
streaming; pulsing dot in the file tree.
- App: auto-switch to Code tab when a stream starts; pulsing dot on
the Code tab; no auto-return to Preview (user chooses).
- Preview iframe is untouched: overlays never enter the VFS, so
`vfs.version` only bumps at tool completion, avoiding rebuilds with
syntactically broken intermediate code.

Made-with: Cursor

frontend/src/App.tsx CHANGED
@@ -41,6 +41,20 @@ export function App() {
41
  diagnostics,
42
  });
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  const previewStatus = deriveAgentStatus(
45
  chat.messages,
46
  chat.isLoading,
@@ -149,10 +163,19 @@ export function App() {
149
  <div className="tab-bar">
150
  <button
151
  type="button"
152
- className={`tab ${activeTab === "code" ? "active" : ""}`}
 
 
153
  onClick={() => setActiveTab("code")}
154
  >
155
  Code · {vfs.files.length}
 
 
 
 
 
 
 
156
  </button>
157
  <button
158
  type="button"
@@ -167,7 +190,11 @@ export function App() {
167
  <div
168
  className={`pane-content ${activeTab === "code" ? "" : "hidden"}`}
169
  >
170
- <CodeEditor files={vfs.files} onChange={handleFileChange} />
 
 
 
 
171
  </div>
172
  <div
173
  className={`pane-content preview-pane ${activeTab === "preview" ? "" : "hidden"}`}
 
41
  diagnostics,
42
  });
43
 
44
+ // When the agent starts streaming a file, flip to the Code tab so
45
+ // the user sees the content appear live (v0/Lovable-style). We
46
+ // don't auto-flip BACK to Preview at the end - let the user choose
47
+ // when they want to see the built app, so they can keep reading
48
+ // the code if they want to.
49
+ const wasStreamingRef = useRef(false);
50
+ useEffect(() => {
51
+ const isStreaming = chat.streamingFiles.size > 0;
52
+ if (isStreaming && !wasStreamingRef.current) {
53
+ setActiveTab("code");
54
+ }
55
+ wasStreamingRef.current = isStreaming;
56
+ }, [chat.streamingFiles]);
57
+
58
  const previewStatus = deriveAgentStatus(
59
  chat.messages,
60
  chat.isLoading,
 
163
  <div className="tab-bar">
164
  <button
165
  type="button"
166
+ className={`tab ${activeTab === "code" ? "active" : ""} ${
167
+ chat.streamingFiles.size > 0 ? "tab-streaming" : ""
168
+ }`}
169
  onClick={() => setActiveTab("code")}
170
  >
171
  Code · {vfs.files.length}
172
+ {chat.streamingFiles.size > 0 && (
173
+ <span
174
+ className="tab-streaming-dot"
175
+ aria-label="writing"
176
+ title="Agent is writing a file"
177
+ />
178
+ )}
179
  </button>
180
  <button
181
  type="button"
 
190
  <div
191
  className={`pane-content ${activeTab === "code" ? "" : "hidden"}`}
192
  >
193
+ <CodeEditor
194
+ files={vfs.files}
195
+ onChange={handleFileChange}
196
+ streamingFiles={chat.streamingFiles}
197
+ />
198
  </div>
199
  <div
200
  className={`pane-content preview-pane ${activeTab === "preview" ? "" : "hidden"}`}
frontend/src/components/CodeEditor.tsx CHANGED
@@ -1,10 +1,21 @@
1
- import Editor from "@monaco-editor/react";
2
- import { useEffect, useMemo, useState } from "react";
3
  import type { VirtualFile } from "../hooks/useVirtualFS";
4
 
 
 
5
  interface CodeEditorProps {
6
  files: VirtualFile[];
7
  onChange: (path: string, content: string) => void;
 
 
 
 
 
 
 
 
 
8
  }
9
 
10
  function guessLanguage(path: string): string {
@@ -34,28 +45,104 @@ function guessLanguage(path: string): string {
34
  }
35
  }
36
 
37
- export function CodeEditor({ files, onChange }: CodeEditorProps) {
 
 
 
 
 
 
 
 
 
 
38
  const [activePath, setActivePath] = useState<string | null>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
 
40
  useEffect(() => {
41
- if (!activePath && files.length > 0) {
42
- const preferred = files.find(
43
- (f) => f.path === "index.html" || f.path === "src/main.ts",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  );
45
- setActivePath(preferred?.path ?? files[0].path);
46
  return;
47
  }
48
- if (activePath && !files.some((f) => f.path === activePath)) {
49
- setActivePath(files[0]?.path ?? null);
50
  }
51
- }, [files, activePath]);
52
 
53
  const activeFile = useMemo(
54
- () => files.find((f) => f.path === activePath) ?? null,
55
- [files, activePath],
56
  );
57
 
58
- if (files.length === 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  return (
60
  <div className="code-layout">
61
  <div className="file-tree">
@@ -73,14 +160,27 @@ export function CodeEditor({ files, onChange }: CodeEditorProps) {
73
  return (
74
  <div className="code-layout">
75
  <div className="file-tree">
76
- {files.map((f) => (
77
  <div
78
  key={f.path}
79
- className={`file-tree-item ${activePath === f.path ? "active" : ""}`}
80
- onClick={() => setActivePath(f.path)}
 
 
 
 
 
81
  >
82
- <span>{f.path}</span>
83
- <span className="file-tree-size">{f.content.length}B</span>
 
 
 
 
 
 
 
 
84
  </div>
85
  ))}
86
  </div>
@@ -92,7 +192,11 @@ export function CodeEditor({ files, onChange }: CodeEditorProps) {
92
  theme="vs-dark"
93
  language={guessLanguage(activeFile.path)}
94
  value={activeFile.content}
95
- onChange={(v) => onChange(activeFile.path, v ?? "")}
 
 
 
 
96
  options={{
97
  fontSize: 12,
98
  fontFamily:
@@ -102,6 +206,7 @@ export function CodeEditor({ files, onChange }: CodeEditorProps) {
102
  tabSize: 2,
103
  wordWrap: "on",
104
  automaticLayout: true,
 
105
  }}
106
  />
107
  ) : (
 
1
+ import Editor, { type OnMount } from "@monaco-editor/react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
  import type { VirtualFile } from "../hooks/useVirtualFS";
4
 
5
+ type MonacoEditorInstance = Parameters<OnMount>[0];
6
+
7
  interface CodeEditorProps {
8
  files: VirtualFile[];
9
  onChange: (path: string, content: string) => void;
10
+ /**
11
+ * Partial file contents streamed from the agent while a `write_file`
12
+ * tool call is in flight (`input-streaming` state). Keys are file
13
+ * paths, values are the growing partial content. Overlays take
14
+ * precedence over `files` for display purposes only - they are NOT
15
+ * written to the VFS (the preview stays stable until the tool
16
+ * completes and the final content is committed).
17
+ */
18
+ streamingFiles?: Map<string, string>;
19
  }
20
 
21
  function guessLanguage(path: string): string {
 
45
  }
46
  }
47
 
48
+ interface DisplayFile {
49
+ path: string;
50
+ content: string;
51
+ streaming: boolean;
52
+ }
53
+
54
+ export function CodeEditor({
55
+ files,
56
+ onChange,
57
+ streamingFiles,
58
+ }: CodeEditorProps) {
59
  const [activePath, setActivePath] = useState<string | null>(null);
60
+ // Remember the path the user manually picked so we don't rip focus
61
+ // away from it on every render. Only auto-follow the streaming file
62
+ // when the user hasn't made a manual selection yet, OR when a brand
63
+ // new file starts streaming (that's the interesting signal: "the
64
+ // agent just created something new, show me").
65
+ const userPickedRef = useRef(false);
66
+ const lastStreamingKeysRef = useRef<string>("");
67
+
68
+ // Build the merged file list: committed files + streaming overlays
69
+ // that aren't yet in the VFS. Overlays override committed content
70
+ // for the same path.
71
+ const displayFiles: DisplayFile[] = useMemo(() => {
72
+ const byPath = new Map<string, DisplayFile>();
73
+ for (const f of files) {
74
+ byPath.set(f.path, { path: f.path, content: f.content, streaming: false });
75
+ }
76
+ if (streamingFiles) {
77
+ for (const [path, content] of streamingFiles) {
78
+ byPath.set(path, { path, content, streaming: true });
79
+ }
80
+ }
81
+ return Array.from(byPath.values()).sort((a, b) =>
82
+ a.path.localeCompare(b.path),
83
+ );
84
+ }, [files, streamingFiles]);
85
 
86
+ // Auto-follow logic.
87
  useEffect(() => {
88
+ const streamingPaths = streamingFiles ? [...streamingFiles.keys()] : [];
89
+ const key = streamingPaths.sort().join("|");
90
+ const streamingStarted =
91
+ streamingPaths.length > 0 && key !== lastStreamingKeysRef.current;
92
+ lastStreamingKeysRef.current = key;
93
+
94
+ if (streamingPaths.length > 0) {
95
+ // A file is actively streaming. Switch to it unless the user
96
+ // just manually picked a different file AND nothing new started
97
+ // streaming in this tick.
98
+ const target = streamingPaths[0];
99
+ if (activePath !== target && (streamingStarted || !userPickedRef.current)) {
100
+ setActivePath(target);
101
+ userPickedRef.current = false;
102
+ }
103
+ return;
104
+ }
105
+
106
+ // No streaming in flight. Pick a sensible default if nothing is
107
+ // active, or recover if the active file disappeared.
108
+ if (!activePath && displayFiles.length > 0) {
109
+ const preferred = displayFiles.find(
110
+ (f) => f.path === "index.html" || f.path === "main.js",
111
  );
112
+ setActivePath(preferred?.path ?? displayFiles[0].path);
113
  return;
114
  }
115
+ if (activePath && !displayFiles.some((f) => f.path === activePath)) {
116
+ setActivePath(displayFiles[0]?.path ?? null);
117
  }
118
+ }, [displayFiles, activePath, streamingFiles]);
119
 
120
  const activeFile = useMemo(
121
+ () => displayFiles.find((f) => f.path === activePath) ?? null,
122
+ [displayFiles, activePath],
123
  );
124
 
125
+ // Auto-scroll Monaco to the tail of the streaming content so new
126
+ // lines appear at the bottom as they arrive, mimicking a terminal
127
+ // "tail -f". When the user scrolls up manually, `revealLine` would
128
+ // fight them - we only auto-reveal while the file is streaming.
129
+ const monacoRef = useRef<MonacoEditorInstance | null>(null);
130
+ const handleMount: OnMount = (editor) => {
131
+ monacoRef.current = editor;
132
+ };
133
+ useEffect(() => {
134
+ if (!activeFile?.streaming) return;
135
+ const editor = monacoRef.current;
136
+ if (!editor) return;
137
+ const model = editor.getModel();
138
+ if (!model) return;
139
+ const lastLine = model.getLineCount();
140
+ // `revealLine` with the `Smooth` scroll type is cheap (no-op when
141
+ // already at the bottom) and doesn't move the cursor.
142
+ editor.revealLine(lastLine);
143
+ }, [activeFile?.content, activeFile?.streaming]);
144
+
145
+ if (displayFiles.length === 0) {
146
  return (
147
  <div className="code-layout">
148
  <div className="file-tree">
 
160
  return (
161
  <div className="code-layout">
162
  <div className="file-tree">
163
+ {displayFiles.map((f) => (
164
  <div
165
  key={f.path}
166
+ className={`file-tree-item ${activePath === f.path ? "active" : ""} ${
167
+ f.streaming ? "streaming" : ""
168
+ }`}
169
+ onClick={() => {
170
+ setActivePath(f.path);
171
+ userPickedRef.current = true;
172
+ }}
173
  >
174
+ <span className="file-tree-path">
175
+ {f.streaming && (
176
+ <span className="file-tree-spinner" aria-hidden="true" />
177
+ )}
178
+ {f.path}
179
+ </span>
180
+ <span className="file-tree-size">
181
+ {f.content.length.toLocaleString()}
182
+ {f.streaming ? "…" : "B"}
183
+ </span>
184
  </div>
185
  ))}
186
  </div>
 
192
  theme="vs-dark"
193
  language={guessLanguage(activeFile.path)}
194
  value={activeFile.content}
195
+ onChange={(v) => {
196
+ if (activeFile.streaming) return;
197
+ onChange(activeFile.path, v ?? "");
198
+ }}
199
+ onMount={handleMount}
200
  options={{
201
  fontSize: 12,
202
  fontFamily:
 
206
  tabSize: 2,
207
  wordWrap: "on",
208
  automaticLayout: true,
209
+ readOnly: activeFile.streaming,
210
  }}
211
  />
212
  ) : (
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -488,6 +488,67 @@ export function useAgentChat({
488
  // the auto-fix timer callback both read it to decide when to act.
489
  chatStatusRef.current = chat.status;
490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
  // Auto-fix loop: when the agent finishes a turn, wait for the preview
492
  // to settle and check for actionable runtime/compile errors. If any
493
  // remain, push an auto-fix prompt onto the dispatch queue. Stops
@@ -614,5 +675,6 @@ export function useAgentChat({
614
  mode,
615
  setMode,
616
  autoFix,
 
617
  };
618
  }
 
488
  // the auto-fix timer callback both read it to decide when to act.
489
  chatStatusRef.current = chat.status;
490
 
491
+ // ------------------------------------------------------------------
492
+ // Streaming file overlays (v0/Lovable-style "watch the code appear")
493
+ // ------------------------------------------------------------------
494
+ //
495
+ // While the model streams a `write_file` tool call, the SDK exposes
496
+ // the partial `input.content` as it's incrementally parsed from the
497
+ // JSON payload. We mirror that partial content into a dedicated
498
+ // overlay map so the code editor can show the file filling up live.
499
+ //
500
+ // The overlay is NOT merged into the VFS - that would re-trigger
501
+ // the preview iframe rebuild on every chunk with syntactically
502
+ // broken code, which spams compile errors. Only the final
503
+ // `input-available` state fires `onToolCall`, which commits to the
504
+ // VFS once and triggers exactly one preview rebuild.
505
+ //
506
+ // Ref-then-state pattern: we keep the map in a ref (single source of
507
+ // truth, stable JSON-stringified signature) and only call
508
+ // `setStreamingFiles` when the signature actually changes, so we
509
+ // don't thrash React on every identical re-derivation.
510
+
511
+ const [streamingFiles, setStreamingFiles] = useState<Map<string, string>>(
512
+ () => new Map(),
513
+ );
514
+ const streamingSignatureRef = useRef<string>("");
515
+
516
+ useEffect(() => {
517
+ const next = new Map<string, string>();
518
+ for (const msg of chat.messages) {
519
+ if (msg.role !== "assistant") continue;
520
+ for (const part of msg.parts ?? []) {
521
+ const p = part as {
522
+ type: string;
523
+ state?: string;
524
+ input?: Record<string, unknown>;
525
+ };
526
+ if (typeof p.type !== "string" || !p.type.startsWith("tool-")) {
527
+ continue;
528
+ }
529
+ if (p.state !== "input-streaming") continue;
530
+ const toolName = p.type.slice("tool-".length);
531
+ if (toolName !== "write_file") continue;
532
+ const path = p.input?.path;
533
+ const content = p.input?.content;
534
+ if (typeof path === "string" && path && typeof content === "string") {
535
+ next.set(path, content);
536
+ }
537
+ }
538
+ }
539
+
540
+ // Stable signature: sorted `path:length` pairs. Length is enough
541
+ // because content is append-only during streaming, so a changed
542
+ // length always means a changed suffix.
543
+ const sig = [...next.entries()]
544
+ .map(([k, v]) => `${k}:${v.length}`)
545
+ .sort()
546
+ .join("|");
547
+ if (sig === streamingSignatureRef.current) return;
548
+ streamingSignatureRef.current = sig;
549
+ setStreamingFiles(next);
550
+ }, [chat.messages]);
551
+
552
  // Auto-fix loop: when the agent finishes a turn, wait for the preview
553
  // to settle and check for actionable runtime/compile errors. If any
554
  // remain, push an auto-fix prompt onto the dispatch queue. Stops
 
675
  mode,
676
  setMode,
677
  autoFix,
678
+ streamingFiles,
679
  };
680
  }
frontend/src/styles.css CHANGED
@@ -966,12 +966,59 @@ pre,
966
  border-left-color: var(--accent);
967
  }
968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
  .file-tree-size {
970
  margin-left: auto;
971
  color: var(--text-faint);
972
  font-size: 10px;
973
  }
974
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
  .editor-area {
976
  min-height: 0;
977
  display: flex;
 
966
  border-left-color: var(--accent);
967
  }
968
 
969
+ .file-tree-item.streaming {
970
+ color: var(--text);
971
+ }
972
+
973
+ .file-tree-item.streaming .file-tree-size {
974
+ color: var(--accent);
975
+ }
976
+
977
+ .file-tree-path {
978
+ display: inline-flex;
979
+ align-items: center;
980
+ gap: 6px;
981
+ min-width: 0;
982
+ overflow: hidden;
983
+ text-overflow: ellipsis;
984
+ white-space: nowrap;
985
+ }
986
+
987
+ .file-tree-spinner {
988
+ width: 8px;
989
+ height: 8px;
990
+ border-radius: 50%;
991
+ background: var(--accent);
992
+ animation: file-tree-pulse 1s ease-in-out infinite;
993
+ flex-shrink: 0;
994
+ }
995
+
996
+ @keyframes file-tree-pulse {
997
+ 0%, 100% { opacity: 0.35; transform: scale(0.8); }
998
+ 50% { opacity: 1; transform: scale(1.15); }
999
+ }
1000
+
1001
  .file-tree-size {
1002
  margin-left: auto;
1003
  color: var(--text-faint);
1004
  font-size: 10px;
1005
  }
1006
 
1007
+ .tab-streaming {
1008
+ position: relative;
1009
+ }
1010
+
1011
+ .tab-streaming-dot {
1012
+ display: inline-block;
1013
+ width: 6px;
1014
+ height: 6px;
1015
+ border-radius: 50%;
1016
+ background: var(--accent);
1017
+ margin-left: 6px;
1018
+ vertical-align: middle;
1019
+ animation: file-tree-pulse 1s ease-in-out infinite;
1020
+ }
1021
+
1022
  .editor-area {
1023
  min-height: 0;
1024
  display: flex;