perf(desktop): make the app feel snappy end-to-end (#12568)

* perf(desktop): make the app feel snappy end-to-end

Fixes several compounding sources of UI jank that made every click and
keystroke feel seconds-slow:

- Aurora background: drop per-frame 46-64px CSS blur re-rasterization;
  bake softness into gradients + a static mask and animate only
  opacity/transform (compositor-only). Onboarding/home idle went from
  ~10fps to a locked 60fps under 4x CPU throttling.
- Hide the app shell while the opaque onboarding overlay is up so a
  second aurora + hero animations are not composited underneath.
- Hero verb animation: opacity/transform only (no text blur filter).
- Composer: keystroke state now lives inside ChatInputBar (versioned
  promptDraft injections for quick actions/undo/resets), and mention/
  slash detection is derived instead of effect-synced; typing went from
  245/246 keystrokes over 50ms to 3/240.
- Chat streaming: coalesce per-token text/reasoning deltas into ~48ms
  flushes; memoize MessageBubble/ToolMessageBlock with stable callbacks
  so finished messages skip re-rendering during streams.
- Session history: only surface isLoadingHistory before the first load;
  background refreshes no longer re-render the whole app twice each.
- Provider catalog (~700KB): dedupe concurrent fetches with a short TTL
  so app boot issues one round-trip instead of three.
- Sidecar: session-log appends are now ordered async writes instead of
  writeFileSync per streamed token; git/folder-picker/editor discovery
  use async execFile so the native picker no longer freezes every
  pending command; editor discovery results cached for 60s.

* fix(desktop): address Bugbot review findings

- Invalidate the shared provider-catalog cache after any provider
  mutation (onboarding connect paths, account sign-in/out, settings
  save, add provider) so post-save reloads never see a pre-save copy.
- Clear the injected composer draft on send so a composer remount
  cannot repopulate the previous prompt.
- Mark the hidden app shell inert + aria-hidden while the onboarding
  overlay covers it, keeping covered controls out of the tab order.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Saoud Rizwan
2026-07-27 15:04:49 -07:00
committed by GitHub
parent 372f029343
commit fabbc144d6
15 changed files with 686 additions and 353 deletions
@@ -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 |
+92 -67
View File
@@ -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<string | null> {
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<string | null> {
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<boolean> {
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<string> {
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 };
}
+21 -4
View File
@@ -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<string, Promise<void>>();
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);
}
});
}
@@ -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,
+59 -30
View File
@@ -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 (
<AccountProvider>
<SidebarProvider>
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<div
aria-hidden={showOnboarding ? true : undefined}
className="flex h-screen w-full overflow-hidden bg-background text-foreground"
// The onboarding overlay is opaque and sits on top of the whole
// shell; hiding the shell keeps its aurora + animations from
// being composited every frame underneath while it still mounts
// and loads (providers, history, transport) in the background.
// `inert` additionally keeps the covered controls out of the
// keyboard tab order and assistive tech while it is hidden.
inert={showOnboarding ? true : undefined}
style={showOnboarding ? { visibility: "hidden" } : undefined}
>
<Sidebar
className="border-r border-sidebar-border"
collapsible="icon"
@@ -386,7 +398,18 @@ function ChatThreadPane({
abort,
hydrateSession,
} = useChatSession();
const [promptInput, setPromptInput] = useState("");
// The live composer text lives inside ChatInputBar so typing does not
// re-render this whole pane. The pane mirrors it in a ref (for reads) and
// pushes external updates (quick actions, undo, resets) via promptDraft.
const promptInputRef = useRef("");
const [promptDraft, setPromptDraft] = useState({ version: 0, value: "" });
const setPromptInput = useCallback((value: string) => {
promptInputRef.current = value;
setPromptDraft((prev) => ({ version: prev.version + 1, value }));
}, []);
const handlePromptInputChange = useCallback((value: string) => {
promptInputRef.current = value;
}, []);
const [pendingAttachments, setPendingAttachments] = useState<File[]>([]);
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<ChatSessionConfig, "thinking" | "reasoningEffort">) => {
@@ -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}
@@ -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<Star[]>(
() =>
@@ -83,14 +91,14 @@ export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div
className="aurora-horizon absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60 blur-[64px]"
className="aurora-horizon aurora-soft-band absolute inset-x-[-8%] bottom-[-3%] h-[40%] opacity-60"
style={{
background:
"linear-gradient(90deg, color-mix(in oklab, var(--brand-lilac) 58%, transparent), color-mix(in oklab, var(--brand-magenta) 62%, transparent) 42%, color-mix(in oklab, var(--brand-periwinkle) 72%, transparent) 78%, color-mix(in oklab, var(--brand-cyan) 58%, transparent))",
}}
/>
<div
className="aurora-current absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50 blur-[46px]"
className="aurora-current aurora-soft-band absolute bottom-[3%] left-[-45%] h-[30%] w-[125%] opacity-50"
style={{
animationDelay: "-2s",
animationDuration: "9s",
@@ -99,7 +107,7 @@ export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
}}
/>
<div
className="aurora-current aurora-current-reverse absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45 blur-[52px]"
className="aurora-current aurora-current-reverse aurora-soft-band absolute bottom-[-5%] right-[-42%] h-[34%] w-[120%] opacity-45"
style={{
animationDelay: "-6s",
animationDuration: "12s",
@@ -110,7 +118,7 @@ export function AuroraBackground({ starCount = 48 }: { starCount?: number }) {
{BLOBS.map((blob) => (
<div
key={blob.id}
className={`aurora-motion absolute blur-[64px] ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
className={`aurora-motion absolute ${blob.reverse ? "aurora-motion-reverse" : ""} ${blob.position}`}
style={{
background: blob.gradient,
animationDuration: blob.duration,
@@ -85,7 +85,7 @@ describe("ChatInputBar", () => {
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"
@@ -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<boolean>;
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<HTMLInputElement | null>(null);
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
const [promptInputFocused, setPromptInputFocused] = useState(false);
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
const [mentionOpen, setMentionOpen] = useState(false);
const [activeMention, setActiveMention] = useState<ActiveMention | null>(
// 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<string | null>(
null,
);
const mentionKey = activeMention
? `${activeMention.start}:${activeMention.query}`
: null;
const mentionOpen = mentionKey !== null && dismissedMentionKey !== mentionKey;
const [mentionFiles, setMentionFiles] = useState<string[]>([]);
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
@@ -305,8 +352,17 @@ export function ChatInputBar({
const mentionLastRequestKeyRef = useRef<string | null>(null);
// ---- Slash command state ----
const [slashOpen, setSlashOpen] = useState(false);
const [activeSlash, setActiveSlash] = useState<ActiveSlash | null>(null);
const activeSlash = useMemo(
() => getActiveSlash(promptInput, cursorIndex),
[promptInput, cursorIndex],
);
const [dismissedSlashKey, setDismissedSlashKey] = useState<string | null>(
null,
);
const slashKey = activeSlash
? `${activeSlash.slashIndex}:${activeSlash.query}`
: null;
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
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"
>
<ArrowUp className="h-4 w-4" />
@@ -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({
/>
</div>
);
}
});
function StatusItem({
icon: Icon,
@@ -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 (
<ToolMessageBlock
@@ -438,14 +449,10 @@ function ChatMessagesImpl({
isStreaming={streamingMessageId === message.id}
key={message.id}
message={message}
onExpandImage={(image) =>
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<void>;
onExpandImage?: (image: ChatMessageImage) => void;
onRestoreCheckpoint?: (runCount: number) => void;
onRestoreCheckpoint?: (
messageId: string,
runCount: number,
) => void | Promise<void>;
restoreDisabled?: boolean;
restorePending?: boolean;
restoreError?: string;
wasCopied?: boolean;
onForkSession?: () => void;
onForkSession?: (messageId: string) => void | Promise<void>;
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 ? (
<>
<MessageActions visible={keepUserActionsVisible}>
{onCopyRawText ? (
{onCopyMessage ? (
<MessageAction
label={wasCopied ? "Copied user message" : "Copy user message"}
onClick={onCopyRawText}
onClick={() => void onCopyMessage(message.id, message.content)}
title={wasCopied ? "Copied" : "Copy message"}
>
{wasCopied ? (
@@ -856,7 +867,9 @@ function MessageBubble({
<MessageAction
disabled={restoreDisabled || restorePending}
label="Restore checkpoint"
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
onClick={() =>
void onRestoreCheckpoint?.(message.id, checkpoint.runCount)
}
title="Restore checkpoint"
>
{restorePending ? (
@@ -877,14 +890,14 @@ function MessageBubble({
{shouldRenderAssistantActions ? (
<MessageActions visible={keepAssistantActionsVisible}>
{onCopyRawText ? (
{onCopyMessage ? (
<MessageAction
label={
wasCopied
? "Copied assistant message"
: "Copy assistant message"
}
onClick={onCopyRawText}
onClick={() => void onCopyMessage(message.id, message.content)}
title={wasCopied ? "Copied" : "Copy raw assistant output"}
>
{wasCopied ? (
@@ -898,7 +911,7 @@ function MessageBubble({
<MessageAction
disabled={forkPending}
label="Fork session"
onClick={onForkSession}
onClick={() => void onForkSession(message.id)}
title="Fork session - copy full message history into a new session"
>
{forkPending ? (
@@ -915,7 +928,7 @@ function MessageBubble({
) : null}
</AgentMessage>
);
}
});
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 (
<ToolActivity expandable={hasExpandedSections}>
<ToolActivityTrigger
additions={diff.additions || undefined}
deletions={diff.deletions || undefined}
icon={
hasError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)
}
label={buildGroupedToolLabel(presentations)}
status={hasError ? "error" : isRunning ? "running" : "success"}
/>
<ToolActivityContent>
{details.length > 0 ? (
<ToolActivityDetails>
{details.map(({ detail, key }) => (
<div key={key}>{detail}</div>
))}
</ToolActivityDetails>
) : null}
{inputPreviews.map((preview) => (
<div className="space-y-1" key={`input_${preview.key}`}>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
{presentations.length > 1 ? `${preview.toolName} input` : "Input"}
return (
<ToolActivity expandable={hasExpandedSections}>
<ToolActivityTrigger
additions={diff.additions || undefined}
deletions={diff.deletions || undefined}
icon={
hasError ? (
<AlertCircle className="size-4 text-destructive/80" />
) : (
<Icon className="size-4" />
)
}
label={buildGroupedToolLabel(presentations)}
status={hasError ? "error" : isRunning ? "running" : "success"}
/>
<ToolActivityContent>
{details.length > 0 ? (
<ToolActivityDetails>
{details.map(({ detail, key }) => (
<div key={key}>{detail}</div>
))}
</ToolActivityDetails>
) : null}
{inputPreviews.map((preview) => (
<div className="space-y-1" key={`input_${preview.key}`}>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
{presentations.length > 1
? `${preview.toolName} input`
: "Input"}
</div>
<ToolActivityCode className="text-sm">
{preview.value}
</ToolActivityCode>
</div>
<ToolActivityCode className="text-sm">
))}
{resultPreviews.map((preview) => (
<div
className="mt-1 text-destructive"
key={`result_${preview.key}`}
>
{presentations.length > 1 ? `${preview.toolName}: ` : null}
{preview.value}
</ToolActivityCode>
</div>
))}
{resultPreviews.map((preview) => (
<div className="mt-1 text-destructive" key={`result_${preview.key}`}>
{presentations.length > 1 ? `${preview.toolName}: ` : null}
{preview.value}
</div>
))}
</ToolActivityContent>
</ToolActivity>
);
}
</div>
))}
</ToolActivityContent>
</ToolActivity>
);
},
(prev, next) =>
prev.messages.length === next.messages.length &&
prev.messages.every((message, index) => message === next.messages[index]),
);
@@ -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<ProviderCatalogResponse>(
"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]);
@@ -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();
}
@@ -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);
@@ -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<string, string>());
const pendingStreamReasoningRef = useRef(
new Map<string, { text: string; redacted: boolean }>(),
);
const pendingStreamTranscriptRef = useRef("");
const streamFlushTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
@@ -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<CliDiscoveredSession[]>("list_discovered_sessions", { limit })
@@ -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<ProviderCatalogResponse>;
} | null = null;
export function fetchProviderCatalog(options?: {
fresh?: boolean;
}): Promise<ProviderCatalogResponse> {
const now = Date.now();
if (
!options?.fresh &&
providerCatalogCache &&
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
) {
return providerCatalogCache.promise;
}
const promise = desktopClient
.invoke<ProviderCatalogResponse>("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<ProviderModelCatalog> {
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
"list_provider_catalog",
);
const payload = await fetchProviderCatalog();
return buildProviderModelCatalog(payload.providers ?? []);
}