diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index b7c33fa228..2d08846e57 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -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)" diff --git a/apps/examples/desktop-app/scripts/desktop-startup.test.ts b/apps/examples/desktop-app/scripts/desktop-startup.test.ts index b7d5567260..106a059ca7 100644 --- a/apps/examples/desktop-app/scripts/desktop-startup.test.ts +++ b/apps/examples/desktop-app/scripts/desktop-startup.test.ts @@ -145,6 +145,29 @@ function processCommandLine(pid: number): string | undefined { return ps.status === 0 ? ps.stdout.trim() : undefined; } +function waitForHubStartup(wsEndpoint: string): Promise { + 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, 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) { diff --git a/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts b/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts new file mode 100644 index 0000000000..f3382eeec1 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts @@ -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((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((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.", + ); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 448c9f526a..2059311041 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -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, options?: { connection?: SidecarWebSocketClient }, ): Promise { + 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); diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index 4e7d9d6e4a..ba3d0bafe9 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -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" diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index e872b7c0dd..ddbcc6bfd6 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -1296,6 +1296,12 @@ async function handleHubApprovalRequest( export async function initializeSessionManager( ctx: SidecarContext, ): Promise { + // 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", diff --git a/apps/examples/desktop-app/sidecar/hub-startup.test.ts b/apps/examples/desktop-app/sidecar/hub-startup.test.ts new file mode 100644 index 0000000000..74f3e9bcf9 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/hub-startup.test.ts @@ -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((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>() + .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"); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/hub-startup.ts b/apps/examples/desktop-app/sidecar/hub-startup.ts new file mode 100644 index 0000000000..c2c2334caa --- /dev/null +++ b/apps/examples/desktop-app/sidecar/hub-startup.ts @@ -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; + private timer?: ReturnType; + private waiters: Array<() => void> = []; + + constructor( + private readonly run: () => Promise, + 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 { + 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); + } +} diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index 5fbb67d9da..4ff4b9a993 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -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 => { + 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`, - ); } /** diff --git a/apps/examples/desktop-app/sidecar/server.ts b/apps/examples/desktop-app/sidecar/server.ts index 882ae10c2f..9330a601b3 100644 --- a/apps/examples/desktop-app/sidecar/server.ts +++ b/apps/examples/desktop-app/sidecar/server.ts @@ -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) { diff --git a/apps/examples/desktop-app/sidecar/types.ts b/apps/examples/desktop-app/sidecar/types.ts index 604777a56c..fcc35aaa60 100644 --- a/apps/examples/desktop-app/sidecar/types.ts +++ b/apps/examples/desktop-app/sidecar/types.ts @@ -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 }; diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 759261eaa8..e330c095c3 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -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 ? ( + + + + ) : null} + {hubReady ? null : } + {/* Outside the gate: the startup mismatch check can fire before App mounts. */} + + + ); +} + +function App() { const [initialThreadId] = useState(makeThreadId); // Outlive keyed chat panes without re-rendering the app on every keystroke. const { current: promptDrafts } = useRef(new Map()); @@ -405,32 +431,27 @@ export default function Home() { void syncDesktopWindowTitle(); }, []); - useEffect(() => { - let cancelled = false; - const revision = environmentSelectionRevision.current; - desktopClient - .invoke("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} /> - {whatsNew ? ( { diff --git a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.test.tsx b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.test.tsx index f05bf8bd59..11e6ed49f0 100644 --- a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.test.tsx +++ b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.test.tsx @@ -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( + + ({ + done: new Promise(() => {}), + stop: vi.fn(), + cancel, + })} + recordingMode="streaming" + /> + , + ); + }); + await render("visible"); + const button = container.querySelector( + '[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(); diff --git a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx index 6f0080a754..bed2a61ea5 100644 --- a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx +++ b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx @@ -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); }; }, []); diff --git a/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx b/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx new file mode 100644 index 0000000000..b3678a9652 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx @@ -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( + , + ), + ); + 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( + , + ), + ); + 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( + , + ), + ); + 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"]); +}); diff --git a/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx b/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx new file mode 100644 index 0000000000..eb80a5dbc3 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx @@ -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) { + const connected = transport === "connected"; + const [backendUnresponsive, setBackendUnresponsive] = useState(false); + const [exportStatus, setExportStatus] = useState(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 = ( + <> +

+ Cline's desktop backend isn't responding +

+

+ Cline keeps trying in the background. Reload to try again now. +

+ + + ); + } else if (connected && hub.state === "failed") { + content = ( + <> +

+ Cline couldn't connect to its Hub +

+

+ {hub.error} Retry, or export diagnostics and share the file with the + Cline team if this keeps happening. +

+
+ + +
+ {exportStatus ? ( +

+ {exportStatus} +

+ ) : null} + + ); + } else { + content = ( + <> +