mirror of
https://github.com/cline/cline.git
synced 2026-08-29 03:52:41 +08:00
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:
@@ -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 |
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user