diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 64da2c8710..a40bfc94c2 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -153,9 +153,9 @@ Supported commands: | `list_mcp_servers` | Direct file I/O | | `upsert_mcp_server` | Direct file I/O | | `delete_mcp_server` | Direct file I/O | -| `get_git_branch` | `execFileSync("git", ...)` | -| `list_git_branches` | `execFileSync("git", ...)` | -| `checkout_git_branch` | `execFileSync("git", ...)` | +| `get_git_branch` | async `execFile("git", ...)` | +| `list_git_branches` | async `execFile("git", ...)` | +| `checkout_git_branch` | async `execFile("git", ...)` | | `search_workspace_files` | `getFileIndex` | | `get_process_context` | In-memory context | | `poll_tool_approvals` | In-memory pending map | diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 740cbaffd3..de5fb75b30 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { existsSync, readdirSync, @@ -8,6 +8,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, join } from "node:path"; +import { promisify } from "node:util"; import type { ClineAccountActionRequest, ProviderCapability, @@ -92,6 +93,12 @@ import type { SidecarContext, } from "./types"; +// All child processes in this module run asynchronously: the sidecar is a +// single event loop shared by every UI command and streaming chat session, so +// a synchronous exec (git, folder picker, editor discovery) freezes the whole +// app until the child exits. +const execFileAsync = promisify(execFile); + // Strict allowlist: the opener hands the URL to the OS protocol handler, so // anything broader (file:, custom app schemes) would let webview content // launch arbitrary local handlers. @@ -476,40 +483,31 @@ async function listSessionsFromSidecarManager( // Git helpers // --------------------------------------------------------------------------- -function listGitBranches( +async function listGitBranches( ctx: SidecarContext, cwd?: string, -): { current?: string; branches?: string[] } { +): Promise<{ current?: string; branches?: string[] }> { const targetCwd = cwd?.trim() || ctx.workspaceRoot; - const current = (() => { - try { - return execFileSync("git", ["branch", "--show-current"], { - cwd: targetCwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return ""; - } - })(); - try { - const stdout = execFileSync( + const [currentResult, branchesResult] = await Promise.all([ + execFileAsync("git", ["branch", "--show-current"], { + cwd: targetCwd, + encoding: "utf8", + }).catch(() => undefined), + execFileAsync( "git", ["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd: targetCwd, encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], }, - ); - const branches = stdout - .split("\n") - .map((v) => v.trim()) - .filter(Boolean); - return { current: current || undefined, branches }; - } catch { - return { current: current || undefined, branches: [] }; - } + ).catch(() => undefined), + ]); + const current = currentResult?.stdout.trim() ?? ""; + const branches = (branchesResult?.stdout ?? "") + .split("\n") + .map((v) => v.trim()) + .filter(Boolean); + return { current: current || undefined, branches }; } // --------------------------------------------------------------------------- @@ -897,11 +895,14 @@ async function listUserInstructionConfigs( // Native OS commands // --------------------------------------------------------------------------- -function pickWorkspaceDirectory(): string | null { +// Async is load-bearing here: the native picker blocks until the user chooses +// a folder, and a synchronous exec would freeze every other sidecar command +// (chat streams, history, settings) for however long the dialog stays open. +async function pickWorkspaceDirectory(): Promise { const platform = process.platform; if (platform === "darwin") { try { - const result = execFileSync( + const { stdout } = await execFileAsync( "osascript", [ "-e", @@ -909,21 +910,21 @@ function pickWorkspaceDirectory(): string | null { "-e", "return POSIX path of theFolder", ], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ).trim(); - return result || null; + { encoding: "utf8" }, + ); + return stdout.trim() || null; } catch { return null; } } // Linux — try zenity try { - const result = execFileSync( + const { stdout } = await execFileAsync( "zenity", ["--file-selection", "--directory", "--title=Select workspace directory"], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ).trim(); - return result || null; + { encoding: "utf8" }, + ); + return stdout.trim() || null; } catch { return null; } @@ -984,12 +985,11 @@ const CODE_EDITOR_CATALOG: readonly CodeEditorDefinition[] = [ { id: "xcode", label: "Xcode", cli: "xed", macApps: ["Xcode"] }, ]; -function findExecutableOnPath(name: string): string | null { +async function findExecutableOnPath(name: string): Promise { try { const locator = process.platform === "win32" ? "where" : "which"; - const stdout = execFileSync(locator, [name], { + const { stdout } = await execFileAsync(locator, [name], { encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], }); return stdout.split("\n")[0]?.trim() || null; } catch { @@ -1009,13 +1009,38 @@ function isMacAppInstalled(app: string): boolean { ); } +// Installed editors change rarely; cache briefly so composer UI refreshes do +// not re-run a batch of `which` lookups on every open. +const EDITOR_CATALOG_CACHE_TTL_MS = 60_000; +let editorCatalogCache: { + fetchedAt: number; + editors: Array<{ id: string; label: string }>; +} | null = null; + /** Editors the current machine can actually launch, in catalog order. */ -function listAvailableCodeEditors(): Array<{ id: string; label: string }> { - return CODE_EDITOR_CATALOG.filter( - (editor) => - findExecutableOnPath(editor.cli) !== null || - (process.platform === "darwin" && editor.macApps.some(isMacAppInstalled)), +async function listAvailableCodeEditors(): Promise< + Array<{ id: string; label: string }> +> { + const now = Date.now(); + if ( + editorCatalogCache && + now - editorCatalogCache.fetchedAt < EDITOR_CATALOG_CACHE_TTL_MS + ) { + return editorCatalogCache.editors; + } + const availability = await Promise.all( + CODE_EDITOR_CATALOG.map( + async (editor) => + (await findExecutableOnPath(editor.cli)) !== null || + (process.platform === "darwin" && + editor.macApps.some(isMacAppInstalled)), + ), + ); + const editors = CODE_EDITOR_CATALOG.filter( + (_, index) => availability[index], ).map(({ id, label }) => ({ id, label })); + editorCatalogCache = { fetchedAt: now, editors }; + return editors; } /** Launches `executable filePath` detached; false if the CLI is unusable. */ @@ -1046,11 +1071,9 @@ function launchEditorCli(executable: string, filePath: string): boolean { return true; } -function launchMacApp(app: string, filePath: string): boolean { +async function launchMacApp(app: string, filePath: string): Promise { try { - execFileSync("open", ["-a", app, filePath], { - stdio: ["ignore", "ignore", "ignore"], - }); + await execFileAsync("open", ["-a", app, filePath]); return true; } catch { return false; @@ -1058,7 +1081,10 @@ function launchMacApp(app: string, filePath: string): boolean { } /** Returns the launcher that handled the file, for logging/UI feedback. */ -function openFileInCodeEditor(filePath: string, editorId?: string): string { +async function openFileInCodeEditor( + filePath: string, + editorId?: string, +): Promise { if ( process.platform === "win32" && WINDOWS_CMD_UNSAFE_PATTERN.test(filePath) @@ -1072,32 +1098,32 @@ function openFileInCodeEditor(filePath: string, editorId?: string): string { if (!editor) { throw new Error(`Unknown editor: ${editorId}`); } - const executable = findExecutableOnPath(editor.cli); + const executable = await findExecutableOnPath(editor.cli); if (executable && launchEditorCli(executable, filePath)) { return editor.label; } - if ( - process.platform === "darwin" && - editor.macApps.some((app) => launchMacApp(app, filePath)) - ) { - return editor.label; + if (process.platform === "darwin") { + for (const app of editor.macApps) { + if (await launchMacApp(app, filePath)) { + return editor.label; + } + } } throw new Error(`${editor.label} is not available on this machine`); } if (!editorId) { for (const editor of CODE_EDITOR_CATALOG) { - const executable = findExecutableOnPath(editor.cli); + const executable = await findExecutableOnPath(editor.cli); if (executable && launchEditorCli(executable, filePath)) { return editor.cli; } } if (process.platform === "darwin") { for (const editor of CODE_EDITOR_CATALOG) { - const app = editor.macApps.find((candidate) => - launchMacApp(candidate, filePath), - ); - if (app) { - return app; + for (const candidate of editor.macApps) { + if (await launchMacApp(candidate, filePath)) { + return candidate; + } } } } @@ -1613,13 +1639,13 @@ export async function handleCommand( typeof args?.cwd === "string" && args.cwd.trim() ? args.cwd.trim() : ctx.workspaceRoot; - const branches = listGitBranches(ctx, cwd); + const branches = await listGitBranches(ctx, cwd); const { prewarmWorkspaceMetadata } = await import("./chat-session"); prewarmWorkspaceMetadata(cwd); return { branch: branches.current }; } if (command === "list_git_branches") { - return listGitBranches( + return await listGitBranches( ctx, typeof args?.cwd === "string" ? args.cwd : undefined, ); @@ -1629,10 +1655,9 @@ export async function handleCommand( const branch = String(args?.branch ?? "").trim(); if (!branch) throw new Error("branch is required"); const targetCwd = cwd?.trim() || ctx.workspaceRoot; - execFileSync("git", ["checkout", branch], { + await execFileAsync("git", ["checkout", branch], { cwd: targetCwd, encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], }); const { refreshWorkspaceMetadata } = await import("./chat-session"); refreshWorkspaceMetadata(targetCwd); @@ -1715,7 +1740,7 @@ export async function handleCommand( } } if (command === "pick_workspace_directory") { - return pickWorkspaceDirectory(); + return await pickWorkspaceDirectory(); } if (command === "open_mcp_settings_file") { const path = ensureMcpSettingsFile(); @@ -1723,7 +1748,7 @@ export async function handleCommand( return path; } if (command === "list_available_editors") { - return listAvailableCodeEditors(); + return await listAvailableCodeEditors(); } if (command === "open_file_in_editor") { const rawPath = String(args?.path ?? "").trim(); @@ -1740,7 +1765,7 @@ export async function handleCommand( typeof args?.editor === "string" && args.editor.trim() ? args.editor.trim() : undefined; - const editor = openFileInCodeEditor(filePath, requestedEditor); + const editor = await openFileInCodeEditor(filePath, requestedEditor); return { path: filePath, editor }; } diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index 0a3d671f9e..e70cca4259 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { appendFile, mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname } from "node:path"; import { @@ -59,6 +59,11 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void { } } +// Session log appends are chained per session so writes stay ordered, but +// they run asynchronously: a synchronous write per streamed token would stall +// the sidecar event loop (and therefore every pending UI command) under load. +const sessionLogWriteTails = new Map>(); + function appendSessionChunk( sessionId: string, stream: string, @@ -66,9 +71,21 @@ function appendSessionChunk( ts: number, ): void { const path = sessionLogPath(sessionId); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, { - flag: "a", + const line = `${JSON.stringify({ ts, stream, chunk })}\n`; + const tail = sessionLogWriteTails.get(sessionId) ?? Promise.resolve(); + const next = tail + .then(async () => { + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, line); + }) + .catch(() => { + // Session logs are best-effort diagnostics; never fail the stream. + }); + sessionLogWriteTails.set(sessionId, next); + void next.finally(() => { + if (sessionLogWriteTails.get(sessionId) === next) { + sessionLogWriteTails.delete(sessionId); + } }); } diff --git a/apps/examples/desktop-app/webview/app/globals.css b/apps/examples/desktop-app/webview/app/globals.css index 971812b09a..c91eecf027 100644 --- a/apps/examples/desktop-app/webview/app/globals.css +++ b/apps/examples/desktop-app/webview/app/globals.css @@ -20,20 +20,19 @@ } } -/* Hero heading cycling verb (components/views/chat/welcome-chat.tsx) */ +/* + * Hero heading cycling verb (components/views/chat/welcome-chat.tsx). + * Opacity + transform only: animating `filter: blur` on text forces + * main-thread repaints for every character each cycle. + */ @keyframes hero-word-in { 0% { opacity: 0; transform: translateY(0.42em); - filter: blur(6px); - } - 60% { - filter: blur(0); } 100% { opacity: 1; transform: translateY(0); - filter: blur(0); } } @@ -222,34 +221,42 @@ background: var(--card); } -/* Aurora background (components/ui/aurora-bg.tsx) */ +/* + * Aurora background (components/ui/aurora-bg.tsx). + * + * Performance contract: no `filter: blur` and no animated properties beyond + * `opacity` + `transform`, so the layers rasterize once and every animation + * frame is compositor-only. Blurred full-viewport layers used to be + * re-rendered on every frame of their drift animations, which alone dropped + * the whole app below 10fps on modest hardware. + */ @keyframes aurora-drift { 0% { opacity: 0.55; - transform: translate3d(-8%, 5%, 0) rotate(-5deg) scale(0.94); + transform: translate3d(-8%, 5%, 0) scale(0.94); } 50% { opacity: 0.92; - transform: translate3d(13%, -10%, 0) rotate(6deg) scale(1.11); + transform: translate3d(13%, -10%, 0) scale(1.11); } 100% { opacity: 0.62; - transform: translate3d(-5%, -2%, 0) rotate(-3deg) scale(1.02); + transform: translate3d(-5%, -2%, 0) scale(1.02); } } @keyframes aurora-drift-reverse { 0% { opacity: 0.62; - transform: translate3d(10%, -6%, 0) rotate(5deg) scale(1.08); + transform: translate3d(10%, -6%, 0) scale(1.08); } 50% { opacity: 0.9; - transform: translate3d(-12%, -12%, 0) rotate(-6deg) scale(0.96); + transform: translate3d(-12%, -12%, 0) scale(0.96); } 100% { opacity: 0.58; - transform: translate3d(6%, 2%, 0) rotate(3deg) scale(1.04); + transform: translate3d(6%, 2%, 0) scale(1.04); } } @@ -271,15 +278,15 @@ @keyframes aurora-current-sweep { 0% { opacity: 0.28; - transform: translate3d(-16%, 8%, 0) rotate(-8deg) scaleX(0.84); + transform: translate3d(-16%, 8%, 0) scaleX(0.84); } 50% { opacity: 0.72; - transform: translate3d(17%, -8%, 0) rotate(4deg) scaleX(1.08); + transform: translate3d(17%, -8%, 0) scaleX(1.08); } 100% { opacity: 0.38; - transform: translate3d(28%, 4%, 0) rotate(-2deg) scaleX(0.94); + transform: translate3d(28%, 4%, 0) scaleX(0.94); } } @@ -287,15 +294,34 @@ 0%, 100% { opacity: 0.15; - transform: translate3d(0, 4px, 0) rotate(0deg) scale(0.78); } 50% { opacity: 0.78; - transform: translate3d(var(--aurora-star-x, 5px), -8px, 0) rotate(35deg) - scale(1.08); } } +/* + * Static vertical fade replacing the old `filter: blur(46-64px)` on the big + * gradient bands: the mask rasterizes once with the layer, so soft edges no + * longer cost anything per animation frame. + */ +.aurora-soft-band { + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black 32%, + black 68%, + transparent + ); + mask-image: linear-gradient( + to bottom, + transparent, + black 32%, + black 68%, + transparent + ); +} + .aurora-horizon { animation: aurora-horizon-breathe 8s ease-in-out -3s infinite alternate; transform-origin: center bottom; @@ -319,6 +345,7 @@ animation-timing-function: ease-in-out; animation-iteration-count: infinite; animation-direction: alternate; + will-change: opacity, transform; } .aurora-motion-reverse { @@ -326,16 +353,11 @@ } .aurora-star { - --aurora-star-x: 5px; animation-name: aurora-twinkle; animation-timing-function: ease-in-out; animation-iteration-count: infinite; } -.aurora-star:nth-of-type(2n) { - --aurora-star-x: -6px; -} - @media (prefers-reduced-motion: reduce) { .aurora-current, .aurora-horizon, diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index f6cc5b27fe..1c1d59a4fc 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -60,6 +60,7 @@ import { markOnboardingCompleted, ONBOARDING_RESET_EVENT, } from "@/lib/onboarding"; +import { fetchProviderCatalog } from "@/lib/provider-model-catalog"; import { getSessionMetadataTitle, type SessionHistoryItem, @@ -266,7 +267,18 @@ export default function Home() { return ( -
+
{ + promptInputRef.current = value; + setPromptDraft((prev) => ({ version: prev.version + 1, value })); + }, []); + const handlePromptInputChange = useCallback((value: string) => { + promptInputRef.current = value; + }, []); const [pendingAttachments, setPendingAttachments] = useState([]); const [isDraggingFiles, setIsDraggingFiles] = useState(false); const dragDepthRef = useRef(0); @@ -452,13 +475,7 @@ function ChatThreadPane({ async function loadProviderCredentials() { try { - const payload = await desktopClient.invoke<{ - providers?: Array<{ - id?: string; - apiKey?: string; - baseUrl?: string; - }>; - }>("list_provider_catalog"); + const payload = await fetchProviderCatalog(); if (cancelled) { return; } @@ -731,7 +748,7 @@ function ChatThreadPane({ setPendingAttachments([]); setManualTitle(""); void reset(); - }, [historySession, manualTitle, reset, threadId]); + }, [historySession, manualTitle, reset, threadId, setPromptInput]); useEffect(() => { if (!historySession) { @@ -745,19 +762,25 @@ function ChatThreadPane({ setPendingAttachments([]); setManualTitle(getSessionMetadataTitle(historySession.metadata)); void hydrateSession(historySession); - }, [historySession, hydrateSession]); + }, [historySession, hydrateSession, setPromptInput]); - const handleSend = useCallback(async () => { - const trimmed = promptInput.trim(); - if (!trimmed && pendingAttachments.length === 0) { - return; - } - onThreadStarted?.(threadId); - setPromptInput(""); - const toSend = [...pendingAttachments]; - setPendingAttachments([]); - await sendPrompt(trimmed, toSend); - }, [onThreadStarted, pendingAttachments, promptInput, sendPrompt, threadId]); + const handleSend = useCallback( + async (prompt: string) => { + const trimmed = prompt.trim(); + if (!trimmed && pendingAttachments.length === 0) { + return; + } + onThreadStarted?.(threadId); + // Also clear the injected draft: the composer cleared its local copy, + // but a stale non-empty draft would repopulate the input if the + // composer remounts (e.g. a transport blip re-showing the loader). + setPromptInput(""); + const toSend = [...pendingAttachments]; + setPendingAttachments([]); + await sendPrompt(trimmed, toSend); + }, + [onThreadStarted, pendingAttachments, sendPrompt, setPromptInput, threadId], + ); const handleReasoningChange = useCallback( (next: Pick) => { @@ -794,11 +817,12 @@ function ChatThreadPane({ description: "Reattach files before sending the restored message.", }); } - setPromptInput((current) => + const current = promptInputRef.current; + setPromptInput( current.trim().length > 0 ? `${current}\n\n${prompt}` : prompt, ); }, - [removePromptInQueue], + [removePromptInQueue, setPromptInput], ); const handleApproveToolApproval = useCallback( (requestId: string) => { @@ -818,6 +842,12 @@ function ChatThreadPane({ }, [answerAskQuestion], ); + const handleRestoreCheckpoint = useCallback( + (runCount: number) => { + void restoreCheckpoint(runCount); + }, + [restoreCheckpoint], + ); const handleForkSession = useCallback(async () => { const result = await forkSession(); @@ -930,6 +960,7 @@ function ChatThreadPane({ onDeleteSession, reset, threadId, + setPromptInput, ]); const handleAttachFiles = useCallback((files: File[]) => { @@ -1141,7 +1172,7 @@ function ChatThreadPane({ mode: prev.mode === "plan" ? "act" : "plan", })) } - onPromptInputChange={setPromptInput} + onPromptInputChange={handlePromptInputChange} onReasoningChange={handleReasoningChange} onSteerPromptInQueue={(promptId) => { void steerPromptInQueue(promptId); @@ -1166,12 +1197,12 @@ function ChatThreadPane({ }; }) } - onSend={() => void handleSend()} + onSend={(prompt) => void handleSend(prompt)} gitBranch={gitBranch} model={config.model} mode={config.mode} promptsInQueue={promptsInQueue} - promptInput={promptInput} + promptDraft={promptDraft} provider={config.provider} reasoningEffort={config.reasoningEffort} status={status} @@ -1247,9 +1278,7 @@ function ChatThreadPane({ chatTransportState={chatTransportState} error={displayedError} messages={displayedMessages} - onRestoreCheckpoint={(runCount) => - void restoreCheckpoint(runCount) - } + onRestoreCheckpoint={handleRestoreCheckpoint} onForkSession={handleForkSession} pendingToolApprovals={pendingToolApprovals} pendingAskQuestions={pendingAskQuestions} diff --git a/apps/examples/desktop-app/webview/components/ui/aurora-bg.tsx b/apps/examples/desktop-app/webview/components/ui/aurora-bg.tsx index 7bc6d087b0..dad7fee1eb 100644 --- a/apps/examples/desktop-app/webview/components/ui/aurora-bg.tsx +++ b/apps/examples/desktop-app/webview/components/ui/aurora-bg.tsx @@ -12,13 +12,15 @@ interface Star { color: string; } -// Big blurred gradient blobs that slowly drift/rotate to fake an aurora. +// Big soft gradient blobs that slowly drift to fake an aurora. Softness is +// baked into the gradient stops (no `filter: blur`) so the layers rasterize +// once and every animation frame is compositor-only work. const BLOBS = [ { id: "periwinkle-left", position: "left-[-20%] bottom-[-40%] w-[70%] h-[80%]", gradient: - "radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), transparent 70%)", + "radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-periwinkle) 64%, transparent), color-mix(in oklab, var(--brand-periwinkle) 30%, transparent) 42%, transparent 70%)", duration: "11s", delay: "0s", reverse: false, @@ -27,7 +29,7 @@ const BLOBS = [ id: "violet-right", position: "right-[-15%] bottom-[-40%] w-[65%] h-[85%]", gradient: - "radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), transparent 70%)", + "radial-gradient(ellipse at center, color-mix(in oklab, var(--brand-violet) 58%, transparent), color-mix(in oklab, var(--brand-violet) 27%, transparent) 42%, transparent 70%)", duration: "12.5s", delay: "-12s", reverse: true, @@ -46,14 +48,20 @@ function seededUnit(index: number, salt: number): number { } /** - * A decorative aurora background built entirely from CSS: blurred gradient + * A decorative aurora background built entirely from CSS: soft gradient * blobs drifting on keyframe animations, plus twinkling star dots. No canvas, * no WebGL, no per-frame JS. Absolutely positioned to fill its nearest * positioned parent; pointer events pass through. * + * Performance contract: no `filter: blur` anywhere (soft edges come from + * gradient falloff + static masks) and animations only touch `opacity` and + * `transform`, so the whole effect stays on the compositor. Blurring these + * full-viewport layers used to force a main-thread re-raster every frame and + * dragged the entire app below 10fps on modest hardware. + * * Keyframes (`aurora-drift`, `aurora-twinkle`) live in app/globals.css. */ -export function AuroraBackground({ starCount = 48 }: { starCount?: number }) { +export function AuroraBackground({ starCount = 32 }: { starCount?: number }) { // The field is deterministic so server and browser markup always agree. const stars = useMemo( () => @@ -83,14 +91,14 @@ export function AuroraBackground({ starCount = 48 }: { starCount?: number }) { className="pointer-events-none absolute inset-0 overflow-hidden" >
(
{ onSteerPromptInQueue={vi.fn()} onSwitchGitBranch={vi.fn(async () => true)} onUndoPromptInQueue={vi.fn()} - promptInput="" + promptDraft={{ version: 0, value: "" }} promptsInQueue={[]} provider="cline" reasoningEffort="high" @@ -165,7 +165,7 @@ describe("ChatInputBar", () => { onSteerPromptInQueue={vi.fn()} onSwitchGitBranch={vi.fn(async () => true)} onUndoPromptInQueue={vi.fn()} - promptInput="" + promptDraft={{ version: 0, value: "" }} promptsInQueue={[]} provider="cline" reasoningEffort="low" diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx index 39aefdb691..a468add185 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx @@ -13,7 +13,7 @@ import { Undo2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Select, SelectContent, @@ -36,7 +36,11 @@ import { } from "@/lib/provider-model-catalog"; import { cn } from "@/lib/utils"; import { SearchableSelect } from "./searchable-select"; -import { WorkspaceSelector } from "./workspace-selector"; +import { WorkspaceSelector as WorkspaceSelectorImpl } from "./workspace-selector"; + +// Memoized: the workspace/branch selector fans out into popovers and lists +// that should not re-render for every keystroke in the composer textarea. +const WorkspaceSelector = memo(WorkspaceSelectorImpl); type ActiveMention = { start: number; @@ -186,6 +190,16 @@ function getActiveSlash(input: string, cursor: number): ActiveSlash | null { return { slashIndex, query }; } +/** + * Externally injected composer text (quick actions, queue undo, resets). + * The live keystroke state stays local to ChatInputBar so typing never + * re-renders the whole page tree; bump `version` to push a new value in. + */ +export type PromptDraft = { + version: number; + value: string; +}; + type ChatInputBarProps = { variant?: "conversation" | "welcome"; status: ChatSessionStatus; @@ -195,7 +209,7 @@ type ChatInputBarProps = { thinking: ChatSessionConfig["thinking"]; reasoningEffort: ChatSessionConfig["reasoningEffort"]; gitBranch: string; - promptInput: string; + promptDraft: PromptDraft; onPromptInputChange: (value: string) => void; onProviderChange: (provider: string) => void; onModelChange: (model: string) => void; @@ -205,7 +219,7 @@ type ChatInputBarProps = { ) => void; onListGitBranches: () => Promise<{ current: string; branches: string[] }>; onSwitchGitBranch: (branch: string) => Promise; - onSend: () => void; + onSend: (prompt: string) => void; onAbort: () => void; promptsInQueue: PromptInQueue[]; attachments: Array<{ id: string; name: string; isImage: boolean }>; @@ -233,7 +247,7 @@ export function ChatInputBar({ thinking, reasoningEffort, gitBranch, - promptInput, + promptDraft, onPromptInputChange, onProviderChange, onModelChange, @@ -259,6 +273,24 @@ export function ChatInputBar({ switchWorkspace: onSwitchWorkspace, pickWorkspaceDirectory: onPickWorkspaceDirectory, } = useWorkspace(); + // Keystrokes only update this local state; the parent page tree is not + // re-rendered per keypress. External writers push text in via promptDraft. + const [promptInput, setPromptInputState] = useState(promptDraft.value); + const appliedDraftVersionRef = useRef(promptDraft.version); + const setPromptInput = useCallback( + (value: string) => { + setPromptInputState(value); + onPromptInputChange(value); + }, + [onPromptInputChange], + ); + useEffect(() => { + if (appliedDraftVersionRef.current === promptDraft.version) { + return; + } + appliedDraftVersionRef.current = promptDraft.version; + setPromptInput(promptDraft.value); + }, [promptDraft, setPromptInput]); const isBusy = status === "starting" || status === "running" || status === "stopping"; const canAbort = status === "running" || status === "stopping"; @@ -290,14 +322,29 @@ export function ChatInputBar({ [model, provider], ); const canSend = hasDraft; + const handleSend = useCallback(() => { + const prompt = promptInput.trim(); + setPromptInput(""); + onSend(prompt); + }, [onSend, promptInput, setPromptInput]); const fileInputRef = useRef(null); const promptInputRef = useRef(null); const [promptInputFocused, setPromptInputFocused] = useState(false); const [cursorIndex, setCursorIndex] = useState(() => promptInput.length); - const [mentionOpen, setMentionOpen] = useState(false); - const [activeMention, setActiveMention] = useState( + // Mention/slash detection is derived synchronously from the input + + // cursor. Deriving (rather than syncing through effects) keeps a keystroke + // at a single render commit; only an explicit Escape dismissal is state. + const activeMention = useMemo( + () => getActiveMention(promptInput, cursorIndex), + [promptInput, cursorIndex], + ); + const [dismissedMentionKey, setDismissedMentionKey] = useState( null, ); + const mentionKey = activeMention + ? `${activeMention.start}:${activeMention.query}` + : null; + const mentionOpen = mentionKey !== null && dismissedMentionKey !== mentionKey; const [mentionFiles, setMentionFiles] = useState([]); const [mentionLoading, setMentionLoading] = useState(false); const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0); @@ -305,8 +352,17 @@ export function ChatInputBar({ const mentionLastRequestKeyRef = useRef(null); // ---- Slash command state ---- - const [slashOpen, setSlashOpen] = useState(false); - const [activeSlash, setActiveSlash] = useState(null); + const activeSlash = useMemo( + () => getActiveSlash(promptInput, cursorIndex), + [promptInput, cursorIndex], + ); + const [dismissedSlashKey, setDismissedSlashKey] = useState( + null, + ); + const slashKey = activeSlash + ? `${activeSlash.slashIndex}:${activeSlash.query}` + : null; + const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey; const [slashCommands, setSlashCommands] = useState( BUILTIN_SLASH_COMMANDS, ); @@ -440,12 +496,6 @@ export function ChatInputBar({ } }, [cancelQueuedPromptEdit, editingQueuedPromptId, promptsInQueue]); - useEffect(() => { - const nextMention = getActiveMention(promptInput, cursorIndex); - setActiveMention(nextMention); - setMentionOpen(nextMention !== null); - }, [promptInput, cursorIndex]); - useEffect(() => { if (!mentionOpen || !activeMention) { setMentionFiles([]); @@ -516,8 +566,9 @@ export function ChatInputBar({ const nextValue = `${promptInput.slice(0, activeMention.start)}@${filePath} ` + promptInput.slice(activeMention.end); - onPromptInputChange(nextValue); - setMentionOpen(false); + // The menu closes on its own: the inserted trailing space ends the + // active mention, so the derived `mentionOpen` turns false. + setPromptInput(nextValue); const nextCursor = activeMention.start + filePath.length + 2; requestAnimationFrame(() => { const input = promptInputRef.current; @@ -529,18 +580,11 @@ export function ChatInputBar({ setCursorIndex(nextCursor); }); }, - [activeMention, onPromptInputChange, promptInput], + [activeMention, promptInput, setPromptInput], ); // ---- Slash command effects ---- - // Detect slash mode from current input + cursor position. - useEffect(() => { - const nextSlash = getActiveSlash(promptInput, cursorIndex); - setActiveSlash(nextSlash); - setSlashOpen(nextSlash !== null); - }, [promptInput, cursorIndex]); - // Reset selection index when slash menu opens/closes. useEffect(() => { if (!slashOpen) { @@ -612,8 +656,8 @@ export function ChatInputBar({ (commandName: string) => { if (!activeSlash) return; const nextValue = `${promptInput.slice(0, activeSlash.slashIndex)}/${commandName} `; - onPromptInputChange(nextValue); - setSlashOpen(false); + // Closes via derivation: the trailing space ends the slash command. + setPromptInput(nextValue); const nextCursor = activeSlash.slashIndex + commandName.length + 2; requestAnimationFrame(() => { const input = promptInputRef.current; @@ -623,7 +667,7 @@ export function ChatInputBar({ setCursorIndex(nextCursor); }); }, - [activeSlash, onPromptInputChange, promptInput], + [activeSlash, promptInput, setPromptInput], ); return ( @@ -890,7 +934,7 @@ export function ChatInputBar({ aria-haspopup="listbox" className="max-h-60 min-h-5 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-5 text-foreground placeholder:text-muted-foreground outline-none" onChange={(e) => { - onPromptInputChange(e.target.value); + setPromptInput(e.target.value); setCursorIndex( e.target.selectionStart ?? e.target.value.length, ); @@ -932,7 +976,7 @@ export function ChatInputBar({ } if (slashOpen && e.key === "Escape") { e.preventDefault(); - setSlashOpen(false); + setDismissedSlashKey(slashKey); return; } if (mentionOpen && mentionFiles.length > 0) { @@ -959,13 +1003,13 @@ export function ChatInputBar({ } if (mentionOpen && e.key === "Escape") { e.preventDefault(); - setMentionOpen(false); + setDismissedMentionKey(mentionKey); return; } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (canSend) { - onSend(); + handleSend(); } } }} @@ -1157,7 +1201,7 @@ export function ChatInputBar({ : "rounded-full bg-foreground text-background hover:bg-foreground/80", )} disabled={!canSend} - onClick={onSend} + onClick={handleSend} type="button" > @@ -1170,7 +1214,9 @@ export function ChatInputBar({ ); } -function ModelSelector({ +// Memoized: the selectors load/hold the full provider-model catalog, so they +// should not re-render for every keystroke in the composer textarea. +const ModelSelector = memo(function ModelSelector({ provider, model, isBusy, @@ -1449,7 +1495,7 @@ function ModelSelector({ />
); -} +}); function StatusItem({ icon: Icon, diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx index c270cf3f20..25a7c67555 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx @@ -36,7 +36,7 @@ import { UndoIcon, X, } from "lucide-react"; -import { memo, useCallback, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { toast } from "@/hooks/use-toast"; import type { @@ -186,6 +186,10 @@ function ChatMessagesImpl({ expandedImage?.sessionId === sessionId ? expandedImage.image : null; const showIdleDetails = !hasMessages && !isSessionSwitching && !showSwitchTransition; + const renderItems = useMemo( + () => groupConsecutiveToolMessages(messages), + [messages], + ); useEffect(() => { if (!visibleExpandedImage) { @@ -346,6 +350,13 @@ function ChatMessagesImpl({ [onRestoreCheckpoint], ); + const handleExpandImage = useCallback( + (image: ChatMessageImage) => { + setExpandedImage({ sessionId, image }); + }, + [sessionId], + ); + const handleForkSession = useCallback( async (messageId: string) => { if (!onForkSession) { @@ -423,7 +434,7 @@ function ChatMessagesImpl({ requestErrors={askQuestionErrors} /> ) : null} - {groupConsecutiveToolMessages(messages).map((item) => { + {renderItems.map((item) => { if (item.type === "tools") { return ( - setExpandedImage({ sessionId, image }) - } - onCopyRawText={() => - void handleCopyMessage(message.id, message.content) - } - onRestoreCheckpoint={(runCount) => - void handleRestoreCheckpoint(message.id, runCount) + onExpandImage={handleExpandImage} + onCopyMessage={handleCopyMessage} + onRestoreCheckpoint={ + onRestoreCheckpoint ? handleRestoreCheckpoint : undefined } restoreDisabled={ !onRestoreCheckpoint || @@ -458,9 +465,7 @@ function ChatMessagesImpl({ restorePending={checkpointActions[message.id] === "undoing"} wasCopied={copiedMessageId === message.id} onForkSession={ - onForkSession - ? () => void handleForkSession(message.id) - : undefined + onForkSession ? handleForkSession : undefined } forkPending={forkingMessageId === message.id} forkError={forkErrors[message.id]} @@ -747,10 +752,13 @@ function AskQuestionPanel({ ); } -function MessageBubble({ +// Memoized with id-parameterized callbacks: during streaming only the message +// object that received a delta changes identity, so all other bubbles skip +// re-rendering (and re-running their Markdown pipeline) per flush. +const MessageBubble = memo(function MessageBubble({ message, isStreaming = false, - onCopyRawText, + onCopyMessage, onExpandImage, onRestoreCheckpoint, restoreDisabled = false, @@ -763,14 +771,17 @@ function MessageBubble({ }: { message: ChatMessage; isStreaming?: boolean; - onCopyRawText?: () => void; + onCopyMessage?: (messageId: string, content: string) => void | Promise; onExpandImage?: (image: ChatMessageImage) => void; - onRestoreCheckpoint?: (runCount: number) => void; + onRestoreCheckpoint?: ( + messageId: string, + runCount: number, + ) => void | Promise; restoreDisabled?: boolean; restorePending?: boolean; restoreError?: string; wasCopied?: boolean; - onForkSession?: () => void; + onForkSession?: (messageId: string) => void | Promise; forkPending?: boolean; forkError?: string; }) { @@ -786,9 +797,9 @@ function MessageBubble({ !isStreaming && !isError && Boolean(displayContent.trim()) && - Boolean(onCopyRawText || onForkSession); + Boolean(onCopyMessage || onForkSession); const shouldRenderUserActions = - isUser && Boolean(onCopyRawText || checkpoint); + isUser && Boolean(onCopyMessage || checkpoint); const keepUserActionsVisible = restorePending || Boolean(restoreError); const keepAssistantActionsVisible = forkPending || Boolean(forkError); @@ -839,10 +850,10 @@ function MessageBubble({ {shouldRenderUserActions ? ( <> - {onCopyRawText ? ( + {onCopyMessage ? ( void onCopyMessage(message.id, message.content)} title={wasCopied ? "Copied" : "Copy message"} > {wasCopied ? ( @@ -856,7 +867,9 @@ function MessageBubble({ onRestoreCheckpoint?.(checkpoint.runCount)} + onClick={() => + void onRestoreCheckpoint?.(message.id, checkpoint.runCount) + } title="Restore checkpoint" > {restorePending ? ( @@ -877,14 +890,14 @@ function MessageBubble({ {shouldRenderAssistantActions ? ( - {onCopyRawText ? ( + {onCopyMessage ? ( void onCopyMessage(message.id, message.content)} title={wasCopied ? "Copied" : "Copy raw assistant output"} > {wasCopied ? ( @@ -898,7 +911,7 @@ function MessageBubble({ void onForkSession(message.id)} title="Fork session - copy full message history into a new session" > {forkPending ? ( @@ -915,7 +928,7 @@ function MessageBubble({ ) : null} ); -} +}); function ReasoningBlock({ content, @@ -1453,100 +1466,115 @@ function buildGroupedToolLabel(presentations: ToolPresentation[]): string { .join(". "); } -function ToolMessageBlock({ messages }: { messages: ChatMessage[] }) { - const presentations = messages.map(buildToolPresentation); - const first = presentations[0]; - if (!first) return null; - const hasError = presentations.some(({ payload }) => payload?.isError); - const isRunning = presentations.some(({ inProgress }) => inProgress); - const kinds = new Set(presentations.map(({ kind }) => kind)); - const kind = kinds.size === 1 ? first.kind : "tool"; - const isFileRead = presentations.every(({ toolName }) => - ["read_files", "file_read", "file-read"].includes(toolName.toLowerCase()), - ); - const Icon = isFileRead - ? FileIcon - : kind === "exploration" - ? Search - : kind === "file-edit" - ? FileEdit - : kind === "bash" - ? SquareTerminalIcon - : kind === "spawn" - ? Bot - : FileSearch; - const details = presentations.flatMap(({ message, summary }) => - summary.details.map((detail) => ({ - detail, - key: `${message.id}_${detail}`, - })), - ); - const inputPreviews = IS_DEBUG - ? presentations - .map(({ message, payload, toolName }) => ({ - key: message.id, - toolName, - value: payload ? formatToolValue(payload.input) : "", - })) - .filter(({ value }) => Boolean(value)) - : []; - const resultPreviews = presentations - .map(({ message, payload, toolName }) => ({ - key: message.id, - toolName, - value: payload?.isError ? formatToolValue(payload.result) : "", - })) - .filter(({ value }) => Boolean(value)); - const hasExpandedSections = - details.length > 0 || inputPreviews.length > 0 || resultPreviews.length > 0; - const diff = presentations.reduce( - (total, { summary }) => ({ - additions: total.additions + (summary.diff?.additions ?? 0), - deletions: total.deletions + (summary.diff?.deletions ?? 0), - }), - { additions: 0, deletions: 0 }, - ); +// Memoized with element-wise comparison: the grouping pass wraps the same +// message objects in fresh arrays every commit, so reference-comparing the +// contents lets finished tool blocks skip re-rendering during streaming. +const ToolMessageBlock = memo( + function ToolMessageBlock({ messages }: { messages: ChatMessage[] }) { + const presentations = messages.map(buildToolPresentation); + const first = presentations[0]; + if (!first) return null; + const hasError = presentations.some(({ payload }) => payload?.isError); + const isRunning = presentations.some(({ inProgress }) => inProgress); + const kinds = new Set(presentations.map(({ kind }) => kind)); + const kind = kinds.size === 1 ? first.kind : "tool"; + const isFileRead = presentations.every(({ toolName }) => + ["read_files", "file_read", "file-read"].includes(toolName.toLowerCase()), + ); + const Icon = isFileRead + ? FileIcon + : kind === "exploration" + ? Search + : kind === "file-edit" + ? FileEdit + : kind === "bash" + ? SquareTerminalIcon + : kind === "spawn" + ? Bot + : FileSearch; + const details = presentations.flatMap(({ message, summary }) => + summary.details.map((detail) => ({ + detail, + key: `${message.id}_${detail}`, + })), + ); + const inputPreviews = IS_DEBUG + ? presentations + .map(({ message, payload, toolName }) => ({ + key: message.id, + toolName, + value: payload ? formatToolValue(payload.input) : "", + })) + .filter(({ value }) => Boolean(value)) + : []; + const resultPreviews = presentations + .map(({ message, payload, toolName }) => ({ + key: message.id, + toolName, + value: payload?.isError ? formatToolValue(payload.result) : "", + })) + .filter(({ value }) => Boolean(value)); + const hasExpandedSections = + details.length > 0 || + inputPreviews.length > 0 || + resultPreviews.length > 0; + const diff = presentations.reduce( + (total, { summary }) => ({ + additions: total.additions + (summary.diff?.additions ?? 0), + deletions: total.deletions + (summary.diff?.deletions ?? 0), + }), + { additions: 0, deletions: 0 }, + ); - return ( - - - ) : ( - - ) - } - label={buildGroupedToolLabel(presentations)} - status={hasError ? "error" : isRunning ? "running" : "success"} - /> - - {details.length > 0 ? ( - - {details.map(({ detail, key }) => ( -
{detail}
- ))} -
- ) : null} - {inputPreviews.map((preview) => ( -
-
- {presentations.length > 1 ? `${preview.toolName} input` : "Input"} + return ( + + + ) : ( + + ) + } + label={buildGroupedToolLabel(presentations)} + status={hasError ? "error" : isRunning ? "running" : "success"} + /> + + {details.length > 0 ? ( + + {details.map(({ detail, key }) => ( +
{detail}
+ ))} +
+ ) : null} + {inputPreviews.map((preview) => ( +
+
+ {presentations.length > 1 + ? `${preview.toolName} input` + : "Input"} +
+ + {preview.value} +
- + ))} + {resultPreviews.map((preview) => ( +
+ {presentations.length > 1 ? `${preview.toolName}: ` : null} {preview.value} - -
- ))} - {resultPreviews.map((preview) => ( -
- {presentations.length > 1 ? `${preview.toolName}: ` : null} - {preview.value} -
- ))} -
-
- ); -} +
+ ))} + + + ); + }, + (prev, next) => + prev.messages.length === next.messages.length && + prev.messages.every((message, index) => message === next.messages[index]), +); diff --git a/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx b/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx index 1a900517de..3ed013f649 100644 --- a/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/onboarding/onboarding-view.tsx @@ -27,7 +27,11 @@ import { readModelSelectionStorageFromWindow, writeModelSelectionStorageToWindow, } from "@/lib/model-selection"; -import type { Provider, ProviderCatalogResponse } from "@/lib/provider-schema"; +import { + fetchProviderCatalog, + invalidateProviderCatalogCache, +} from "@/lib/provider-model-catalog"; +import type { Provider } from "@/lib/provider-schema"; const CREATE_ACCOUNT_URL = "https://app.cline.bot"; @@ -198,9 +202,7 @@ function ConnectStep({ let cancelled = false; async function loadProviders() { try { - const payload = await desktopClient.invoke( - "list_provider_catalog", - ); + const payload = await fetchProviderCatalog(); if (cancelled) { return; } @@ -256,6 +258,9 @@ function ConnectStep({ } setSignInError(error instanceof Error ? error.message : String(error)); } finally { + // The login may have persisted credentials; drop the short-lived + // catalog cache so the app reloads them instead of a pre-save copy. + invalidateProviderCatalogCache(); if (signInAttemptRef.current === attempt) { setSigningIn(false); } @@ -327,6 +332,9 @@ function ConnectStep({ } catch (error) { setClineKeyError(error instanceof Error ? error.message : String(error)); } finally { + // Credentials may have been saved (or rolled back); drop the + // short-lived catalog cache so consumers reload the persisted state. + invalidateProviderCatalogCache(); setClineKeySaving(false); } }, [clineApiKey, onConnected, refreshAccount]); @@ -354,6 +362,10 @@ function ConnectStep({ } catch (error) { setSaveError(error instanceof Error ? error.message : String(error)); } finally { + // Onboarding completion remounts the chat pane to reload provider + // credentials; drop the short-lived catalog cache so that reload + // sees the just-saved key rather than a pre-save copy. + invalidateProviderCatalogCache(); setSaving(false); } }, [apiKey, onConnected, selectedProvider]); diff --git a/apps/examples/desktop-app/webview/components/views/settings/account-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/account-view.tsx index 2aa7f04273..ea72facb4f 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/account-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/account-view.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useAccount } from "@/contexts/account-context"; import { desktopClient, openExternalUrl } from "@/lib/desktop-client"; +import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog"; import { cn } from "@/lib/utils"; const DASHBOARD_URL = "https://app.cline.bot/dashboard"; @@ -255,6 +256,9 @@ export function AccountView() { setOverviewError(message); resetAccountData(); } finally { + // The login may have persisted credentials; drop the short-lived + // catalog cache so consumers reload them. + invalidateProviderCatalogCache(); setAccountActionPending(null); void refreshAccount(); } @@ -281,6 +285,7 @@ export function AccountView() { const message = normalizeAccountViewError(err).message; setOverviewError(message); } finally { + invalidateProviderCatalogCache(); setAccountActionPending(null); void refreshAccount(); } diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx index 905706cc0f..e3148429db 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/app-icon"; import { desktopClient } from "@/lib/desktop-client"; import { resetOnboarding } from "@/lib/onboarding"; +import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog"; import type { Provider, ProviderCatalogResponse, @@ -200,6 +201,10 @@ export function SettingsView({ } catch (error) { const message = error instanceof Error ? error.message : String(error); window.alert(`Failed to save provider settings for ${id}: ${message}`); + } finally { + // Keep the shared short-lived catalog cache (composer model + // selector, onboarding) in sync with the just-saved settings. + invalidateProviderCatalogCache(); } }, [], @@ -357,6 +362,7 @@ export function SettingsView({ models_source_url: payload.modelsSourceUrl, capabilities: payload.capabilities, }); + invalidateProviderCatalogCache(); await loadProviderCatalog(); setAddingProvider(false); setSelectedProviderId(payload.providerId); diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts index cedf3125c9..12694c805b 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts @@ -56,6 +56,10 @@ export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants"; const MAX_MESSAGES = 800; +// How long streamed text/reasoning deltas are buffered before a React commit. +// ~3 frames: fast enough to feel live, slow enough to absorb per-token events. +const STREAM_FLUSH_INTERVAL_MS = 48; + const RELEVANT_STREAMS = new Set([ "chat_text", "chat_reasoning", @@ -489,6 +493,76 @@ export function useChatSession() { [], ); + // ---- Stream coalescing ---- + // + // Text/reasoning deltas can arrive per-token, and a React commit per token + // re-renders the whole conversation for every word. Deltas are buffered in + // refs and flushed on a short timer, so streaming costs at most ~20 commits + // per second regardless of token rate while still feeling live. + + const pendingStreamTextRef = useRef(new Map()); + const pendingStreamReasoningRef = useRef( + new Map(), + ); + const pendingStreamTranscriptRef = useRef(""); + const streamFlushTimerRef = useRef | null>( + null, + ); + + const flushPendingStream = useCallback(() => { + if (streamFlushTimerRef.current !== null) { + clearTimeout(streamFlushTimerRef.current); + streamFlushTimerRef.current = null; + } + const texts = pendingStreamTextRef.current; + if (texts.size > 0) { + pendingStreamTextRef.current = new Map(); + for (const [id, chunk] of texts) { + appendMessageContent(id, chunk); + } + } + const reasonings = pendingStreamReasoningRef.current; + if (reasonings.size > 0) { + pendingStreamReasoningRef.current = new Map(); + for (const [id, entry] of reasonings) { + appendMessageReasoning(id, entry.text, entry.redacted); + } + } + const transcript = pendingStreamTranscriptRef.current; + if (transcript) { + pendingStreamTranscriptRef.current = ""; + setRawTranscript((prev) => `${prev}${transcript}`); + } + }, [appendMessageContent, appendMessageReasoning]); + + const schedulePendingStreamFlush = useCallback(() => { + if (streamFlushTimerRef.current !== null) { + return; + } + streamFlushTimerRef.current = setTimeout(() => { + streamFlushTimerRef.current = null; + flushPendingStream(); + }, STREAM_FLUSH_INTERVAL_MS); + }, [flushPendingStream]); + + const discardPendingStream = useCallback(() => { + if (streamFlushTimerRef.current !== null) { + clearTimeout(streamFlushTimerRef.current); + streamFlushTimerRef.current = null; + } + pendingStreamTextRef.current = new Map(); + pendingStreamReasoningRef.current = new Map(); + pendingStreamTranscriptRef.current = ""; + }, []); + + useEffect(() => { + return () => { + if (streamFlushTimerRef.current !== null) { + clearTimeout(streamFlushTimerRef.current); + } + }; + }, []); + // ---- Process context ---- const applyProcessContext = useCallback(async () => { @@ -693,6 +767,67 @@ export function useChatSession() { return; } + // --- Text stream (buffered) --- + if (payload.stream === "chat_text") { + let assistantId = activeAssistantMessageIdRef.current; + if (!assistantId) { + assistantId = makeId("assistant"); + addMessage({ + id: assistantId, + sessionId: listeningSessionId, + role: "assistant", + content: "", + createdAt: chunkCreatedAt(payload), + }); + activeAssistantMessageIdRef.current = assistantId; + setActiveAssistantMessageId(assistantId); + } + const pending = pendingStreamTextRef.current; + pending.set( + assistantId, + (pending.get(assistantId) ?? "") + payload.chunk, + ); + pendingStreamTranscriptRef.current += payload.chunk; + schedulePendingStreamFlush(); + return; + } + + if (payload.stream === "chat_reasoning") { + let assistantId = activeAssistantMessageIdRef.current; + if (!assistantId) { + assistantId = makeId("assistant"); + addMessage({ + id: assistantId, + sessionId: listeningSessionId, + role: "assistant", + content: "", + createdAt: chunkCreatedAt(payload), + }); + activeAssistantMessageIdRef.current = assistantId; + setActiveAssistantMessageId(assistantId); + } + let parsed: ReasoningDeltaEvent = {}; + try { + parsed = JSON.parse(payload.chunk) as ReasoningDeltaEvent; + } catch { + parsed = { text: payload.chunk }; + } + if (parsed.text || parsed.redacted === true) { + const pending = pendingStreamReasoningRef.current; + const existing = pending.get(assistantId); + pending.set(assistantId, { + text: `${existing?.text ?? ""}${parsed.text ?? ""}`, + redacted: (existing?.redacted ?? false) || parsed.redacted === true, + }); + schedulePendingStreamFlush(); + } + return; + } + + // Any non-delta event (tool calls, prompt starts, done markers) must + // observe fully applied text, so drain the buffers first. + flushPendingStream(); + if (payload.stream === "chat_queued_prompt_start") { let parsed: { prompt?: string; @@ -757,54 +892,6 @@ export function useChatSession() { return; } - // --- Text stream --- - if (payload.stream === "chat_text") { - let assistantId = activeAssistantMessageIdRef.current; - if (!assistantId) { - assistantId = makeId("assistant"); - addMessage({ - id: assistantId, - sessionId: listeningSessionId, - role: "assistant", - content: "", - createdAt: chunkCreatedAt(payload), - }); - activeAssistantMessageIdRef.current = assistantId; - setActiveAssistantMessageId(assistantId); - } - appendMessageContent(assistantId, payload.chunk); - setRawTranscript((prev) => `${prev}${payload.chunk}`); - return; - } - - if (payload.stream === "chat_reasoning") { - let assistantId = activeAssistantMessageIdRef.current; - if (!assistantId) { - assistantId = makeId("assistant"); - addMessage({ - id: assistantId, - sessionId: listeningSessionId, - role: "assistant", - content: "", - createdAt: chunkCreatedAt(payload), - }); - activeAssistantMessageIdRef.current = assistantId; - setActiveAssistantMessageId(assistantId); - } - let parsed: ReasoningDeltaEvent = {}; - try { - parsed = JSON.parse(payload.chunk) as ReasoningDeltaEvent; - } catch { - parsed = { text: payload.chunk }; - } - appendMessageReasoning( - assistantId, - parsed.text ?? "", - parsed.redacted === true, - ); - return; - } - // --- Core log --- if (payload.stream === "chat_core_log") { dispatchCoreLog(payload.chunk); @@ -920,9 +1007,9 @@ export function useChatSession() { }, [ addMessage, - appendMessageContent, - appendMessageReasoning, clearLiveToolRefs, + flushPendingStream, + schedulePendingStreamFlush, shouldApplyStreamChunk, ], ); @@ -1059,6 +1146,7 @@ export function useChatSession() { setIsHydratingSession(false); abortedRef.current = false; clearAbortFallbackTimeout(); + discardPendingStream(); setMessages([]); setRawTranscript(""); resetCounters(); @@ -1082,6 +1170,7 @@ export function useChatSession() { [ addMessage, clearAbortFallbackTimeout, + discardPendingStream, resetCounters, setErrorState, startSession, @@ -1662,6 +1751,7 @@ export function useChatSession() { setIsHydratingSession(false); abortedRef.current = false; clearAbortFallbackTimeout(); + discardPendingStream(); setMessages([]); setRawTranscript(""); setError(null); @@ -1689,6 +1779,7 @@ export function useChatSession() { }, [ sessionId, clearAbortFallbackTimeout, + discardPendingStream, postSession, resetCounters, clearLiveToolRefs, @@ -1722,6 +1813,7 @@ export function useChatSession() { setPendingAskQuestions([]); setPromptsInQueue([]); clearLiveToolRefs(); + discardPendingStream(); const applyHydratedMessages = ( msgs: ChatMessage[], @@ -1838,6 +1930,7 @@ export function useChatSession() { [ clearAbortFallbackTimeout, clearLiveToolRefs, + discardPendingStream, refreshPromptsInQueue, refreshSessionDiffSummary, resetStreamDedupe, diff --git a/apps/examples/desktop-app/webview/hooks/use-session-history.ts b/apps/examples/desktop-app/webview/hooks/use-session-history.ts index 71fa9ce94e..860229c296 100644 --- a/apps/examples/desktop-app/webview/hooks/use-session-history.ts +++ b/apps/examples/desktop-app/webview/hooks/use-session-history.ts @@ -521,7 +521,12 @@ export function useSessionHistory({ const refreshPromise = (async () => { lastRefreshStartedAtRef.current = Date.now(); const limit = fetchLimitRef.current; - setIsLoadingHistory(true); + // Only surface the loading state before anything has been fetched: + // consumers only render it for an empty list, and toggling it on + // every background poll re-rendered the whole app twice per refresh. + if (sessionsRef.current.length === 0) { + setIsLoadingHistory(true); + } try { const discovered = await desktopClient .invoke("list_discovered_sessions", { limit }) diff --git a/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts b/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts index 3041d1a079..39a0ec9e6d 100644 --- a/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts +++ b/apps/examples/desktop-app/webview/lib/provider-model-catalog.ts @@ -48,10 +48,47 @@ export function buildProviderModelCatalog( }; } +// The provider catalog payload is large (hundreds of KB) and several +// components request it at startup (composer, onboarding, credentials sync). +// Deduplicate concurrent requests and keep the response briefly so the app +// boot issues a single round-trip instead of one per consumer. +const PROVIDER_CATALOG_CACHE_TTL_MS = 5_000; + +let providerCatalogCache: { + fetchedAt: number; + promise: Promise; +} | null = null; + +export function fetchProviderCatalog(options?: { + fresh?: boolean; +}): Promise { + const now = Date.now(); + if ( + !options?.fresh && + providerCatalogCache && + now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS + ) { + return providerCatalogCache.promise; + } + const promise = desktopClient + .invoke("list_provider_catalog") + .catch((error) => { + // Never cache failures. + if (providerCatalogCache?.promise === promise) { + providerCatalogCache = null; + } + throw error; + }); + providerCatalogCache = { fetchedAt: now, promise }; + return promise; +} + +export function invalidateProviderCatalogCache(): void { + providerCatalogCache = null; +} + export async function loadProviderModelCatalog(): Promise { - const payload = await desktopClient.invoke( - "list_provider_catalog", - ); + const payload = await fetchProviderCatalog(); return buildProviderModelCatalog(payload.providers ?? []); }