Spaces:
Runtime error
feat(editor): live-stream file contents into Monaco during write_file
Browse filesMirror 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 +29 -2
- frontend/src/components/CodeEditor.tsx +124 -19
- frontend/src/hooks/useAgentChat.ts +62 -0
- frontend/src/styles.css +47 -0
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"}`}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const [activePath, setActivePath] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
| 40 |
useEffect(() => {
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
);
|
| 45 |
-
setActivePath(preferred?.path ??
|
| 46 |
return;
|
| 47 |
}
|
| 48 |
-
if (activePath && !
|
| 49 |
-
setActivePath(
|
| 50 |
}
|
| 51 |
-
}, [
|
| 52 |
|
| 53 |
const activeFile = useMemo(
|
| 54 |
-
() =>
|
| 55 |
-
[
|
| 56 |
);
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 77 |
<div
|
| 78 |
key={f.path}
|
| 79 |
-
className={`file-tree-item ${activePath === f.path ? "active" : ""}
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
>
|
| 82 |
-
<span
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
) : (
|
|
@@ -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 |
}
|
|
@@ -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;
|