fix(desktop): show a startup screen with Retry and Export diagnostics when the Hub fails (#15130)

* fix(desktop): start the local Hub after the sidecar is reachable

The sidecar ran Hub setup before starting its server. When the Hub failed
or hung, the webview had nothing to connect to, and every action surfaced
as "desktop backend endpoint not ready" or a confusing sign-in/save error.

Start the server and publish the endpoint first, then run the existing Hub
setup (login-shell PATH, bundled CLI `hub ensure`, session manager) in the
background. Its status (starting, ready, failed) is broadcast as
`hub_status` and replayed to each new connection. Setup that has not
finished after 30 seconds reports failure but keeps running, so a slow Hub
still completes. `retry_hub` retries; commands other than the status,
retry, and diagnostics export wait while startup is in progress, as they
previously waited for the transport. Failures are logged and reported to
telemetry; the user-facing message is fixed text.

* feat(desktop): show a startup screen with Retry and diagnostics export

Until the local Hub is ready, show a spinner instead of the app. If Hub
startup fails, show what happened with Retry and an Export diagnostics
button that uses the existing Hub-independent export. If the desktop
backend itself is unreachable for 30 seconds, say so and offer Reload.

The app mounts the first time the Hub is ready and then stays mounted; the
screen covers it again only if a later Hub startup fails. The Hub update
dialog moves outside this gate so the startup mismatch check cannot fire
before it is listening.

* fix(desktop): address startup screen review feedback

- Replace the app with the startup screen when a later Hub startup fails,
  instead of covering it. Dialogs portal outside the inert wrapper, so an
  open one kept trapping focus and disabling clicks behind the screen. The
  screen now sits below dialogs (z-40) so the Hub update prompt stays on top.
- Render the window title bar on the startup screen so frameless Windows
  windows keep their minimize/maximize/close controls and drag region.
- Drop a command that waited for Hub startup if its connection closed in
  the meantime. Close cleanup has already run by then, so work such as a
  browser sign-in started for that connection could not be cancelled.

* fix(desktop): keep open conversations through a Hub startup failure

Unmounting the app when a later Hub startup failed discarded open threads
and the selected session, and Retry remounted it before the Hub was ready.

Keep the app mounted and hide it with React Activity whenever the Hub is
not ready. Hidden Activity content keeps its state but unmounts effects, so
open dialogs and popovers, including portaled ones, are hidden and release
their focus traps and body pointer-events while the startup screen shows.
The hook also resets to "starting" when the transport drops, so a restarted
sidecar's status is never read from the previous one.

* fix(desktop): resume stream flushing and avoid leaking a manager on retry

- Hiding the app with Activity runs useChatSession's effect cleanup, which
  cleared the stream flush timer but left its ref set. Activity keeps refs,
  so after the app was restored schedulePendingStreamFlush saw the stale id
  and never scheduled again, stalling streamed text. Reset the ref (and the
  turn-end reconcile timer ref) in those cleanups.
- initializeSessionManager installs the local binding before its final
  approval-readiness sync. If that sync failed, a retried Hub startup
  created a second manager and overwrote the binding without disposing the
  first. Reuse the installed binding and only re-run the sync.

* fix(desktop): keep the open remote conversation when the app is shown again

Showing the app again after a Hub restart re-runs Activity effects, so the
get_process_context bootstrap selected a fresh draft for the connected SSH
environment and navigated away from the open conversation. Refresh the
remote environment on every load, but bootstrap navigation only after the
first successful one.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(desktop): reset voice input when the app is hidden mid-recording

Hiding the app with Activity runs SpeechInput's unmount cleanup, which
cancels the recording, but keeps its listening/processing state. The
cancelled recording could no longer clear that state, so the composer
stayed read-only after the Hub recovered.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-10-22 16:46:04 -07:00
committed by GitHub
co-authored by Saoud Rizwan Cursor Agent
parent 96cb5c4c08
commit 24d3a4f49e
23 changed files with 1036 additions and 70 deletions
+1 -1
View File
@@ -32,7 +32,7 @@
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -145,6 +145,29 @@ function processCommandLine(pid: number): string | undefined {
return ps.status === 0 ? ps.stdout.trim() : undefined;
}
function waitForHubStartup(wsEndpoint: string): Promise<string> {
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsEndpoint);
const timer = setTimeout(() => {
socket.close();
reject(new Error("Hub startup did not settle within 30 seconds"));
}, 30_000);
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
const state = message.event?.payload?.state;
if (message.event?.name === "hub_status" && state !== "starting") {
clearTimeout(timer);
socket.close();
resolve(state);
}
};
socket.onerror = () => {
clearTimeout(timer);
reject(new Error(`Could not connect to ${wsEndpoint}`));
};
});
}
async function runStartupScenario(
extraEnv: Record<string, string>,
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
@@ -231,12 +254,16 @@ async function runStartupScenario(
},
);
let endpoint: string | undefined;
let wsEndpoint: string | undefined;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
try {
const message = JSON.parse(line);
if (message.type === "ready") endpoint = message.endpoint;
if (message.type === "ready") {
endpoint = message.endpoint;
wsEndpoint = message.wsEndpoint;
}
} catch {
/* Ignore other output and incomplete lines. */
}
@@ -253,6 +280,11 @@ async function runStartupScenario(
});
expect(health.ok).toBe(true);
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
// The backend publishes its endpoint before starting the Hub and reports
// Hub startup over the transport, replaying the current state on connect.
expect(await waitForHubStartup(String(wsEndpoint)), stderrPath).toBe(
"ready",
);
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
if (existingHub) {
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { handleCommand } from "./commands";
import { HubStartup } from "./hub-startup";
import type { SidecarContext, SidecarWebSocketClient } from "./types";
function contextWith(hubStartup: HubStartup): SidecarContext {
return {
localWorkspaceRoot: "/workspace",
wsClients: new Set(),
runtimeBindings: new Map(),
hubBuildMismatch: null,
hubStartup,
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
}
describe("commands during Hub startup", () => {
it("answers retry without waiting for the Hub", async () => {
const startup = new HubStartup(
() => new Promise(() => {}),
vi.fn(),
vi.fn(),
);
startup.start();
const ctx = contextWith(startup);
await expect(handleCommand(ctx, "retry_hub")).resolves.toEqual({
state: "starting",
});
});
it("holds Hub-backed commands until startup settles", async () => {
let finishSetup!: () => void;
const startup = new HubStartup(
() =>
new Promise<void>((resolve) => {
finishSetup = resolve;
}),
vi.fn(),
vi.fn(),
);
startup.start();
let settled = false;
const pending = handleCommand(
contextWith(startup),
"no_such_command",
).finally(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
finishSetup();
await expect(pending).rejects.toThrow();
expect(settled).toBe(true);
});
it("drops a waiting command whose connection closed during startup", async () => {
let finishSetup!: () => void;
const startup = new HubStartup(
() =>
new Promise<void>((resolve) => {
finishSetup = resolve;
}),
vi.fn(),
vi.fn(),
);
startup.start();
const ctx = contextWith(startup);
const connection = {} as SidecarWebSocketClient;
ctx.wsClients.add(connection);
const pending = handleCommand(
ctx,
"run_provider_oauth_login",
{ providerId: "cline" },
{ connection },
);
ctx.wsClients.delete(connection);
finishSetup();
await expect(pending).rejects.toThrow(
"The connection closed while Cline Hub was starting.",
);
});
});
@@ -133,6 +133,7 @@ import {
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { getHubStatus, restartHub } from "./hub-admin";
import { isHubIndependentCommand } from "./hub-startup";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
@@ -1877,6 +1878,20 @@ export async function handleCommand(
args?: Record<string, unknown>,
options?: { connection?: SidecarWebSocketClient },
): Promise<unknown> {
if (command === "retry_hub") {
ctx.hubStartup?.start();
return ctx.hubStartup?.status ?? null;
}
// The transport is published before the Hub is ready. Hold Hub-backed
// commands until startup settles, as they waited for the transport before.
if (ctx.hubStartup && !isHubIndependentCommand(command)) {
await ctx.hubStartup.waitUntilSettled();
// Its close cleanup already ran, so nothing could cancel work (such as a
// browser sign-in) started for a connection that left while waiting.
if (options?.connection && !ctx.wsClients.has(options.connection)) {
throw new Error("The connection closed while Cline Hub was starting.");
}
}
const explicitEnvironment = requestedEnvironmentId(args);
if (explicitEnvironment) {
ctx = getEnvironmentContext(ctx, explicitEnvironment);
@@ -158,6 +158,23 @@ describe("Code sidecar runtime capabilities", () => {
);
});
it("reuses the installed session manager when a retried startup only failed its readiness sync", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
updateCapabilitiesMock.mockRejectedValueOnce(new Error("hub busy"));
await expect(initializeSessionManager(ctx)).rejects.toThrow("hub busy");
const installed = ctx.runtimeBindings.get("local");
expect(installed).toBeDefined();
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledTimes(1);
expect(ctx.runtimeBindings.get("local")).toBe(installed);
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(2);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -1296,6 +1296,12 @@ async function handleHubApprovalRequest(
export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
// A retried startup must not replace (and leak) a manager that an earlier
// attempt installed before its final readiness sync failed.
if (ctx.runtimeBindings.has(LOCAL_ENVIRONMENT_ID)) {
await syncSidecarApprovalReadiness(ctx);
return;
}
setHomeDirIfUnset(homedir());
const sessionManager = await ClineCore.create({
clientName: "cline-code",
@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HubStartup, type HubStartupStatus } from "./hub-startup";
function deferred() {
let resolve!: () => void;
let reject!: (error: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
afterEach(() => {
vi.useRealTimers();
});
describe("HubStartup", () => {
it("reports ready when setup succeeds and releases waiting commands", async () => {
const setup = deferred();
const statuses: HubStartupStatus[] = [];
const startup = new HubStartup(
() => setup.promise,
(status) => statuses.push(status),
vi.fn(),
);
startup.start();
const waiting = startup.waitUntilSettled();
setup.resolve();
await waiting;
expect(statuses.map((status) => status.state)).toEqual([
"starting",
"ready",
]);
});
it("reports a fixed message and hands the raw error to the failure hook", async () => {
const error = new Error(
"hub exploded at ws://127.0.0.1:25463?token=secret",
);
const onFailure = vi.fn();
const startup = new HubStartup(
() => Promise.reject(error),
vi.fn(),
onFailure,
);
startup.start();
await startup.waitUntilSettled();
expect(onFailure).toHaveBeenCalledWith(error);
expect(startup.status.state).toBe("failed");
expect(startup.status.error).not.toContain("secret");
});
it("fails after the timeout but still becomes ready if the slow setup finishes", async () => {
vi.useFakeTimers();
const setup = deferred();
const onFailure = vi.fn();
const startup = new HubStartup(
() => setup.promise,
vi.fn(),
onFailure,
1_000,
);
startup.start();
await vi.advanceTimersByTimeAsync(1_000);
expect(startup.status).toEqual({
state: "failed",
error: "Cline Hub did not respond within 30 seconds.",
});
expect(onFailure).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("1000ms") }),
);
setup.resolve();
await vi.runAllTimersAsync();
expect(startup.status.state).toBe("ready");
});
it("reuses a pending attempt on retry and starts a new one after it fails", async () => {
vi.useFakeTimers();
const first = deferred();
const run = vi
.fn<() => Promise<void>>()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(undefined);
const startup = new HubStartup(run, vi.fn(), vi.fn(), 1_000);
startup.start();
await vi.advanceTimersByTimeAsync(1_000);
startup.start();
expect(startup.status.state).toBe("starting");
expect(run).toHaveBeenCalledTimes(1);
first.reject(new Error("still broken"));
await vi.runAllTimersAsync();
expect(startup.status.state).toBe("failed");
startup.start();
await vi.runAllTimersAsync();
expect(run).toHaveBeenCalledTimes(2);
expect(startup.status.state).toBe("ready");
});
});
@@ -0,0 +1,84 @@
import type { DesktopHubStatus } from "../webview/lib/desktop-transport";
export type HubStartupStatus = DesktopHubStatus;
const HUB_STARTUP_TIMEOUT_MS = 30_000;
/** Commands the startup screen needs before the Hub is ready. */
const HUB_INDEPENDENT_COMMANDS = new Set(["retry_hub", "export_diagnostics"]);
export function isHubIndependentCommand(command: string): boolean {
return HUB_INDEPENDENT_COMMANDS.has(command);
}
/**
* Runs local Hub setup after the sidecar is already reachable, so the webview
* can show progress, a failure, Retry, and diagnostics export instead of
* waiting on a backend that never comes up.
*
* Only one setup attempt runs at a time. A timeout reports failure but lets
* the attempt keep going: if a slow Hub answers later, startup still
* completes. Retry while an attempt is pending waits on that same attempt.
*/
export class HubStartup {
status: HubStartupStatus = { state: "starting" };
private attempt?: Promise<void>;
private timer?: ReturnType<typeof setTimeout>;
private waiters: Array<() => void> = [];
constructor(
private readonly run: () => Promise<void>,
private readonly onChange: (status: HubStartupStatus) => void,
private readonly onFailure: (error: unknown) => void,
private readonly timeoutMs = HUB_STARTUP_TIMEOUT_MS,
) {}
start(): void {
if (this.status.state === "ready") return;
this.setStatus({ state: "starting" });
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (this.status.state === "starting") {
this.onFailure(
new Error(
`Local Hub startup did not finish within ${this.timeoutMs}ms; still waiting in the background`,
),
);
this.setStatus({
state: "failed",
error: "Cline Hub did not respond within 30 seconds.",
});
}
}, this.timeoutMs);
if (this.attempt) return;
this.attempt = this.run()
.then(
() => this.setStatus({ state: "ready" }),
(error) => {
this.onFailure(error);
this.setStatus({
state: "failed",
error: "Cline couldn't start or connect to its Hub.",
});
},
)
.finally(() => {
this.attempt = undefined;
});
}
/** Resolves once startup is no longer in progress (ready or failed). */
waitUntilSettled(): Promise<void> {
if (this.status.state !== "starting") return Promise.resolve();
return new Promise((resolve) => this.waiters.push(resolve));
}
private setStatus(status: HubStartupStatus): void {
this.status = status;
if (status.state !== "starting") {
clearTimeout(this.timer);
for (const resolve of this.waiters.splice(0)) resolve();
}
this.onChange(status);
}
}
+60 -35
View File
@@ -25,6 +25,7 @@ import {
disposeSidecarContext,
initializeSessionManager,
} from "./context";
import { HubStartup } from "./hub-startup";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
@@ -90,23 +91,43 @@ async function main(desktopCliPath?: string) {
}
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
// The packaged app hands Hub startup to its bundled CLI: it reuses a
// compatible healthy Hub or starts one, and the session manager attaches.
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
// the session manager still starts the Hub through the same CLI launcher.
if (desktopCliPath) {
try {
const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
} catch (error) {
observability.logger.error?.("Cline CLI hub ensure failed", { error });
const setUpLocalHub = async (): Promise<void> => {
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
// The packaged app hands Hub startup to its bundled CLI: it reuses a
// compatible healthy Hub or starts one, and the session manager attaches.
// Afterwards the PATH above is set, so a new Hub inherits it. On failure
// the session manager still starts the Hub through the same CLI launcher.
if (desktopCliPath) {
try {
const hub = await ensureHubWithDesktopCli(
desktopCliPath,
workspaceRoot,
);
observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
} catch (error) {
observability.logger.error?.("Cline CLI hub ensure failed", { error });
}
}
}
await initializeSessionManager(ctx);
await initializeSessionManager(ctx);
checkStartupHubBuildMismatch();
};
ctx.hubStartup = new HubStartup(
setUpLocalHub,
(status) => broadcastEvent(ctx, "hub_status", status),
(error) => {
observability.logger.error?.("Local Hub startup failed", { error });
captureSdkError(observability.telemetry, {
component: "desktop",
operation: "sidecar.hub_startup",
error,
handled: true,
});
},
);
let shuttingDown = false;
let handlingFatalError = false;
@@ -184,16 +205,35 @@ async function main(desktopCliPath?: string) {
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
},
});
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
wsEndpoint.searchParams.set("approval_token", approvalToken);
process.stdout.write(
`${JSON.stringify({
type: "ready",
endpoint,
wsEndpoint: wsEndpoint.toString(),
pid: process.pid,
mode: SIDECAR_MODE,
})}\n`,
);
// The webview is reachable now, so it can show Hub startup progress and
// failures instead of commands timing out against an unpublished backend.
ctx.hubStartup.start();
// The watcher's first check only runs after its interval, but a mismatch
// that already exists at startup - an older Hub this app attached to
// because it is still serving other clients' sessions - must prompt
// before the user starts working, not half a minute in. Session-manager
// init has already settled the hub state, so check once right away. The
// broadcast reaches webviews that are already connected; the replay in
// before the user starts working, not half a minute in. Hub setup has
// settled the hub state by the time this runs. The broadcast reaches
// webviews that are already connected; the replay in
// createWebSocketHandler covers ones that connect later. Skipped when
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
// hosts keep protocol-only compatibility and must not show update prompts.
if (!process.env.CLINE_HUB_PORT?.trim()) {
function checkStartupHubBuildMismatch(): void {
if (process.env.CLINE_HUB_PORT?.trim()) return;
void checkManagedHubBuildMismatch()
.then((mismatch) => {
if (!mismatch || ctx.hubBuildMismatch) {
@@ -212,21 +252,6 @@ async function main(desktopCliPath?: string) {
})
.catch(() => undefined);
}
// A wildcard bind isn't a dialable address; advertise loopback instead.
const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
const endpoint = `http://${dialHost}:${port}`;
const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
wsEndpoint.searchParams.set("approval_token", approvalToken);
process.stdout.write(
`${JSON.stringify({
type: "ready",
endpoint,
wsEndpoint: wsEndpoint.toString(),
pid: process.pid,
mode: SIDECAR_MODE,
})}\n`,
);
}
/**
@@ -364,6 +364,9 @@ export function createWebSocketHandler(ctx: SidecarContext) {
pid: process.pid,
mode: SIDECAR_MODE,
});
if (ctx.hubStartup) {
ws.send(encodeSidecarEvent("hub_status", ctx.hubStartup.status));
}
// Replay a pending mismatch so webviews that connect (or reload)
// after detection still prompt the user to update and restart.
if (ctx.hubBuildMismatch) {
@@ -11,6 +11,7 @@ import type {
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
import type { HubStartup } from "./hub-startup";
export const LOCAL_ENVIRONMENT_ID = "local";
@@ -166,6 +167,8 @@ export type SidecarContext = {
* replayed to webviews that connect after the event fired.
*/
hubBuildMismatch: ManagedHubBuildMismatchEvent | null;
/** Local Hub setup, which runs after the transport is published. */
hubStartup?: HubStartup;
};
export type BunRuntimeApi = {
serve: (options: unknown) => { port: number; stop?: () => void };
+51 -31
View File
@@ -8,6 +8,7 @@ import { AttachmentDropZone } from "@cline/ui";
import { Loader2, LoaderCircle } from "lucide-react";
import dynamic from "next/dynamic";
import {
Activity,
useCallback,
useEffect,
useMemo,
@@ -17,6 +18,7 @@ import {
} from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
import { HubStartupScreen } from "@/components/hub-startup-screen";
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
import { SessionCommandBar } from "@/components/session-command-bar";
import {
@@ -54,11 +56,12 @@ import {
import { AccountProvider, useAccount } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import type { ProcessContext } from "@/hooks/chat-session/types";
import { checkForUpdateAndNotify, useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
import { useHubStartup } from "@/hooks/use-hub-startup";
import { usePendingAttachments } from "@/hooks/use-pending-attachments";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import { useRemoteEnvironmentBootstrap } from "@/hooks/use-remote-environment-bootstrap";
import { useSessionAgents } from "@/hooks/use-session-agents";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
@@ -141,10 +144,7 @@ import {
type WorkIn,
writeWorkInToWindow,
} from "@/lib/work-in-selection";
import {
type RemoteWorkspaceEnvironment,
remoteWorkspaceEnvironmentFromContext,
} from "@/lib/workspace-environment";
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
import {
filterWorkspacePaths,
isTaskWorktreePath,
@@ -299,6 +299,32 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
const startup = useHubStartup();
const hubReady =
startup.transport === "connected" && startup.hub.state === "ready";
// Mount the app once the Hub is first ready, then hide it with Activity
// whenever the Hub is not ready. Hidden Activity content keeps its state
// (open conversations) but unmounts effects, so its dialogs, including
// portaled ones, release focus traps and body pointer-events.
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (hubReady) setLoaded(true);
}, [hubReady]);
return (
<>
{loaded ? (
<Activity mode={hubReady ? "visible" : "hidden"}>
<App />
</Activity>
) : null}
{hubReady ? null : <HubStartupScreen {...startup} />}
{/* Outside the gate: the startup mismatch check can fire before App mounts. */}
<HubUpdateRequiredDialog />
</>
);
}
function App() {
const [initialThreadId] = useState(makeThreadId);
// Outlive keyed chat panes without re-rendering the app on every keystroke.
const { current: promptDrafts } = useRef(new Map<string, string>());
@@ -405,32 +431,27 @@ export default function Home() {
void syncDesktopWindowTitle();
}, []);
useEffect(() => {
let cancelled = false;
const revision = environmentSelectionRevision.current;
desktopClient
.invoke<ProcessContext>("get_process_context")
.then((context) => {
if (!cancelled && revision === environmentSelectionRevision.current) {
const remoteEnvironment =
remoteWorkspaceEnvironmentFromContext(context);
setActiveRemoteEnvironment(remoteEnvironment);
if (remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
useRemoteEnvironmentBootstrap(
environmentSelectionRevision,
useCallback(
(
remoteEnvironment: RemoteWorkspaceEnvironment | null,
isFirstLoad: boolean,
) => {
setActiveRemoteEnvironment(remoteEnvironment);
// Later loads follow a reconnect; a new draft would replace the
// conversation that is still open.
if (isFirstLoad && remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
})
.catch(() => {
// The chat bootstrap reports backend availability separately.
});
return () => {
cancelled = true;
};
}, []);
},
[],
),
);
useEffect(() => {
if (view !== "chat") return;
@@ -1055,7 +1076,6 @@ export default function Home() {
onOpenChange={setExportDiagnosticsOpen}
open={exportDiagnosticsOpen}
/>
<HubUpdateRequiredDialog />
{whatsNew ? (
<WhatsNewDialog
onOpenChange={(open) => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { Activity, act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SpeechInput } from "./speech-input";
@@ -487,6 +487,44 @@ describe("SpeechInput", () => {
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("returns to inactive after an Activity hides it mid-recording", async () => {
const cancel = vi.fn();
const onActiveChange = vi.fn();
const render = (mode: "visible" | "hidden") =>
act(async () => {
root.render(
<Activity mode={mode}>
<SpeechInput
onActiveChange={onActiveChange}
onStartStreaming={async () => ({
done: new Promise<void>(() => {}),
stop: vi.fn(),
cancel,
})}
recordingMode="streaming"
/>
</Activity>,
);
});
await render("visible");
const button = container.querySelector<HTMLButtonElement>(
'[aria-label="Record speech"]',
);
await act(async () => {
button?.click();
await Promise.resolve();
});
expect(button?.getAttribute("aria-label")).toBe("Stop recording");
await render("hidden");
await render("visible");
expect(cancel).toHaveBeenCalledOnce();
expect(onActiveChange).toHaveBeenLastCalledWith(false);
expect(button?.disabled).toBe(false);
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
it("returns to inactive when MediaRecorder rejects stop", async () => {
const stopError = new DOMException("Already stopped", "InvalidStateError");
const onActiveChange = vi.fn();
@@ -297,6 +297,10 @@ export function SpeechInput({
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop();
}
// A hidden Activity keeps this state; the cancelled recording can no
// longer reset it, which would leave the composer read-only.
setIsListening(false);
setIsProcessing(false);
};
}, []);
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { HubStartupScreen } from "./hub-startup-screen";
const invoke = vi.hoisted(() => vi.fn());
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke },
isTauriAvailable: () => false,
}));
const container = document.createElement("div");
document.body.append(container);
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
vi.useRealTimers();
vi.clearAllMocks();
});
function buttons(): string[] {
return [...container.querySelectorAll("button")].map(
(button) => button.textContent ?? "",
);
}
it("shows only a spinner while the Hub is starting", () => {
act(() =>
root.render(
<HubStartupScreen transport="connected" hub={{ state: "starting" }} />,
),
);
expect(container.textContent).toContain("Starting Cline…");
expect(buttons()).toEqual([]);
// The window stays draggable (and closable on frameless Windows) without the app.
expect(
container.querySelector('[data-slot="window-title-bar"]'),
).not.toBeNull();
});
it("offers Retry and diagnostics export when the Hub fails", async () => {
invoke.mockResolvedValue({ path: "/tmp/cline-diagnostics.txt" });
act(() =>
root.render(
<HubStartupScreen
transport="connected"
hub={{ state: "failed", error: "Cline couldn't start its Hub." }}
/>,
),
);
expect(container.textContent).toContain("Cline couldn't connect to its Hub");
expect(buttons()).toEqual(["Retry", "Export diagnostics"]);
const [retry, exportButton] = container.querySelectorAll("button");
act(() => retry.click());
expect(invoke).toHaveBeenCalledWith("retry_hub");
await act(async () => exportButton.click());
expect(invoke).toHaveBeenCalledWith("export_diagnostics");
expect(container.textContent).toContain(
"Diagnostics saved to /tmp/cline-diagnostics.txt",
);
});
it("calls out an unreachable desktop backend after 30 seconds", () => {
vi.useFakeTimers();
act(() =>
root.render(
<HubStartupScreen transport="reconnecting" hub={{ state: "starting" }} />,
),
);
expect(container.textContent).toContain("Starting Cline…");
act(() => vi.advanceTimersByTime(30_000));
expect(container.textContent).toContain(
"Cline's desktop backend isn't responding",
);
expect(buttons()).toEqual(["Reload"]);
});
@@ -0,0 +1,117 @@
"use client";
import { Loader2 } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
WindowTitleBar,
WindowTitleBarProvider,
} from "@/components/window-title-bar";
import type { useHubStartup } from "@/hooks/use-hub-startup";
import { desktopClient } from "@/lib/desktop-client";
const BACKEND_UNRESPONSIVE_MS = 30_000;
/**
* Shown instead of the app while local Hub startup is in progress or has failed, so the
* user gets Retry and a diagnostics export instead of Hub errors from
* whichever feature they tried first.
*/
export function HubStartupScreen({
transport,
hub,
}: ReturnType<typeof useHubStartup>) {
const connected = transport === "connected";
const [backendUnresponsive, setBackendUnresponsive] = useState(false);
const [exportStatus, setExportStatus] = useState<string | null>(null);
// The client keeps reconnecting on its own; only call it out once it has
// been unreachable for a while.
useEffect(() => {
setBackendUnresponsive(false);
if (connected) return;
const timer = setTimeout(
() => setBackendUnresponsive(true),
BACKEND_UNRESPONSIVE_MS,
);
return () => clearTimeout(timer);
}, [connected]);
const exportDiagnostics = async () => {
setExportStatus("Exporting diagnostics…");
try {
const result = await desktopClient.invoke<{ path: string }>(
"export_diagnostics",
);
setExportStatus(`Diagnostics saved to ${result.path}`);
} catch (error) {
setExportStatus(error instanceof Error ? error.message : String(error));
}
};
let content: ReactNode;
if (!connected && backendUnresponsive) {
content = (
<>
<h1 className="text-base font-medium">
Cline's desktop backend isn't responding
</h1>
<p className="text-sm text-muted-foreground">
Cline keeps trying in the background. Reload to try again now.
</p>
<Button onClick={() => window.location.reload()}>Reload</Button>
</>
);
} else if (connected && hub.state === "failed") {
content = (
<>
<h1 className="text-base font-medium">
Cline couldn't connect to its Hub
</h1>
<p className="text-sm text-muted-foreground">
{hub.error} Retry, or export diagnostics and share the file with the
Cline team if this keeps happening.
</p>
<div className="flex gap-2">
<Button onClick={() => void desktopClient.invoke("retry_hub")}>
Retry
</Button>
<Button variant="outline" onClick={() => void exportDiagnostics()}>
Export diagnostics
</Button>
</div>
{exportStatus ? (
<p className="break-all text-xs text-muted-foreground">
{exportStatus}
</p>
) : null}
</>
);
} else {
content = (
<>
<Loader2
aria-hidden="true"
className="size-6 animate-spin text-muted-foreground"
/>
<output className="text-sm text-muted-foreground">
Starting Cline…
</output>
</>
);
}
// Below dialogs (z-50) so the Hub update prompt stays on top. The title bar
// keeps the drag region and, on frameless Windows, the window controls.
return (
<main className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
<WindowTitleBarProvider contentEnabled={false}>
<WindowTitleBar
className="absolute inset-x-0 top-0"
hostContent={false}
/>
</WindowTitleBarProvider>
<div className="flex max-w-md flex-col items-center gap-4">{content}</div>
</main>
);
}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, StrictMode } from "react";
import { Activity, act, StrictMode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -433,6 +433,84 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("keeps flushing streamed text after the app is hidden and restored", async () => {
vi.useFakeTimers();
try {
const sendResponse = deferred<unknown>();
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
const request = args?.request as
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (command === "get_process_context") {
return {
environmentId: "local",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
taskWorktreeRoot: "/home/host/cline-dir/worktrees",
};
}
if (command === "chat_session_command") {
if (request?.action === "start")
return { sessionId: request.config?.sessionId };
if (request?.action === "send") return await sendResponse.promise;
return { ok: true, prompts: [] };
}
return [];
},
);
const renderIn = (mode: "visible" | "hidden") =>
act(async () =>
root.render(
<Activity mode={mode}>
<HookHarness />
</Activity>,
),
);
const latestChatHandler = () =>
subscribeMock.mock.calls
.filter(([eventName]) => eventName === "chat_event")
.at(-1)?.[1] as (payload: unknown) => void;
await renderIn("visible");
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
void current.sendPrompt("Say hello");
for (let i = 0; i < 5; i += 1) await Promise.resolve();
});
const sessionId = current.sessionId;
await act(async () => {
latestChatHandler()({
sessionId,
stream: "chat_text",
chunk: "Hel",
ts: 1,
index: 1,
});
});
// Hiding runs effect cleanups while the pending flush timer is armed.
await renderIn("hidden");
await renderIn("visible");
await act(async () => {
latestChatHandler()({
sessionId,
stream: "chat_text",
chunk: "lo",
ts: 2,
index: 2,
});
await vi.advanceTimersByTimeAsync(200);
});
expect(
current.messages.filter((message) => message.role === "assistant"),
).toEqual([expect.objectContaining({ content: "Hello" })]);
} finally {
vi.useRealTimers();
}
});
it("keeps a remounted pane's newer draft when the original session start rejects", async () => {
const drafts = new Map<string, string>();
let draft!: ReturnType<typeof usePromptDraft>;
@@ -1074,6 +1074,7 @@ export function useChatSession(environmentId: string) {
return () => {
if (turnEndReconcileTimerRef.current !== null) {
clearTimeout(turnEndReconcileTimerRef.current);
turnEndReconcileTimerRef.current = null;
}
};
}, []);
@@ -1419,6 +1420,9 @@ export function useChatSession(environmentId: string) {
return () => {
if (streamFlushTimerRef.current !== null) {
clearTimeout(streamFlushTimerRef.current);
// The app can be hidden and restored (Activity) with refs intact; a
// stale id would make schedulePendingStreamFlush skip every flush.
streamFlushTimerRef.current = null;
}
};
}, []);
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { useHubStartup } from "./use-hub-startup";
const mocks = vi.hoisted(() => ({
transportHandler: undefined as ((state: string) => void) | undefined,
hubHandler: undefined as ((payload: unknown) => void) | undefined,
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: {
getTransportState: () => "connected",
subscribeTransportState: (handler: (state: string) => void) => {
mocks.transportHandler = handler;
return () => {};
},
subscribe: (_name: string, handler: (payload: unknown) => void) => {
mocks.hubHandler = handler;
return () => {};
},
},
}));
const container = document.createElement("div");
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
});
function Probe() {
const { transport, hub } = useHubStartup();
return <span>{`${transport}:${hub.state}`}</span>;
}
it("forgets the previous sidecar's ready status when the transport drops", () => {
act(() => root.render(<Probe />));
act(() => mocks.hubHandler?.({ state: "ready" }));
expect(container.textContent).toBe("connected:ready");
act(() => mocks.transportHandler?.("reconnecting"));
expect(container.textContent).toBe("reconnecting:starting");
// The new sidecar has not replayed its status yet.
act(() => mocks.transportHandler?.("connected"));
expect(container.textContent).toBe("connected:starting");
act(() => mocks.hubHandler?.({ state: "ready" }));
expect(container.textContent).toBe("connected:ready");
});
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { desktopClient } from "@/lib/desktop-client";
import type {
DesktopHubStatus,
DesktopTransportState,
} from "@/lib/desktop-transport";
/**
* The sidecar sends `hub_status` whenever local Hub startup changes and
* replays it to every new connection, so subscribing is enough.
*/
export function useHubStartup() {
const [transport, setTransport] = useState<DesktopTransportState>(
desktopClient.getTransportState(),
);
const [hub, setHub] = useState<DesktopHubStatus>({ state: "starting" });
useEffect(
() =>
desktopClient.subscribeTransportState((next) => {
setTransport(next);
// A restarted sidecar replays its own status on connect; never reuse
// the previous sidecar's "ready".
if (next !== "connected") setHub({ state: "starting" });
}),
[],
);
useEffect(
() =>
desktopClient.subscribe("hub_status", (payload) => {
if (payload && typeof payload === "object" && "state" in payload) {
setHub(payload as DesktopHubStatus);
}
}),
[],
);
return { transport, hub };
}
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import {
Activity,
act,
useCallback,
useReducer,
useRef,
useState,
} from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import {
createDesktopAppState,
type DesktopAppAction,
desktopAppReducer,
} from "@/lib/desktop-app-state";
import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
import { useRemoteEnvironmentBootstrap } from "./use-remote-environment-bootstrap";
const invoke = vi.hoisted(() => vi.fn());
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
const container = document.createElement("div");
let root = createRoot(container);
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
act(() => root.unmount());
root = createRoot(container);
vi.clearAllMocks();
nextDraft = 0;
});
let nextDraft = 0;
let dispatchApp: (action: DesktopAppAction<string>) => void;
function Probe() {
const revision = useRef(0);
const [state, dispatch] = useReducer(
desktopAppReducer<string>,
createDesktopAppState("draft-0", "General", "local"),
);
dispatchApp = dispatch;
const [environment, setEnvironment] =
useState<RemoteWorkspaceEnvironment | null>(null);
useRemoteEnvironmentBootstrap(
revision,
useCallback(
(remote: RemoteWorkspaceEnvironment | null, isFirstLoad: boolean) => {
setEnvironment(remote);
if (isFirstLoad && remote) {
dispatch({
type: "select-environment-draft",
threadId: `draft-${++nextDraft}`,
environmentId: remote.id,
});
}
},
[],
),
);
return (
<span>{`${environment?.id ?? "local"}:${state.navigation.current.activeThreadId}`}</span>
);
}
function render(mode: "visible" | "hidden") {
return act(async () =>
root.render(
<Activity mode={mode}>
<Probe />
</Activity>,
),
);
}
it("keeps an open remote conversation when the app is shown again", async () => {
invoke.mockResolvedValue({
remoteEnvironment: { id: "ssh-1" },
homeDir: "/home/me",
});
await render("visible");
expect(container.textContent).toBe("ssh-1:draft-1");
act(() =>
dispatchApp({
type: "thread-started",
threadId: "draft-1",
sessionId: "session-1",
}),
);
await render("hidden");
await render("visible");
expect(invoke).toHaveBeenCalledTimes(2);
expect(container.textContent).toBe("ssh-1:draft-1");
});
it("bootstraps navigation on the first load that succeeds", async () => {
invoke.mockRejectedValueOnce(new Error("Hub starting")).mockResolvedValue({
remoteEnvironment: { id: "ssh-1" },
homeDir: "/home/me",
});
await render("visible");
expect(container.textContent).toBe("local:draft-0");
await render("hidden");
await render("visible");
expect(container.textContent).toBe("ssh-1:draft-1");
});
@@ -0,0 +1,43 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import type { ProcessContext } from "@/hooks/chat-session/types";
import { desktopClient } from "@/lib/desktop-client";
import {
type RemoteWorkspaceEnvironment,
remoteWorkspaceEnvironmentFromContext,
} from "@/lib/workspace-environment";
/**
* Reads the remote environment the sidecar is connected to. The app is hidden
* with Activity while the Hub restarts, and showing it again re-runs this
* effect, so `isFirstLoad` lets the caller refresh the environment on every
* load but only bootstrap navigation once.
*/
export function useRemoteEnvironmentBootstrap(
selectionRevision: RefObject<number>,
onLoad: (
environment: RemoteWorkspaceEnvironment | null,
isFirstLoad: boolean,
) => void,
): void {
const loadedRef = useRef(false);
useEffect(() => {
let cancelled = false;
const revision = selectionRevision.current;
desktopClient
.invoke<ProcessContext>("get_process_context")
.then((context) => {
if (cancelled || revision !== selectionRevision.current) return;
const isFirstLoad = !loadedRef.current;
loadedRef.current = true;
onLoad(remoteWorkspaceEnvironmentFromContext(context), isFirstLoad);
})
.catch(() => {
// The chat bootstrap reports backend availability separately.
});
return () => {
cancelled = true;
};
}, [selectionRevision, onLoad]);
}
@@ -33,6 +33,13 @@ export type DesktopTransportMessage =
| DesktopTransportResponse
| DesktopTransportEvent;
/** Local Hub startup, sent as the `hub_status` event. */
export type DesktopHubStatus = {
state: "starting" | "ready" | "failed";
/** User-facing summary. Raw errors go to the log and diagnostics report. */
error?: string;
};
export type DesktopTransportState =
| "connecting"
| "reconnecting"