From 419829e9d09e6d573a74f51e8fac55f61786f8b6 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:16:11 -0700 Subject: [PATCH] feat(desktop): integrate SSH remote environments (#14117) * feat(core): add SSH remote environments * feat(desktop): integrate SSH remote environments * fix(core): harden SSH lifecycle and helper package exports * fix(desktop): correct SSH credentials, history and packaging * fix(core): use current SSH identity for pending cleanup * docs(core): clarify SSH destination invariants during cleanup * fix(desktop): restore remote history before reopening SSH sessions * fix(core): recover SSH cleanup after remote Hub crashes * test(core): make SSH regression coverage portable on Windows * fix(core): leave account connectors untouched by SSH Hubs * fix(core): restore missing SSH helpers for pending cleanup * fix(desktop): route remote detach and refresh SSH profiles * UI clean up * fix(desktop): open remote settings from environment selector * fix(desktop): find bundled SSH helpers in Tauri's Linux resource directory Tauri's deb, rpm, and AppImage bundles install binaries under usr/bin and resources under usr/lib/. The sidecar only probed the macOS and Windows layouts, so packaged Linux builds could not locate the remote helper and every SSH connect failed with "no compatible remote helper". * fix(desktop): drive the environment selector from the thread's environment The selector showed the globally connected environment while prompts were routed to the active thread's environment, so navigating Back from an SSH draft to a local draft displayed SSH while sending to the local machine. Re-selecting the already-connected host now just opens its draft instead of tearing down and rebuilding the remote runtime. * build(desktop): bundle only Linux remote helpers The macOS helpers land in Contents/Resources as Mach-O files. Tauri only codesigns frameworks, externalBin, and the main binaries, and an unsigned Mach-O anywhere in the bundle fails notarization, so shipping them would break the next desktop-publish run. Linux x64 and arm64 cover common SSH hosts; macOS targets can still use CLINE_REMOTE_HELPER_BINARY. * style(desktop): format Linux remote helper test --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Co-authored-by: Saoud Rizwan --- apps/examples/desktop-app/README.md | 61 +- apps/examples/desktop-app/bun.mts | 2 +- .../desktop-app/scripts/build-sidecar-bin.ts | 55 +- .../desktop-app/scripts/verify-ssh-poc.ts | 182 ++++ .../desktop-app/sidecar/chat-session.test.ts | 327 ++++-- .../desktop-app/sidecar/chat-session.ts | 422 ++++++-- .../sidecar/cloud-sessions-lifecycle.test.ts | 6 +- .../sidecar/commands-hub-upgrade.test.ts | 2 +- apps/examples/desktop-app/sidecar/commands.ts | 948 ++++++++++++++--- .../desktop-app/sidecar/context.test.ts | 55 +- apps/examples/desktop-app/sidecar/context.ts | 377 +++++-- apps/examples/desktop-app/sidecar/index.ts | 9 +- apps/examples/desktop-app/sidecar/mcp.test.ts | 16 +- .../remote-environment-commands.test.ts | 950 ++++++++++++++++++ .../desktop-app/sidecar/remote-helper.test.ts | 53 + .../desktop-app/sidecar/remote-helper.ts | 46 + .../remote-session-credentials.test.ts | 266 +++++ .../sidecar/restore-checkpoint.test.ts | 42 +- .../sidecar/session-data/messages.ts | 25 +- .../sidecar/session-data/search.ts | 4 +- .../desktop-app/sidecar/shell-path.test.ts | 250 ----- .../desktop-app/sidecar/shell-path.ts | 239 ----- apps/examples/desktop-app/sidecar/types.ts | 24 +- .../desktop-app/src-tauri/tauri.conf.json | 6 +- .../desktop-app/webview/app/globals.css | 3 +- .../examples/desktop-app/webview/app/page.tsx | 519 +++++++++- .../webview/components/agent-sidebar.tsx | 2 + .../views/chat/chat-input-bar.test.tsx | 23 + .../components/views/chat/chat-input-bar.tsx | 25 +- .../components/views/chat/diff-view.test.tsx | 20 +- .../components/views/chat/diff-view.tsx | 14 +- .../views/chat/environment-selector.test.tsx | 211 ++++ .../views/chat/environment-selector.tsx | 200 ++++ .../chat/remote-directory-picker.test.tsx | 145 +++ .../views/chat/remote-directory-picker.tsx | 229 +++++ .../views/chat/welcome-chat.test.tsx | 30 +- .../components/views/chat/welcome-chat.tsx | 5 +- .../views/sessions/sessions-view.test.tsx | 1 + .../remote-environments-view.test.tsx | 178 ++++ .../settings/remote-environments-view.tsx | 685 +++++++++++++ .../components/views/settings/sections.ts | 1 + .../views/settings/settings-view.tsx | 5 +- .../webview/hooks/chat-session/constants.ts | 11 +- .../webview/hooks/chat-session/types.ts | 13 + .../webview/hooks/use-chat-session.test.tsx | 210 +++- .../webview/hooks/use-chat-session.ts | 221 ++-- .../webview/hooks/use-session-agents.test.tsx | 11 +- .../webview/hooks/use-session-agents.ts | 23 +- .../hooks/use-session-history.test.tsx | 86 +- .../webview/hooks/use-session-history.ts | 143 ++- .../desktop-app/webview/lib/chat-schema.ts | 1 + .../webview/lib/desktop-app-state.test.ts | 187 +++- .../webview/lib/desktop-app-state.ts | 98 +- .../webview/lib/desktop-notifications.test.ts | 19 + .../webview/lib/desktop-notifications.ts | 47 +- .../webview/lib/remote-environments.test.ts | 59 ++ .../webview/lib/remote-environments.ts | 149 +++ .../webview/lib/session-history.ts | 6 + .../webview/lib/session-identity.ts | 16 + .../webview/lib/workspace-environment.test.ts | 30 + .../webview/lib/workspace-environment.ts | 16 + .../webview/lib/workspace-paths.test.ts | 209 +++- .../webview/lib/workspace-paths.ts | 65 +- 63 files changed, 7034 insertions(+), 1249 deletions(-) create mode 100644 apps/examples/desktop-app/scripts/verify-ssh-poc.ts create mode 100644 apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts create mode 100644 apps/examples/desktop-app/sidecar/remote-helper.test.ts create mode 100644 apps/examples/desktop-app/sidecar/remote-helper.ts create mode 100644 apps/examples/desktop-app/sidecar/remote-session-credentials.test.ts delete mode 100644 apps/examples/desktop-app/sidecar/shell-path.test.ts delete mode 100644 apps/examples/desktop-app/sidecar/shell-path.ts create mode 100644 apps/examples/desktop-app/webview/components/views/chat/environment-selector.test.tsx create mode 100644 apps/examples/desktop-app/webview/components/views/chat/environment-selector.tsx create mode 100644 apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.test.tsx create mode 100644 apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.tsx create mode 100644 apps/examples/desktop-app/webview/components/views/settings/remote-environments-view.test.tsx create mode 100644 apps/examples/desktop-app/webview/components/views/settings/remote-environments-view.tsx create mode 100644 apps/examples/desktop-app/webview/lib/remote-environments.test.ts create mode 100644 apps/examples/desktop-app/webview/lib/remote-environments.ts create mode 100644 apps/examples/desktop-app/webview/lib/session-identity.ts create mode 100644 apps/examples/desktop-app/webview/lib/workspace-environment.test.ts create mode 100644 apps/examples/desktop-app/webview/lib/workspace-environment.ts diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index 0a7d7f60f8..5bbb9389be 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -124,7 +124,64 @@ agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`, API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set `CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details: -[`sidecar/shell-path.ts`](./sidecar/shell-path.ts). +[`core shell-path.ts`](../../../sdk/packages/core/src/remote/shell-path.ts). + +## SSH Remote Environments + +Open **Settings → Remote** to add and test an SSH host. Saving or testing a +profile does not activate it. From the welcome chat, open the environment +selector beside the workspace picker and choose the saved host; that selection +starts the SSH connection at the remote user's home directory. Choose **Add +project…** from the normal workspace selector to browse that machine and select +a project, or choose **Local** in the environment selector to disconnect. Recent +and last-used workspaces are remembered separately for each SSH host and for +the local machine. + +SSH config aliases are supported. Leave **Port** blank to use the alias's SSH +configuration (including its configured port), or enter a port to override it. +The desktop keeps its webview and native integration local; only the +authenticated Cline Hub protocol is forwarded through SSH. Agent tools, +workspace discovery, Git metadata, and session persistence therefore run on the +SSH host, while approvals and live session events return to the desktop. + +The shared `@cline/core` `RemoteEnvironmentService` owns this feature; other +clients can use the same service and `ClineCore` remote backend (see `sdk/DOC.md`). +Desktop owns the settings UI and packaged helper resource lookup. + +The service stores host metadata at +`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an +identity-file path, never private-key contents. On first connect it uploads a +content-addressed, branch-matched, self-contained Hub helper under +`~/.cline/remote/`, binds the Hub to remote loopback, and forwards it to a +random local loopback port. Linux x64 and arm64 helpers are bundled by +`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not +supported. macOS SSH targets need a locally built helper passed through +`CLINE_REMOTE_HELPER_BINARY` until the bundled helpers are codesigned for +notarization. The helper includes its own runtime. It is copied once per matching desktop build and cached, with no +`apt`, `npm`, root access, +global CLI install, or public Hub port. Disconnecting stops the desktop-owned +remote Hub but leaves the helper cached for a faster reconnect. The helper +imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and +MCP executables remain visible. + +Each service instance uses its own discovery record, so an existing Cline CLI/Hub on the +same account is neither replaced nor stopped. Both Hub processes can coexist +while the desktop is connected; this isolation keeps the remote helper separate from the default CLI Hub. + +The desktop currently leaves file attachments and opening a remote file in a local +editor disabled. Text, images, file mentions/search, Git branch operations, +session history, and remote agent tools are supported. The current desktop +provider access/API token is sent through the authenticated tunnel for the +session; reusable OAuth refresh credentials are not copied into remote provider +settings. + +For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts +`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`, +`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote +connection at the SSH user's home, starts an agent session in the test +workspace with the selected desktop provider, asks the agent to read +`REMOTE_MARKER.txt`, then verifies the session appears in remote history and +that its messages can be read back. ## Web Visual System @@ -289,3 +346,5 @@ credentials, request headers, recorded audio, or transcript contents. The sidecar mints a short-lived transcription token; the long-lived gateway credential is never sent to the webview. Batch models such as `openai/whisper-1` continue to transcribe after recording stops. + +SSH requires an already-trusted host key. Before first connection, verify the server fingerprint through a trusted channel and enroll it with your SSH client. Unknown or changed keys are rejected. diff --git a/apps/examples/desktop-app/bun.mts b/apps/examples/desktop-app/bun.mts index ec8dc25459..d5bd117dcd 100644 --- a/apps/examples/desktop-app/bun.mts +++ b/apps/examples/desktop-app/bun.mts @@ -1,8 +1,8 @@ import { $ } from "bun"; const main = async () => { - await $`next build`.cwd("webview"); await $`bun run build:sidecar:bin`; + await $`next build`.cwd("webview"); }; main().catch((error: unknown) => { diff --git a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts index 17ac5c68ff..1b64c2986c 100644 --- a/apps/examples/desktop-app/scripts/build-sidecar-bin.ts +++ b/apps/examples/desktop-app/scripts/build-sidecar-bin.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { $ } from "bun"; import { telemetryDefineArgs } from "./telemetry-define-args"; @@ -38,22 +39,57 @@ const sidecarOutfile = (targetTriple: string): string => { return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`; }; -const buildSidecar = async (targetTriple: string): Promise => { - const outfile = sidecarOutfile(targetTriple); +const buildSidecar = async ( + targetTriple: string, + outfile = sidecarOutfile(targetTriple), + entrypoint = "./sidecar/index.ts", + minify = false, +): Promise => { const bunTarget = resolveBunCompileTarget(targetTriple); // Telemetry config must be inlined into the compiled binary: a packaged // app launched from Finder/the Dock has no OTEL_* env at runtime, so // without this the sidecar silently ships with telemetry disabled. // Verify with ` --telemetry-selfcheck` after building. const defines = telemetryDefineArgs(); + const optimizationArgs = minify ? ["--minify"] : []; + // A compiled Bun executable otherwise reads .env and bunfig.toml from its + // launch directory before our entrypoint runs. Remote helpers are launched + // from an SSH user's home directory, so that behavior can both make the + // helper fail on an unrelated dotenv file and leak workspace credentials + // into the Hub process. Packaged binaries must depend only on their explicit + // process environment and compiled configuration. + const runtimeIsolationArgs = [ + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + ]; if (bunTarget) { - await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`; + await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`; } else { - await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`; + await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`; } return outfile; }; +// SSH environments run the same Hub build as the desktop in a dedicated +// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server, +// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts. +// macOS helpers are deliberately not bundled: they are Mach-O files under +// Contents/Resources, which Tauri does not codesign, and any unsigned Mach-O +// in the bundle fails notarization. Shipping them needs a signing step first. +const buildRemoteHelpers = async (): Promise => { + for (const targetTriple of [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + ]) { + await buildSidecar( + targetTriple, + `./src-tauri/bin/remote-helpers/cline-remote-helper-${targetTriple}`, + "../../../sdk/packages/core/dist/remote/remote-helper-entry.js", + true, + ); + } +}; + // Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself // but expects sidecars (externalBin) to already be fat binaries named // `-universal-apple-darwin`, so build both slices and merge them here. @@ -67,13 +103,18 @@ const buildUniversalMacSidecar = async (): Promise => { }; const main = async () => { + // All compiled helpers and the sidecar depend on fresh SDK package exports. + await $`bun run build:sdk`.cwd( + fileURLToPath(new URL("../../../../", import.meta.url)), + ); const targetTriple = await resolveTargetTriple(); - await $`mkdir -p src-tauri/bin`; + await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`; if (targetTriple === "universal-apple-darwin") { await buildUniversalMacSidecar(); - return; + } else { + await buildSidecar(targetTriple); } - await buildSidecar(targetTriple); + await buildRemoteHelpers(); }; main().catch((error: unknown) => { diff --git a/apps/examples/desktop-app/scripts/verify-ssh-poc.ts b/apps/examples/desktop-app/scripts/verify-ssh-poc.ts new file mode 100644 index 0000000000..414e980588 --- /dev/null +++ b/apps/examples/desktop-app/scripts/verify-ssh-poc.ts @@ -0,0 +1,182 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ClineCore, + ProviderSettingsManager, + RemoteEnvironmentService, + RuntimeOAuthTokenManager, + resolveProviderApiKeyFromSettings, + SessionSource, + toProviderConfig, +} from "@cline/core"; + +const required = (name: string): string => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +}; + +async function main(): Promise { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-")); + const service = new RemoteEnvironmentService({ + profilesPath: join(temporaryDirectory, "remote-environments.json"), + helperBinaryPath: required("CLINE_SSH_TEST_HELPER"), + knownHostsPath: process.env.CLINE_SSH_TEST_KNOWN_HOSTS?.trim() || undefined, + commandTimeoutMs: 60_000, + uploadTimeoutMs: 5 * 60_000, + }); + let core: ClineCore | undefined; + + try { + const helperPath = required("CLINE_SSH_TEST_HELPER"); + const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE"); + const profile = await service.upsert({ + name: "SSH proof host", + host: required("CLINE_SSH_TEST_HOST"), + user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined, + identityFile: required("CLINE_SSH_TEST_KEY"), + }); + const connection = await service.connect(profile.id); + const marker = await service.run(profile.id, { + command: "sed", + args: ["-n", "1p", "REMOTE_MARKER.txt"], + cwd: workspaceRoot, + }); + + const providerSettings = new ProviderSettingsManager(); + const stored = providerSettings.read(); + const providerId = stored.lastUsedProvider; + if (!providerId) + throw new Error("No configured desktop provider is available"); + const settings = providerSettings.getProviderSettings(providerId); + if (!settings) + throw new Error(`No settings found for provider ${providerId}`); + const modelId = settings.model || "meta/muse-spark-1.2"; + const oauth = await new RuntimeOAuthTokenManager({ + providerSettingsManager: providerSettings, + }).resolveProviderApiKey({ providerId }); + const apiKey = + oauth?.apiKey || + resolveProviderApiKeyFromSettings(providerSettings, providerId); + if (!apiKey) + throw new Error(`No credential found for provider ${providerId}`); + + const providerConfig = { + ...toProviderConfig( + { + ...(providerSettings.getProviderSettings(providerId) ?? settings), + model: modelId, + }, + { includeKnownModels: false }, + ), + }; + delete providerConfig.refreshToken; + providerConfig.apiKey = apiKey; + providerConfig.accessToken = apiKey; + + core = await ClineCore.create({ + clientName: "cline-code", + backendMode: "remote", + remote: { + endpoint: connection.endpoint, + authToken: connection.authToken, + workspaceRoot: connection.workspaceRoot, + cwd: connection.workspaceRoot, + clientType: "code-sidecar-ssh", + }, + }); + const eventNames: string[] = []; + const unsubscribe = core.subscribe((event) => { + eventNames.push(event.type); + }); + const started = await core.start({ + config: { + providerId, + modelId, + apiKey, + providerConfig, + workspaceRoot, + cwd: workspaceRoot, + systemPrompt: "", + mode: "act", + enableTools: true, + enableSpawnAgent: false, + enableAgentTeams: false, + }, + source: SessionSource.DESKTOP, + interactive: true, + toolPolicies: { "*": { autoApprove: true } }, + }); + const result = await core.send({ + sessionId: started.sessionId, + prompt: + "Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.", + }); + const sessions = await core.list(20, { hydrate: false }); + const messages = await core.readMessages(started.sessionId); + unsubscribe(); + await core.dispose("desktop_ssh_proof_reconnect"); + core = undefined; + await service.disconnect(profile.id); + + const reconnected = await service.connect(profile.id); + core = await ClineCore.create({ + clientName: "cline-code", + backendMode: "remote", + remote: { + endpoint: reconnected.endpoint, + authToken: reconnected.authToken, + workspaceRoot: reconnected.workspaceRoot, + cwd: reconnected.workspaceRoot, + clientType: "code-sidecar-ssh", + }, + }); + const sessionsAfterReconnect = await core.list(20, { hydrate: false }); + const messagesAfterReconnect = await core.readMessages(started.sessionId); + + const resultText = result?.text ?? ""; + const report = { + connected: true, + remote: `${connection.platform}/${connection.arch}`, + connectionRoot: connection.workspaceRoot, + workspaceRoot: started.manifest.workspace_root, + sessionId: started.sessionId, + listContainsSession: sessions.some( + (session) => session.sessionId === started.sessionId, + ), + messageCount: messages.length, + reconnected: true, + reconnectListContainsSession: sessionsAfterReconnect.some( + (session) => session.sessionId === started.sessionId, + ), + reconnectMessageCount: messagesAfterReconnect.length, + helperBytes: (await stat(helperPath)).size, + sshMarker: marker.stdout.trim(), + agentText: resultText, + agentObservedMarker: resultText.includes("remote workspace proof"), + eventNames: [...new Set(eventNames)], + }; + if ( + report.sshMarker !== "remote workspace proof" || + !report.agentObservedMarker || + !report.listContainsSession || + !report.reconnectListContainsSession || + report.messageCount < 2 || + report.reconnectMessageCount < 2 || + !report.eventNames.includes("agent_event") + ) { + throw new Error(`SSH proof failed: ${JSON.stringify(report)}`); + } + process.stdout.write(`${JSON.stringify(report)}\n`); + } finally { + await core?.dispose("desktop_ssh_proof_complete"); + await service.dispose(); + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/apps/examples/desktop-app/sidecar/chat-session.test.ts b/apps/examples/desktop-app/sidecar/chat-session.test.ts index 451e433d44..a8e29836ba 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.test.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.test.ts @@ -84,6 +84,42 @@ describe("rewriteDesktopTeamPrompt", () => { } }); }); +function localRuntimeContext( + sessionManager: Record, + options: { sessionIds?: string[]; workspaceRoot?: string } = {}, +) { + const workspaceRoot = options.workspaceRoot ?? "/workspace"; + return { + runtimeBindings: new Map([ + [ + "local", + { + environmentId: "local", + kind: "local" as const, + workspaceRoot, + sessionManager, + hubClient: { + command: vi.fn(async () => undefined), + }, + unsubscribeSessionEvents: () => {}, + }, + ], + ]), + sessionEnvironmentIds: new Map( + (options.sessionIds ?? []).map((sessionId) => [sessionId, "local"]), + ), + activeEnvironmentId: "local", + remoteEnvironments: null, + localWorkspaceRoot: workspaceRoot, + }; +} + +function localSessionManager(ctx: SidecarContext): Record { + return ctx.runtimeBindings.get("local")?.sessionManager as unknown as Record< + string, + unknown + >; +} describe("buildSessionConnectionUpdate", () => { it("does not clear reasoning settings when config omits reasoning fields", () => { @@ -238,7 +274,7 @@ describe("pathless session starts", () => { const ctx = { liveSessions: new Map(), restoringWorkspacePaths: new Set(), - sessionManager: { start }, + ...localRuntimeContext({ start }), telemetryUser: { distinctId: "account-1", accountId: "account-1", @@ -267,6 +303,7 @@ describe("pathless session starts", () => { sessionId: "session-pathless", cwd: "/home/host/.cline/data/workspaces/chat", workspaceRoot: "/home/host/.cline/data/workspaces/chat", + environmentId: "local", }); expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({ cwd: "/home/host/.cline/data/workspaces/chat", @@ -275,6 +312,60 @@ describe("pathless session starts", () => { }); }); +describe("environment-bound session attach", () => { + it("does not fall through to another host when the requested environment lacks the session", async () => { + const sessionId = "same-session-id"; + const localGet = vi.fn(async () => ({ + sessionId, + status: "completed", + provider: "cline", + model: "anthropic/claude-sonnet-4.6", + cwd: "/local/project", + workspaceRoot: "/local/project", + })); + const remoteGet = vi.fn(async () => undefined); + const ctx = { + liveSessions: new Map(), + sessionEnvironmentIds: new Map([[sessionId, "local"]]), + activeEnvironmentId: "local", + runtimeBindings: new Map([ + [ + "local", + { + environmentId: "local", + kind: "local", + workspaceRoot: "/local/project", + sessionManager: { get: localGet }, + hubClient: { command: vi.fn() }, + unsubscribeSessionEvents: () => {}, + }, + ], + [ + "pi-host", + { + environmentId: "pi-host", + kind: "ssh", + workspaceRoot: "/home/pi", + sessionManager: { get: remoteGet }, + hubClient: { command: vi.fn() }, + unsubscribeSessionEvents: () => {}, + }, + ], + ]), + } as unknown as SidecarContext; + + await expect( + handleChatSessionCommand(ctx, { + action: "attach", + sessionId, + config: { environmentId: "pi-host" }, + }), + ).rejects.toThrow(`Session ${sessionId} not found`); + expect(remoteGet).toHaveBeenCalledWith(sessionId); + expect(localGet).not.toHaveBeenCalled(); + }); +}); + describe("session forks", () => { it("restores the selected workspace checkpoint before forking for message editing", async () => { const sourceSessionId = `source-fork-${Date.now()}`; @@ -314,29 +405,32 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId: sourceSessionId, - source: "desktop", - status: "completed", - provider: "cline", - model: "anthropic/claude-sonnet-4.6", - cwd: "/workspace/project", - workspaceRoot: "/workspace/project", - metadata: { - checkpoint: { - latest: { ref: "second", createdAt: 2, runCount: 2 }, - history: [ - { ref: "first", createdAt: 1, runCount: 1 }, - { ref: "second", createdAt: 2, runCount: 2 }, - ], + ...localRuntimeContext( + { + get: vi.fn(async () => ({ + sessionId: sourceSessionId, + source: "desktop", + status: "completed", + provider: "cline", + model: "anthropic/claude-sonnet-4.6", + cwd: "/workspace/project", + workspaceRoot: "/workspace/project", + metadata: { + checkpoint: { + latest: { ref: "second", createdAt: 2, runCount: 2 }, + history: [ + { ref: "first", createdAt: 1, runCount: 1 }, + { ref: "second", createdAt: 2, runCount: 2 }, + ], + }, }, - }, - })), - readMessages, - restore, - start, - }, + })), + readMessages, + restore, + start, + }, + { sessionIds: [sourceSessionId] }, + ), streamIndices: new Map(), wsClients: new Set(), } as unknown as SidecarContext; @@ -438,26 +532,29 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId: sourceSessionId, - source: "desktop", - status: "completed", - provider: "cline", - model: "anthropic/claude-sonnet-4.6", - cwd: "/workspace/project", - workspaceRoot: "/workspace/project", - metadata: { - checkpoint: { - latest: { ref: "first", createdAt: 1, runCount: 1 }, - history: [{ ref: "first", createdAt: 1, runCount: 1 }], + ...localRuntimeContext( + { + get: vi.fn(async () => ({ + sessionId: sourceSessionId, + source: "desktop", + status: "completed", + provider: "cline", + model: "anthropic/claude-sonnet-4.6", + cwd: "/workspace/project", + workspaceRoot: "/workspace/project", + metadata: { + checkpoint: { + latest: { ref: "first", createdAt: 1, runCount: 1 }, + history: [{ ref: "first", createdAt: 1, runCount: 1 }], + }, }, - }, - })), - readMessages: vi.fn(async () => sourceMessages), - restore, - send, - }, + })), + readMessages: vi.fn(async () => sourceMessages), + restore, + send, + }, + { sessionIds: [sourceSessionId, siblingSessionId] }, + ), streamIndices: new Map(), wsClients: new Set(), } as unknown as SidecarContext; @@ -524,7 +621,7 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { + ...localRuntimeContext({ get: vi.fn(async () => ({ sessionId: sourceSessionId, source: "desktop", @@ -540,7 +637,7 @@ describe("session forks", () => { readMessages, restore, start, - }, + }), streamIndices: new Map(), wsClients: new Set(), } as unknown as SidecarContext; @@ -602,20 +699,23 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId: sourceSessionId, - source: "desktop", - status: "completed", - provider: "cline", - model: "anthropic/claude-sonnet-4.6", - cwd: "/workspace/project", - workspaceRoot: "/workspace/project", - })), - readMessages, - restore, - start, - }, + ...localRuntimeContext( + { + get: vi.fn(async () => ({ + sessionId: sourceSessionId, + source: "desktop", + status: "completed", + provider: "cline", + model: "anthropic/claude-sonnet-4.6", + cwd: "/workspace/project", + workspaceRoot: "/workspace/project", + })), + readMessages, + restore, + start, + }, + { sessionIds: [sourceSessionId] }, + ), streamIndices: new Map(), wsClients: new Set(), pendingQuestions: new Map(), @@ -666,7 +766,7 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { restore }, + ...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }), } as unknown as SidecarContext; await expect( @@ -697,13 +797,16 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId: sourceSessionId, - status: "running", - })), - restore, - }, + ...localRuntimeContext( + { + get: vi.fn(async () => ({ + sessionId: sourceSessionId, + status: "running", + })), + restore, + }, + { sessionIds: [sourceSessionId] }, + ), } as unknown as SidecarContext; await expect( @@ -747,15 +850,18 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId: sourceSessionId, - status: "completed", - cwd: "/workspace/project", - workspaceRoot: "/workspace/project", - })), - restore, - }, + ...localRuntimeContext( + { + get: vi.fn(async () => ({ + sessionId: sourceSessionId, + status: "completed", + cwd: "/workspace/project", + workspaceRoot: "/workspace/project", + })), + restore, + }, + { sessionIds: [sourceSessionId, siblingSessionId] }, + ), } as unknown as SidecarContext; await expect( @@ -799,7 +905,7 @@ describe("session forks", () => { restoringWorkspacePaths: new Set(), streamIndices: new Map(), wsClients: new Set(), - sessionManager: { restore }, + ...localRuntimeContext({ restore }), } as unknown as SidecarContext; const restoreRequest = { action: "restore_checkpoint" as const, @@ -862,7 +968,7 @@ describe("session forks", () => { ], ]), restoringWorkspacePaths: new Set(["/workspace/project"]), - sessionManager: { send }, + ...localRuntimeContext({ send }, { sessionIds: [sessionId] }), } as unknown as SidecarContext; await expect( @@ -922,17 +1028,20 @@ describe("first-send connection updates", () => { restoringWorkspacePaths: new Set(), streamIndices: new Map(), wsClients: new Set(), - sessionManager: { - readMessages, - readSessionCompactionState, - send, - start, - stop, - updateSessionConnection, - pendingPrompts: { - list: vi.fn(async () => []), + ...localRuntimeContext( + { + readMessages, + readSessionCompactionState, + send, + start, + stop, + updateSessionConnection, + pendingPrompts: { + list: vi.fn(async () => []), + }, }, - }, + { sessionIds: [sessionId] }, + ), } as unknown as SidecarContext; return { ctx, @@ -1056,7 +1165,7 @@ describe("first-send connection updates", () => { attachmentCount: number; userFiles?: string[]; }> = []; - const manager = ctx.sessionManager as unknown as { + const manager = localSessionManager(ctx) as unknown as { send: typeof send; pendingPrompts: { list: (input: unknown) => Promise; @@ -1188,15 +1297,13 @@ describe("first-send connection updates", () => { if (!session) throw new Error("missing session"); const queuedMap = new Map([["pending_1", [queuedFile]]]); session.queuedAttachmentFiles = queuedMap; - (ctx.sessionManager as unknown as { get: unknown }).get = vi.fn( - async () => ({ - status: "idle", - provider: "cline", - model: "anthropic/claude-sonnet-4.6", - cwd: "/workspace", - workspaceRoot: "/workspace", - }), - ); + (localSessionManager(ctx) as { get?: unknown }).get = vi.fn(async () => ({ + status: "idle", + provider: "cline", + model: "anthropic/claude-sonnet-4.6", + cwd: "/workspace", + workspaceRoot: "/workspace", + })); await handleChatSessionCommand(ctx, { action: "attach", @@ -1640,18 +1747,20 @@ Follow the desktop send workflow instructions.`, }), ); const ctx = { - workspaceRoot: workspace, liveSessions: new Map([[sessionId, session]]), restoringWorkspacePaths: new Set(), streamIndices: new Map(), wsClients: new Set(), - sessionManager: { - send, - pendingPrompts: { - list: vi.fn(async () => []), - update: updatePendingPrompt, + ...localRuntimeContext( + { + send, + pendingPrompts: { + list: vi.fn(async () => []), + update: updatePendingPrompt, + }, }, - }, + { sessionIds: [sessionId], workspaceRoot: workspace }, + ), } as unknown as SidecarContext; return { ctx, send, session, sessionId, updatePendingPrompt }; } @@ -1806,11 +1915,11 @@ describe("mistake-limit prompt", () => { streamIndices: new Map(), pendingQuestions: new Map(), liveSessions: new Map(), - sessionManager: { + ...localRuntimeContext({ send: steer, stop: vi.fn(async () => {}), abort: vi.fn(async () => {}), - }, + }), } as unknown as SidecarContext; const readQuestionRequest = () => { const raw = send.mock.calls @@ -2017,7 +2126,7 @@ describe("mistake-limit prompt", () => { const { ctx, steer, readQuestionRequest } = createPromptContext(); if (failure === "rejected") steer.mockRejectedValueOnce(new Error("Disconnected")); - else ctx.sessionManager = null; + else ctx.runtimeBindings.clear(); const recovery = createDesktopMistakeRecovery(ctx, () => "session-1"); const decision = recovery.onConsecutiveMistakeLimitReached(limitContext); const waiting = Promise.all([ @@ -2267,7 +2376,7 @@ describe("mistake-limit prompt", () => { const ctx = { liveSessions: new Map(), restoringWorkspacePaths: new Set(), - sessionManager: { start }, + ...localRuntimeContext({ start }), } as unknown as SidecarContext; await handleChatSessionCommand(ctx, { action: "start", @@ -2292,7 +2401,7 @@ describe("queue steering routing", () => { const ctx = { liveSessions: new Map(), wsClients: new Set(), - sessionManager: { pendingPrompts: { steerFirst, update } }, + ...localRuntimeContext({ pendingPrompts: { steerFirst, update } }), } as unknown as SidecarContext; await handleChatSessionCommand(ctx, { action: "steer_prompt", diff --git a/apps/examples/desktop-app/sidecar/chat-session.ts b/apps/examples/desktop-app/sidecar/chat-session.ts index c5c8578a88..25585d6996 100644 --- a/apps/examples/desktop-app/sidecar/chat-session.ts +++ b/apps/examples/desktop-app/sidecar/chat-session.ts @@ -11,14 +11,18 @@ import { findCheckpointForRun, getCoreBuiltinToolCatalog, isSkillsToolAvailable, + ProviderSettingsManager, projectSessionCompactionState, + RuntimeOAuthTokenManager, readGlobalSettings, readSessionCheckpointHistory, + resolveProviderApiKeyFromSettings, type SessionCompactionState, type SessionPendingPrompt, type SessionRecord, SessionSource, splitCoreSessionConfig, + toProviderConfig, trimMessagesBeforeUserRun, } from "@cline/core"; import type { MessageWithMetadata } from "@cline/llms"; @@ -38,6 +42,9 @@ import { createDesktopExtensionContext } from "./client-context"; import { cancelSidecarMistakeQuestions, emitChunk, + findSessionRuntimeBinding, + getEnvironmentContext, + getSessionRuntimeBinding, nowMs, requestSidecarAskQuestion, sendEvent, @@ -49,6 +56,7 @@ import type { JsonRecord, LiveSession, PromptInQueue, + SessionRuntimeBinding, SidecarContext, } from "./types"; @@ -368,6 +376,7 @@ function createLiveSession( overrides?: Partial, ): LiveSession { return { + environmentId: overrides?.environmentId, config, messages: overrides?.messages ?? [], promptsInQueue: overrides?.promptsInQueue ?? [], @@ -514,7 +523,9 @@ export function createDesktopMistakeLimitPrompt( .join(" "); // Use the existing steering queue so the running model receives the // guidance, including any instructions entered in the desktop prompt. - const manager = ctx.sessionManager; + const manager = ctx.runtimeBindings.get( + ctx.sessionEnvironmentIds.get(sessionId) ?? "local", + )?.sessionManager; try { if (!manager) throw new Error("Desktop session manager is unavailable"); const continuedThroughIteration = Math.max( @@ -714,11 +725,24 @@ export function mergeSessionConfig( const providerId = readAliasedString(updates, "provider", "providerId") ?? readAliasedString(currentConfig, "provider", "providerId"); + const previous = { ...currentConfig }; + if (hasProviderChanged(currentConfig, updates)) { + for (const key of [ + "apiKey", + "api_key", + "baseUrl", + "headers", + "providerConfig", + "accessToken", + "refreshToken", + ]) + delete previous[key]; + } const modelId = readAliasedString(updates, "model", "modelId") ?? readAliasedString(currentConfig, "model", "modelId"); return { - ...currentConfig, + ...previous, ...updates, ...(providerId ? { provider: providerId, providerId } : {}), ...(modelId ? { model: modelId, modelId } : {}), @@ -832,9 +856,99 @@ function applyPendingPrompts( })); } -function getSessionManager(ctx: SidecarContext): ClineCore { - if (!ctx.sessionManager) throw new Error("Session manager not initialized"); - return ctx.sessionManager; +function readEnvironmentId(config: JsonRecord | undefined): string | undefined { + const value = config?.environmentId ?? config?.environment_id; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readExplicitSystemPrompt(config: JsonRecord): string { + const value = config.systemPrompt ?? config.system_prompt; + return typeof value === "string" ? value : ""; +} + +/** + * A remote Hub intentionally has its own HOME and provider settings. Send the + * selected desktop provider configuration over the authenticated SSH tunnel so + * a normal signed-in desktop session can make model requests on the host. The + * refresh token is omitted: v0 uses the current access/API token and never + * writes the desktop's reusable OAuth credential to the remote settings file. + */ +async function withRemoteProviderCredentials( + config: JsonRecord, +): Promise { + const providerId = String(config.provider ?? config.providerId ?? "").trim(); + if (!providerId) return config; + + const manager = new ProviderSettingsManager(); + const settings = manager.getProviderSettings(providerId); + if (!settings) return config; + const modelId = String( + config.model ?? config.modelId ?? settings.model ?? "", + ).trim(); + const explicitProviderConfig = + config.providerConfig && typeof config.providerConfig === "object" + ? (config.providerConfig as JsonRecord) + : undefined; + const explicitApiKey = + [ + config.apiKey, + config.api_key, + explicitProviderConfig?.apiKey, + explicitProviderConfig?.accessToken, + ] + .find( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + ?.trim() ?? ""; + + const oauth = explicitApiKey + ? null + : await new RuntimeOAuthTokenManager({ + providerSettingsManager: manager, + }).resolveProviderApiKey({ providerId }); + // Refresh can replace access tokens, account IDs, and provider metadata. + const refreshedSettings = manager.getProviderSettings(providerId) ?? settings; + const storedConfig = { + ...toProviderConfig( + { ...refreshedSettings, ...(modelId ? { model: modelId } : {}) }, + { includeKnownModels: false }, + ), + }; + const apiKey = + explicitApiKey || + oauth?.apiKey || + resolveProviderApiKeyFromSettings(manager, providerId) || + String(storedConfig.apiKey ?? "").trim(); + const providerConfig = { + ...storedConfig, + ...explicitProviderConfig, + providerId, + ...(modelId ? { modelId } : {}), + ...(apiKey ? { apiKey, accessToken: apiKey } : {}), + }; + delete providerConfig.refreshToken; + + return { + ...config, + ...(apiKey ? { apiKey } : {}), + ...(!config.baseUrl && storedConfig.baseUrl + ? { baseUrl: storedConfig.baseUrl } + : {}), + ...(!config.headers && storedConfig.headers + ? { headers: storedConfig.headers } + : {}), + providerConfig, + }; +} + +function getSessionManager( + ctx: SidecarContext, + sessionId?: string, + config?: JsonRecord, +): ClineCore { + return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config)) + .sessionManager; } // --------------------------------------------------------------------------- @@ -846,23 +960,39 @@ async function handleStart( request: ChatSessionCommandRequest, ): Promise { if (!request.config) throw new Error("config is required"); - const manager = getSessionManager(ctx); - const systemPrompt = await resolveSystemPrompt(request.config); + const binding = getSessionRuntimeBinding( + ctx, + undefined, + readEnvironmentId(request.config), + ); + const manager = binding.sessionManager; + const config = + binding.kind === "ssh" + ? await withRemoteProviderCredentials(request.config) + : request.config; + // Workspace discovery must happen where the files live. Local desktop + // sessions keep the eager prompt path; SSH sessions leave a blank prompt for + // the remote Hub's LocalRuntimeHost bootstrap to compose from remote metadata. + const systemPrompt = + binding.kind === "ssh" + ? readExplicitSystemPrompt(config) + : await resolveSystemPrompt(config); const requestedSessionId = String( - request.config.sessionId ?? request.config.session_id ?? "", + config.sessionId ?? config.session_id ?? "", ).trim(); const initialMessages = - Array.isArray(request.config.initialMessages) && - request.config.initialMessages.length > 0 - ? request.config.initialMessages + Array.isArray(config.initialMessages) && config.initialMessages.length > 0 + ? config.initialMessages : requestedSessionId - ? (readPersistedChatMessages(requestedSessionId) ?? undefined) + ? binding.kind === "ssh" + ? await manager.readMessages(requestedSessionId) + : (readPersistedChatMessages(requestedSessionId) ?? undefined) : undefined; // Resolved once start() returns; the mistake-limit prompt reads it lazily. let startedSessionId = requestedSessionId; const coreConfig: JsonRecord = { ...buildCoreSessionConfig( - request.config, + config, ctx.telemetryUser, createDesktopMistakeRecovery(ctx, () => startedSessionId), ), @@ -884,7 +1014,15 @@ async function handleStart( source: SessionSource.DESKTOP, interactive: true, ...(initialMessages ? { initialMessages } : {}), - toolPolicies: resolveToolPolicies(request.config), + toolPolicies: resolveToolPolicies(config), + sessionMetadata: + binding.kind === "ssh" + ? { + remoteEnvironmentId: binding.environmentId, + remoteEnvironmentName: binding.remote?.profile.name, + remoteHost: binding.remote?.profile.host, + } + : undefined, }); const sessionId = startResult.sessionId; startedSessionId = sessionId; @@ -892,20 +1030,33 @@ async function handleStart( const cwd = startResult.manifest.cwd; ctx.logger?.log("Desktop chat session started", { sessionId }); const session = createLiveSession( - { ...request.config, cwd, workspaceRoot }, { + ...request.config, + cwd, + workspaceRoot, + environmentId: binding.environmentId, + }, + { + environmentId: binding.environmentId, messages: initialMessages, prompt: initialMessages ? derivePromptFromMessages(initialMessages) : undefined, - title: requestedSessionId - ? readSessionMetadataTitle(requestedSessionId) - : undefined, + title: + requestedSessionId && binding.kind === "local" + ? readSessionMetadataTitle(requestedSessionId) + : undefined, status: "idle", }, ); ctx.liveSessions.set(sessionId, session); - return { sessionId, cwd, workspaceRoot }; + ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId); + return { + sessionId, + cwd, + workspaceRoot, + environmentId: binding.environmentId, + }; } async function handleAttach( @@ -917,7 +1068,18 @@ async function handleAttach( throw new Error("sessionId is required"); } - const manager = getSessionManager(ctx); + const preferredEnvironmentId = readEnvironmentId(request.config); + // An explicit environment is a hard routing boundary. Falling through to + // another connected host can attach a same-id session from the wrong machine. + const binding = preferredEnvironmentId + ? getSessionRuntimeBinding(ctx, sessionId, preferredEnvironmentId) + : await findSessionRuntimeBinding(ctx, sessionId); + if (!binding) { + throw new Error( + `Session ${sessionId} not found in a connected environment`, + ); + } + const manager = binding.sessionManager; const session = await manager.get(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); @@ -928,13 +1090,12 @@ async function handleAttach( ? (session.metadata as JsonRecord) : undefined; const existing = ctx.liveSessions.get(sessionId); - if (ctx.hubClient) { - await ctx.hubClient.command("session.attach", { sessionId }, sessionId); - } - const attachedConfig: JsonRecord = { + await binding.hubClient.command("session.attach", { sessionId }, sessionId); + const baseAttachedConfig: JsonRecord = { ...(existing?.config ?? {}), ...(request.config ?? {}), sessionId, + environmentId: binding.environmentId, provider: session.provider || existing?.config.provider || "", model: session.model || existing?.config.model || "", cwd: @@ -950,7 +1111,8 @@ async function handleAttach( }; ctx.liveSessions.set( sessionId, - createLiveSession(attachedConfig, { + createLiveSession(baseAttachedConfig, { + environmentId: binding.environmentId, messages: existing?.messages ?? [], promptsInQueue: existing?.promptsInQueue ?? [], status: session.status, @@ -970,9 +1132,11 @@ async function handleAttach( consumedAttachmentFiles: existing?.consumedAttachmentFiles, }), ); + ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId); return { sessionId, + environmentId: binding.environmentId, status: session.status, provider: session.provider, model: session.model, @@ -1030,11 +1194,20 @@ async function startRebuiltSession( async function rebuildSessionForProviderChange( ctx: SidecarContext, - manager: ClineCore, + binding: SessionRuntimeBinding, sessionId: string, previousConfig: JsonRecord, nextConfig: JsonRecord, ): Promise { + const manager = binding.sessionManager; + const effectivePreviousConfig = + binding.kind === "ssh" + ? await withRemoteProviderCredentials(previousConfig) + : previousConfig; + const effectiveNextConfig = + binding.kind === "ssh" + ? await withRemoteProviderCredentials(nextConfig) + : nextConfig; const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] = await Promise.all([ manager.readMessages(sessionId), @@ -1046,8 +1219,12 @@ async function rebuildSessionForProviderChange( }); return undefined; }), - resolveSystemPrompt(previousConfig), - resolveSystemPrompt(nextConfig), + binding.kind === "ssh" + ? readExplicitSystemPrompt(effectivePreviousConfig) + : resolveSystemPrompt(effectivePreviousConfig), + binding.kind === "ssh" + ? readExplicitSystemPrompt(effectiveNextConfig) + : resolveSystemPrompt(effectiveNextConfig), ]); cancelSidecarMistakeQuestions(ctx, sessionId, "Session provider changed"); @@ -1058,7 +1235,7 @@ async function rebuildSessionForProviderChange( manager, ctx, sessionId, - nextConfig, + effectiveNextConfig, nextSystemPrompt, messages, compactionState, @@ -1069,7 +1246,7 @@ async function rebuildSessionForProviderChange( // persistence failure cannot leave runtime and cached state diverged. await manager.updateSessionConnection( sessionId, - buildSessionConnectionUpdate(nextConfig), + buildSessionConnectionUpdate(effectiveNextConfig), ); } catch (replacementError) { try { @@ -1080,14 +1257,14 @@ async function rebuildSessionForProviderChange( manager, ctx, sessionId, - previousConfig, + effectivePreviousConfig, previousSystemPrompt, messages, compactionState, ); await manager.updateSessionConnection( sessionId, - buildSessionConnectionUpdate(previousConfig), + buildSessionConnectionUpdate(effectivePreviousConfig), ); } catch (rollbackError) { throw new AggregateError( @@ -1112,8 +1289,13 @@ async function handleSend( if (!prompt && !hasAttachments) { throw new Error("prompt or attachment is required"); } - const manager = getSessionManager(ctx); const session = ctx.liveSessions.get(sessionId); + const binding = getSessionRuntimeBinding( + ctx, + sessionId, + readEnvironmentId(request.config), + ); + const manager = binding.sessionManager; const lockedWorkspaceKey = workspacePathKey( session?.config ?? request.config, ); @@ -1128,19 +1310,23 @@ async function handleSend( } // Dispatch the expanded or rewritten instructions, but keep the raw // `/command` token as the session's display prompt. - const runtimePrompt = await resolveDesktopRuntimePrompt( - ctx, - readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot, - prompt, - request.config?.mode ?? session?.config?.mode, - ); + const runtimePrompt = + binding.kind === "ssh" + ? prompt + : await resolveDesktopRuntimePrompt( + ctx, + readWorkspacePath(session?.config ?? request.config) ?? + ctx.localWorkspaceRoot, + prompt, + request.config?.mode ?? session?.config?.mode, + ); let delivery = request.delivery; if (!delivery && session?.busy) { delivery = "queue"; } const nextConfig = request.config ? mergeSessionConfig(session?.config ?? {}, request.config) - : undefined; + : session?.config; const providerChanged = Boolean( session && request.config && @@ -1163,23 +1349,28 @@ async function handleSend( } } try { - if (request.config && nextConfig) { + if ((request.config || binding.kind === "ssh") && nextConfig) { if (providerChanged && session) { await rebuildSessionForProviderChange( ctx, - manager, + binding, sessionId, session.config, nextConfig, ); } else if ( + binding.kind === "ssh" || !session || session.attachedViaHub || shouldUpdateSessionConnection(session.config, nextConfig) ) { await manager.updateSessionConnection( sessionId, - buildSessionConnectionUpdate(nextConfig), + buildSessionConnectionUpdate( + binding.kind === "ssh" + ? await withRemoteProviderCredentials(nextConfig) + : nextConfig, + ), ); } if (session) { @@ -1190,6 +1381,14 @@ async function handleSend( } } + if ( + binding.kind === "ssh" && + (request.attachments?.userFiles?.length ?? 0) > 0 + ) { + throw new Error( + "File attachments are not available in the SSH proof of concept yet. Images and text prompts are supported.", + ); + } const userFiles = materializeUserFiles( sessionId, request.attachments?.userFiles, @@ -1333,7 +1532,7 @@ async function handleStop( const sessionId = request.sessionId?.trim(); if (!sessionId) throw new Error("sessionId is required"); cancelSidecarMistakeQuestions(ctx, sessionId, "Session stopped"); - await getSessionManager(ctx).stop(sessionId); + await getSessionManager(ctx, sessionId, request.config).stop(sessionId); const session = ctx.liveSessions.get(sessionId); if (session) { session.busy = false; @@ -1349,7 +1548,10 @@ async function handleAbort( const sessionId = request.sessionId?.trim(); if (!sessionId) throw new Error("sessionId is required"); cancelSidecarMistakeQuestions(ctx, sessionId, "Run aborted"); - await getSessionManager(ctx).abort(sessionId, "user_abort"); + await getSessionManager(ctx, sessionId, request.config).abort( + sessionId, + "user_abort", + ); const session = ctx.liveSessions.get(sessionId); if (session) { session.busy = false; @@ -1371,7 +1573,7 @@ async function handleFork( ) { throw new Error("forkBeforeRunCount must be a positive integer"); } - const manager = getSessionManager(ctx); + const manager = getSessionManager(ctx, sourceSessionId, request.config); const liveSourceSession = ctx.liveSessions.get(sourceSessionId); if ( forkBeforeRunCount !== undefined && @@ -1423,10 +1625,18 @@ async function handleForkUnlocked( sourceSession: SessionRecord | undefined, restoreWorkspacePath?: string, ): Promise { - const manager = getSessionManager(ctx); + const binding = getSessionRuntimeBinding( + ctx, + sourceSessionId, + readEnvironmentId(request.config), + ); + const manager = binding.sessionManager; const sourceMessages = - readPersistedChatMessages(sourceSessionId) ?? - ctx.liveSessions.get(sourceSessionId)?.messages; + binding.kind === "ssh" + ? await manager.readMessages(sourceSessionId) + : (readPersistedChatMessages(sourceSessionId) ?? + ctx.liveSessions.get(sourceSessionId)?.messages); + if (!sourceMessages?.length) { throw new Error(`No messages found for session ${sourceSessionId}`); } @@ -1434,12 +1644,16 @@ async function handleForkUnlocked( const sourceMetadata = (sourceSession?.metadata && typeof sourceSession.metadata === "object" ? (sourceSession.metadata as JsonRecord) - : undefined) ?? readSessionMetadata(sourceSessionId); + : undefined) ?? + (binding.kind === "local" + ? readSessionMetadata(sourceSessionId) + : undefined); const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config; - const forkConfig: JsonRecord = { + const baseForkConfig: JsonRecord = { ...(liveConfig ?? {}), ...(request.config ?? {}), sessionId: undefined, + environmentId: binding.environmentId, provider: sourceSession?.provider || liveConfig?.provider || @@ -1469,6 +1683,10 @@ async function handleForkUnlocked( request.config?.cwd || "", }; + const forkConfig = + binding.kind === "ssh" + ? await withRemoteProviderCredentials(baseForkConfig) + : baseForkConfig; const checkpointMetadata = sourceMetadata?.checkpoint !== undefined ? { checkpoints: sourceMetadata.checkpoint } @@ -1489,8 +1707,10 @@ async function handleForkUnlocked( ...checkpointMetadata, }, }; - const systemPrompt = await resolveSystemPrompt(forkConfig); - // Assigned below once the forked session exists; read lazily by the prompt. + const systemPrompt = + binding.kind === "ssh" + ? readExplicitSystemPrompt(forkConfig) + : await resolveSystemPrompt(forkConfig); let newSessionId = ""; const startInput = { ...splitCoreSessionConfig( @@ -1568,13 +1788,20 @@ async function handleForkUnlocked( ctx.liveSessions.delete(sourceSessionId); ctx.liveSessions.set( newSessionId, - createLiveSession(forkConfig, { + createLiveSession(baseForkConfig, { + environmentId: binding.environmentId, messages: forkMessages, prompt: derivePromptFromMessages(forkMessages), - title: readSessionMetadataTitle(sourceSessionId), + title: + binding.kind === "local" + ? readSessionMetadataTitle(sourceSessionId) + : typeof sourceMetadata?.title === "string" + ? sourceMetadata.title + : undefined, status: "idle", }), ); + ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId); sendPromptsInQueueSnapshot(ctx, sourceSessionId); sendPromptsInQueueSnapshot(ctx, newSessionId); return { @@ -1597,10 +1824,11 @@ async function handleReset( session?.status === "running" || session?.status === "stopping" ) { - await getSessionManager(ctx).stop(sessionId); + await getSessionManager(ctx, sessionId, request.config).stop(sessionId); } discardAllTrackedAttachments(sessionId, session); ctx.liveSessions.delete(sessionId); + ctx.sessionEnvironmentIds.delete(sessionId); sendPromptsInQueueSnapshot(ctx, sessionId); } return { sessionId: request.sessionId, ok: true }; @@ -1619,14 +1847,25 @@ async function handleRestoreCheckpoint( runCount < 1 ) throw new Error("checkpointRunCount must be a positive integer"); - const config = request.config; - if (!config) throw new Error("config is required to restore a checkpoint"); + const requestedConfig = request.config; + if (!requestedConfig) + throw new Error("config is required to restore a checkpoint"); const cwd = - (typeof config.cwd === "string" && config.cwd.trim()) || - (typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) || + (typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) || + (typeof requestedConfig.workspaceRoot === "string" && + requestedConfig.workspaceRoot.trim()) || ""; if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required"); - const manager = getSessionManager(ctx); + const binding = getSessionRuntimeBinding( + ctx, + sourceSessionId, + readEnvironmentId(requestedConfig), + ); + const manager = binding.sessionManager; + const config = + binding.kind === "ssh" + ? await withRemoteProviderCredentials(requestedConfig) + : requestedConfig; return withWorkspaceRestoreLock(ctx, cwd, async () => { // Updated once restore() returns; read lazily by the mistake-limit prompt. let restoredSessionId = sourceSessionId; @@ -1640,7 +1879,10 @@ async function handleRestoreCheckpoint( buildCoreSessionConfig( { ...config, - systemPrompt: await resolveSystemPrompt(config), + systemPrompt: + binding.kind === "ssh" + ? readExplicitSystemPrompt(config) + : await resolveSystemPrompt(config), }, ctx.telemetryUser, createDesktopMistakeRecovery(ctx, () => restoredSessionId), @@ -1669,7 +1911,8 @@ async function handleRestoreCheckpoint( ctx.liveSessions.delete(sourceSessionId); ctx.liveSessions.set( sessionId, - createLiveSession(config, { + createLiveSession(requestedConfig, { + environmentId: binding.environmentId, messages: restoredMessages, prompt: derivePromptFromMessages(restoredMessages), title: readSessionMetadataTitle(sourceSessionId), @@ -1680,7 +1923,10 @@ async function handleRestoreCheckpoint( // transcript describing the discarded turns, and read_session_messages // prefers that file over the live session. Write the trimmed history so // the transcript matches the workspace the restore just rolled back to. - persistSessionMessages(sessionId, restoredMessages); + if (binding.kind === "local") { + persistSessionMessages(sessionId, restoredMessages); + } + ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId); sendPromptsInQueueSnapshot(ctx, sourceSessionId); sendPromptsInQueueSnapshot(ctx, sessionId); return { @@ -1696,9 +1942,11 @@ async function handlePendingPrompts( ): Promise { const sessionId = request.sessionId?.trim(); if (!sessionId) throw new Error("sessionId is required"); - const prompts = await getSessionManager(ctx).pendingPrompts.list({ + const prompts = await getSessionManager( + ctx, sessionId, - }); + request.config, + ).pendingPrompts.list({ sessionId }); return { sessionId, promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts), @@ -1714,7 +1962,7 @@ async function handleSteerPrompt( if (!sessionId) throw new Error("sessionId is required"); if (request.promptId !== undefined && !promptId) throw new Error("promptId cannot be empty"); - const manager = getSessionManager(ctx); + const manager = getSessionManager(ctx, sessionId, request.config); const result = promptId ? await manager.pendingPrompts.update({ sessionId, @@ -1742,16 +1990,24 @@ async function handleUpdatePendingPrompt( if (!prompt) { throw new Error("prompt is required"); } - const manager = getSessionManager(ctx); + const binding = getSessionRuntimeBinding( + ctx, + sessionId, + readEnvironmentId(request.config), + ); + const manager = binding.sessionManager; const sessionConfig = ctx.liveSessions.get(sessionId)?.config; // Queued prompts are delivered by the runtime without another pass // through handleSend, so resolve slash commands here too. - const runtimePrompt = await resolveDesktopRuntimePrompt( - ctx, - readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot, - prompt, - sessionConfig?.mode, - ); + const runtimePrompt = + binding.kind === "ssh" + ? prompt + : await resolveDesktopRuntimePrompt( + ctx, + readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot, + prompt, + sessionConfig?.mode, + ); const result = await manager.pendingPrompts.update({ sessionId, promptId, @@ -1774,7 +2030,7 @@ async function handleRemovePendingPrompt( if (!sessionId || !promptId) { throw new Error("sessionId and promptId are required"); } - const manager = getSessionManager(ctx); + const manager = getSessionManager(ctx, sessionId, request.config); const result = await manager.pendingPrompts.delete({ sessionId, promptId, @@ -1819,5 +2075,19 @@ export async function handleChatSessionCommand( ): Promise { const handler = ACTION_HANDLERS[request.action]; if (!handler) throw new Error("unsupported action"); - return handler(ctx, request); + const explicitEnvironment = readEnvironmentId(request.config); + const binding = + !explicitEnvironment && request.sessionId + ? await findSessionRuntimeBinding(ctx, request.sessionId) + : undefined; + return handler( + getEnvironmentContext( + ctx, + explicitEnvironment ?? + binding?.environmentId ?? + ctx.activeEnvironmentId ?? + "local", + ), + request, + ); } diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts b/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts index 9e923cd969..ff9f12f6af 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions-lifecycle.test.ts @@ -33,7 +33,11 @@ function createContext(): { ctx: SidecarContext } { ]), pendingApprovals: new Map(), pendingQuestions: new Map(), - sessionManager: null, + runtimeBindings: new Map(), + sessionEnvironmentIds: new Map(), + activeEnvironmentId: "local", + remoteEnvironments: null, + localWorkspaceRoot: "/local/workspace", cloudSessionManager: null, hubClient: null, workspaceRoot: "/local/workspace", diff --git a/apps/examples/desktop-app/sidecar/commands-hub-upgrade.test.ts b/apps/examples/desktop-app/sidecar/commands-hub-upgrade.test.ts index 13cec7eabe..bea0601386 100644 --- a/apps/examples/desktop-app/sidecar/commands-hub-upgrade.test.ts +++ b/apps/examples/desktop-app/sidecar/commands-hub-upgrade.test.ts @@ -14,7 +14,7 @@ vi.mock("@cline/core", async () => { function createContext(): SidecarContext { return { - workspaceRoot: "/workspace", + localWorkspaceRoot: "/workspace", wsClients: new Set(), hubBuildMismatch: { url: "ws://127.0.0.1:25463/hub", diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index e37b6f4b93..ae547c3429 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -1,7 +1,13 @@ import { execFile, spawn } from "node:child_process"; -import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { + existsSync, + readdirSync, + realpathSync, + rmSync, + statSync, +} from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, extname, isAbsolute, join } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, posix } from "node:path"; import { promisify } from "node:util"; import type { ClineAccountActionRequest, @@ -10,6 +16,8 @@ import type { ProviderClient, ProviderConfig, ProviderProtocol, + RemoteEnvironmentConnection, + RemoteEnvironmentInput, SaveProviderSettingsActionRequest, } from "@cline/core"; import { @@ -33,6 +41,7 @@ import { parseMcpServerRegistration, persistClineAccountTelemetryIdentity, probeMcpServerConnection, + RemoteEnvironmentService, readGlobalSettings, resolveClineAccountTelemetryIdentity, resolveMcpServerRegistration, @@ -83,7 +92,13 @@ import { } from "./connectors"; import { broadcastEvent, + connectRemoteSessionRuntime, + disconnectRemoteSessionRuntime, ensureSharedHubClient, + findSessionRuntimeBinding, + getEnvironmentContext, + getEnvironmentContexts, + getRuntimeBinding, resolveSidecarAskQuestion, sendEventToClient, } from "./context"; @@ -123,6 +138,7 @@ import { } from "./paths"; import { getPullRequestStatus } from "./pull-request"; import { capturePullRequestEvent } from "./pull-request-telemetry"; +import { resolveDesktopRemoteHelper } from "./remote-helper"; import { listSessionAgents } from "./session-data/agents"; import { readSessionHooks } from "./session-data/artifacts"; import { normalizeSessionTitle } from "./session-data/common"; @@ -135,6 +151,7 @@ import type { SidecarContext, SidecarWebSocketClient, } from "./types"; +import { LOCAL_ENVIRONMENT_ID } from "./types"; import { pickWorkspaceDirectory } from "./workspace-picker"; // All child processes in this module run asynchronously: the sidecar is a @@ -202,6 +219,130 @@ function emitDesktopDebugLog( metadata, }); } +const remoteEnvironmentTransitionTails = new WeakMap< + SidecarContext, + Promise +>(); + +function withRemoteEnvironmentTransition( + ctx: SidecarContext, + operation: () => Promise, +): Promise { + const previous = + remoteEnvironmentTransitionTails.get(ctx) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const tail = result.then( + () => undefined, + () => undefined, + ); + remoteEnvironmentTransitionTails.set(ctx, tail); + void tail.finally(() => { + if (remoteEnvironmentTransitionTails.get(ctx) === tail) { + remoteEnvironmentTransitionTails.delete(ctx); + } + }); + return result; +} + +function activeRemoteEnvironmentState(ctx: SidecarContext): { + activeEnvironmentId: string; + activeProfileId: string | null; +} { + const binding = ctx.runtimeBindings.get(ctx.activeEnvironmentId); + if (binding?.kind === "ssh") { + return { + activeEnvironmentId: binding.environmentId, + activeProfileId: binding.environmentId, + }; + } + return { + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + activeProfileId: null, + }; +} + +function broadcastLocalEnvironment( + ctx: SidecarContext, + details: { reason?: string; message?: string } = {}, +): void { + broadcastEvent(ctx, "remote_environment_changed", { + status: "disconnected", + activeProfileId: null, + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + workspaceRoot: ctx.localWorkspaceRoot, + ...details, + }); +} + +function getRemoteEnvironmentService( + ctx: SidecarContext, +): RemoteEnvironmentService { + if (!ctx.remoteEnvironments) { + ctx.remoteEnvironments = new RemoteEnvironmentService({ + dependencies: { + resolveHelperBinary: async (target) => + resolveDesktopRemoteHelper(target), + }, + onStatusChange: (status) => { + broadcastEvent(ctx, "remote_environment_status", status); + }, + onConnectionLost: (status) => { + const binding = ctx.runtimeBindings.get(status.profileId); + if (binding?.kind !== "ssh") return; + const wasActive = ctx.activeEnvironmentId === status.profileId; + void disconnectRemoteSessionRuntime(ctx, status.profileId) + .catch((error) => { + ctx.logger?.log("Failed to dispose dead SSH runtime", { + error, + environmentId: status.profileId, + severity: "warn", + }); + }) + .finally(() => { + if ( + !wasActive || + ctx.activeEnvironmentId !== LOCAL_ENVIRONMENT_ID + ) { + return; + } + broadcastLocalEnvironment(ctx, { + reason: "tunnel_error", + message: status.message, + }); + }); + }, + }); + } + return ctx.remoteEnvironments; +} + +function requestedEnvironmentId( + args: Record | undefined, +): string | undefined { + if (typeof args?.environmentId !== "string") return undefined; + const environmentId = args.environmentId.trim(); + return environmentId || undefined; +} + +function getCommandRuntimeBinding( + ctx: SidecarContext, + args: Record | undefined, +) { + const environmentId = requestedEnvironmentId(args) ?? ctx.activeEnvironmentId; + return getRuntimeBinding(ctx, environmentId); +} + +async function getCommandSessionBinding( + ctx: SidecarContext, + sessionId: string, + args: Record | undefined, +) { + const environmentId = requestedEnvironmentId(args); + return environmentId + ? getRuntimeBinding(ctx, environmentId) + : await findSessionRuntimeBinding(ctx, sessionId); +} // Strict allowlist: the opener hands the URL to the OS protocol handler, so // anything broader (file:, custom app schemes) would let webview content @@ -375,11 +516,10 @@ function syncSignedOutAccountContext(ctx: SidecarContext): void { } function mergePersistedSessionRecord( - store: SqliteSessionStore, sessionId: string, record: JsonRecord, + persisted?: JsonRecord, ): JsonRecord { - const persisted = store.get(sessionId) as unknown as JsonRecord | undefined; const metadata = record.metadata && typeof record.metadata === "object" ? (record.metadata as JsonRecord) @@ -422,42 +562,46 @@ function mergePersistedSessionRecord( async function getSessionFromSidecarManager( ctx: SidecarContext, sessionId: string, + environmentId?: string, ): Promise { const store = new SqliteSessionStore(); - if (ctx.sessionManager) { - const session = await ctx.sessionManager.get(sessionId); + const binding = environmentId + ? getRuntimeBinding(ctx, environmentId) + : await findSessionRuntimeBinding(ctx, sessionId); + if (binding) { + const session = await binding.sessionManager.get(sessionId); if (session) { - return mergePersistedSessionRecord( - store, + const merged = mergePersistedSessionRecord( sessionId, session as unknown as JsonRecord, + binding.kind === "local" + ? (store.get(sessionId) as unknown as JsonRecord | undefined) + : undefined, ); + return { + ...merged, + environmentId: binding.environmentId, + remoteEnvironment: + binding.kind === "ssh" + ? { + id: binding.environmentId, + name: binding.remote?.profile.name, + host: binding.remote?.profile.host, + } + : undefined, + }; } } - if (ctx.hubClient) { - try { - const reply = await ctx.hubClient.command( - "session.get", - undefined, - sessionId, - ); - const session = reply.payload?.session; - if (session && typeof session === "object") { - return mergePersistedSessionRecord( - store, - sessionId, - session as JsonRecord, - ); - } - } catch { - // Fall through to the local SQLite index. - } - } - - const persisted = store.get(sessionId) as unknown as JsonRecord | undefined; + const persisted = + !environmentId || environmentId === LOCAL_ENVIRONMENT_ID + ? (store.get(sessionId) as unknown as JsonRecord | undefined) + : undefined; return persisted - ? mergePersistedSessionRecord(store, sessionId, persisted) + ? { + ...mergePersistedSessionRecord(sessionId, persisted, persisted), + environmentId: LOCAL_ENVIRONMENT_ID, + } : undefined; } @@ -466,70 +610,90 @@ async function listSessionsFromSidecarManager( limit: number, ): Promise { const max = Math.max(1, Math.floor(limit)); - if (ctx.sessionManager) { - return await ctx.sessionManager.list(max, { hydrate: false }); - } - const byId = new Map(); const store = new SqliteSessionStore(); - if (ctx.hubClient) { + for (const binding of ctx.runtimeBindings.values()) { try { - const reply = await ctx.hubClient.command("session.list", { limit: max }); - const sessions = Array.isArray(reply.payload?.sessions) - ? reply.payload.sessions - : []; + const sessions = await binding.sessionManager.list(max, { + hydrate: false, + }); for (const item of sessions) { if (!item || typeof item !== "object") continue; - const record = item as JsonRecord; + const record = item as unknown as JsonRecord; const sessionId = String(record.sessionId ?? "").trim(); - if (sessionId) - byId.set( - sessionId, - mergePersistedSessionRecord(store, sessionId, record), - ); + if (!sessionId) continue; + getEnvironmentContext( + ctx, + binding.environmentId, + ).sessionEnvironmentIds.set(sessionId, binding.environmentId); + const merged = mergePersistedSessionRecord( + sessionId, + record, + binding.kind === "local" + ? (store.get(sessionId) as unknown as JsonRecord | undefined) + : undefined, + ); + byId.set(JSON.stringify([binding.environmentId, sessionId]), { + ...merged, + environmentId: binding.environmentId, + remoteEnvironment: + binding.kind === "ssh" + ? { + id: binding.environmentId, + name: binding.remote?.profile.name, + host: binding.remote?.profile.host, + } + : undefined, + }); } } catch { - // Fall through to the local SQLite index. + // Keep history available from the other connected environments. } } if (byId.size === 0) { for (const session of store.list(max)) { - byId.set(session.sessionId, session as unknown as JsonRecord); + byId.set(JSON.stringify([LOCAL_ENVIRONMENT_ID, session.sessionId]), { + ...(session as unknown as JsonRecord), + environmentId: LOCAL_ENVIRONMENT_ID, + }); } } - for (const [sessionId, session] of ctx.liveSessions.entries()) { - const existing = byId.get(sessionId); - byId.set(sessionId, { - ...(existing ?? {}), - sessionId, - status: session.status, - provider: session.config.provider ?? existing?.provider ?? "", - model: session.config.model ?? existing?.model ?? "", - cwd: session.config.cwd ?? existing?.cwd ?? "", - workspaceRoot: - session.config.workspaceRoot ?? - existing?.workspaceRoot ?? - existing?.cwd ?? - "", - prompt: session.prompt ?? existing?.prompt, - startedAt: - existing?.startedAt ?? new Date(session.startedAt).toISOString(), - endedAt: - session.endedAt !== undefined - ? new Date(session.endedAt).toISOString() - : existing?.endedAt, - metadata: { - ...((existing?.metadata && typeof existing.metadata === "object" - ? existing.metadata - : {}) as JsonRecord), - ...(session.title ? { title: session.title } : {}), - }, - }); + for (const scoped of getEnvironmentContexts(ctx)) { + for (const [sessionId, session] of scoped.liveSessions.entries()) { + const key = JSON.stringify([scoped.activeEnvironmentId, sessionId]); + const existing = byId.get(key); + byId.set(key, { + ...(existing ?? {}), + sessionId, + environmentId: scoped.activeEnvironmentId, + status: session.status, + provider: session.config.provider ?? existing?.provider ?? "", + model: session.config.model ?? existing?.model ?? "", + cwd: session.config.cwd ?? existing?.cwd ?? "", + workspaceRoot: + session.config.workspaceRoot ?? + existing?.workspaceRoot ?? + existing?.cwd ?? + "", + prompt: session.prompt ?? existing?.prompt, + startedAt: + existing?.startedAt ?? new Date(session.startedAt).toISOString(), + endedAt: + session.endedAt !== undefined + ? new Date(session.endedAt).toISOString() + : existing?.endedAt, + metadata: { + ...((existing?.metadata && typeof existing.metadata === "object" + ? existing.metadata + : {}) as JsonRecord), + ...(session.title ? { title: session.title } : {}), + }, + }); + } } - return Array.from(byId.values()) .sort((left, right) => { const leftTime = Date.parse( @@ -613,9 +777,36 @@ function metadataSessionSearchHits( async function listGitBranches( ctx: SidecarContext, + binding: ReturnType, cwd?: string, ): Promise<{ current?: string; branches?: string[] }> { - const targetCwd = cwd?.trim() || ctx.workspaceRoot; + const targetCwd = cwd?.trim() || binding.workspaceRoot; + if (binding.kind === "ssh") { + const remote = ctx.remoteEnvironments; + if (!remote) throw new Error("Remote environment service is unavailable"); + const [currentResult, branchesResult] = await Promise.all([ + remote + .run(binding.environmentId, { + command: "git", + args: ["branch", "--show-current"], + cwd: targetCwd, + }) + .catch(() => undefined), + remote + .run(binding.environmentId, { + command: "git", + args: ["for-each-ref", "--format=%(refname:short)", "refs/heads"], + cwd: targetCwd, + }) + .catch(() => undefined), + ]); + const current = currentResult?.stdout.trim() ?? ""; + const branches = (branchesResult?.stdout ?? "") + .split("\n") + .map((value) => value.trim()) + .filter(Boolean); + return { current: current || undefined, branches }; + } const [currentResult, branchesResult] = await Promise.all([ execFileAsync("git", ["branch", "--show-current"], { cwd: targetCwd, @@ -638,6 +829,181 @@ async function listGitBranches( return { current: current || undefined, branches }; } +const REMOTE_FILE_SEARCH_OUTPUT_LIMIT_BYTES = 256 * 1024; +const REMOTE_FILE_SEARCH_SCRIPT = + "if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then " + + "git -c core.quotePath=false ls-files --cached --others --exclude-standard; " + + "else find . -type d '(' -name .git -o -name node_modules ')' -prune -o -type f -print; fi | " + + `head -c ${REMOTE_FILE_SEARCH_OUTPUT_LIMIT_BYTES}`; + +async function searchRemoteWorkspaceFiles( + ctx: SidecarContext, + binding: ReturnType, + args?: Record, +): Promise { + if (binding.kind !== "ssh" || !ctx.remoteEnvironments) { + return await searchWorkspaceFiles( + { localWorkspaceRoot: binding.workspaceRoot }, + args, + ); + } + const root = + typeof args?.workspaceRoot === "string" && args.workspaceRoot.trim() + ? args.workspaceRoot.trim() + : binding.workspaceRoot; + const query = + typeof args?.query === "string" ? args.query.trim().toLowerCase() : ""; + const limit = + typeof args?.limit === "number" && Number.isFinite(args.limit) + ? Math.max(1, Math.min(50, Math.trunc(args.limit))) + : 10; + const result = await ctx.remoteEnvironments.run(binding.environmentId, { + command: "sh", + args: ["-c", REMOTE_FILE_SEARCH_SCRIPT], + cwd: root, + }); + // A byte cap can split a path. Discard the incomplete final record. + const output = result.stdout.slice(0, result.stdout.lastIndexOf("\n") + 1); + const rank = (path: string): number => { + if (!query) return 3; + const lower = path.toLowerCase(); + if (lower.startsWith(query)) return 0; + if (lower.includes(`/${query}`)) return 1; + if (lower.includes(query)) return 2; + return Number.POSITIVE_INFINITY; + }; + return output + .split("\n") + .map((path) => path.trim().replace(/^\.\//, "")) + .filter(Boolean) + .map((path) => ({ path, rank: rank(path) })) + .filter((entry) => Number.isFinite(entry.rank)) + .sort((left, right) => + left.rank === right.rank + ? left.path.localeCompare(right.path) + : left.rank - right.rank, + ) + .slice(0, limit) + .map((entry) => entry.path); +} + +// Directory browsing is intentionally bounded at both the entry and transport +// levels. Remote directory names are NUL-delimited so whitespace and shell +// metacharacters remain data, and the selected path is passed as a positional +// argument through RemoteEnvironmentService.run rather than interpolated into +// the static shell program. +const WORKSPACE_DIRECTORY_ENTRY_LIMIT = 200; +const REMOTE_DIRECTORY_OUTPUT_LIMIT_BYTES = 256 * 1024; +const REMOTE_DIRECTORY_LIST_SCRIPT = + `find -L "$1" -mindepth 1 -maxdepth 1 -type d -print0 | ` + + `head -c ${REMOTE_DIRECTORY_OUTPUT_LIMIT_BYTES}`; + +type WorkspaceDirectoryListResult = { + environmentId: string; + currentPath: string; + parentPath: string | null; + entries: Array<{ name: string; path: string }>; + truncated: boolean; +}; + +function parentLocalPath(path: string): string | null { + const parent = dirname(path); + return parent === path ? null : parent; +} + +function parentRemotePath(path: string): string | null { + const parent = posix.dirname(path); + return parent === path ? null : parent; +} + +async function listWorkspaceDirectories( + ctx: SidecarContext, + environmentId: string, + path?: string, +): Promise { + const binding = getRuntimeBinding(ctx, environmentId); + const requestedPath = path && path.trim().length > 0 ? path : undefined; + if (binding.kind === "local") { + const currentPath = realpathSync(requestedPath || homedir()); + if (!statSync(currentPath).isDirectory()) { + throw new Error(`Workspace path is not a directory: ${currentPath}`); + } + const directories = readdirSync(currentPath, { withFileTypes: true }) + .filter((entry) => { + if (entry.isDirectory()) return true; + if (!entry.isSymbolicLink()) return false; + try { + return statSync(join(currentPath, entry.name)).isDirectory(); + } catch { + return false; + } + }) + .map((entry) => ({ + name: entry.name, + path: join(currentPath, entry.name), + })) + .sort( + (left, right) => + left.name.localeCompare(right.name) || + left.path.localeCompare(right.path), + ); + return { + environmentId: binding.environmentId, + currentPath, + parentPath: parentLocalPath(currentPath), + entries: directories.slice(0, WORKSPACE_DIRECTORY_ENTRY_LIMIT), + truncated: directories.length > WORKSPACE_DIRECTORY_ENTRY_LIMIT, + }; + } + + const remote = ctx.remoteEnvironments; + if (!remote) throw new Error("Remote environment service is unavailable"); + const home = binding.remote?.homeDir ?? binding.workspaceRoot; + const canonicalResult = await remote.run(binding.environmentId, { + command: "pwd", + args: ["-P"], + cwd: requestedPath || home, + }); + const currentPath = canonicalResult.stdout.replace(/\r?\n$/, ""); + if (!currentPath.startsWith("/") || /[\0\r\n]/.test(currentPath)) { + throw new Error("SSH host returned an invalid canonical directory path"); + } + const listResult = await remote.run(binding.environmentId, { + command: "sh", + args: [ + "-c", + REMOTE_DIRECTORY_LIST_SCRIPT, + "cline-list-workspace-directories", + currentPath, + ], + }); + const outputEndedAtBoundary = + listResult.stdout.length === 0 || listResult.stdout.endsWith("\0"); + const encodedPaths = listResult.stdout.split("\0"); + if (encodedPaths.at(-1) === "" || !outputEndedAtBoundary) { + encodedPaths.pop(); + } + const paths = Array.from(new Set(encodedPaths.filter(Boolean))).sort((a, b) => + a.localeCompare(b), + ); + return { + environmentId: binding.environmentId, + currentPath, + parentPath: parentRemotePath(currentPath), + entries: paths + .slice(0, WORKSPACE_DIRECTORY_ENTRY_LIMIT) + .map((entryPath) => ({ + name: posix.basename(entryPath), + path: entryPath, + })), + truncated: + paths.length > WORKSPACE_DIRECTORY_ENTRY_LIMIT || + !outputEndedAtBoundary || + Buffer.byteLength(listResult.stdout) >= + REMOTE_DIRECTORY_OUTPUT_LIMIT_BYTES, + }; +} + // --------------------------------------------------------------------------- // Routine schedule helpers (in-process via shared hub server) // --------------------------------------------------------------------------- @@ -912,8 +1278,8 @@ async function listHubSettings( ): Promise { const hubClient = await ensureSharedHubClient(ctx); const reply = await hubClient.command("settings.list", { - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, + cwd: ctx.localWorkspaceRoot, }); if (!reply.ok) { throw new Error( @@ -935,8 +1301,8 @@ async function toggleHubSetting( const hubClient = await ensureSharedHubClient(ctx); const reply = await hubClient.command("settings.toggle", { ...input, - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, + cwd: ctx.localWorkspaceRoot, }); if (!reply.ok) { throw new Error( @@ -950,7 +1316,7 @@ async function listUserInstructionConfigs( ctx: SidecarContext, settingsSnapshot?: CoreSettingsSnapshot, ): Promise { - const workspaceRoot = ctx.workspaceRoot; + const workspaceRoot = ctx.localWorkspaceRoot; const hubSettings = settingsSnapshot ?? (await listHubSettings(ctx)); const warnings: string[] = []; const userInstructionService = createUserInstructionConfigService({ @@ -1348,6 +1714,199 @@ export async function handleCommand( args?: Record, options?: { connection?: SidecarWebSocketClient }, ): Promise { + const explicitEnvironment = requestedEnvironmentId(args); + if (explicitEnvironment) { + ctx = getEnvironmentContext(ctx, explicitEnvironment); + } else if (typeof args?.sessionId === "string" && args.sessionId.trim()) { + const binding = await findSessionRuntimeBinding(ctx, args.sessionId.trim()); + if (binding) ctx = getEnvironmentContext(ctx, binding.environmentId); + } + + // ── SSH remote environments ────────────────────────────────────── + if (command === "list_remote_environments") { + const service = getRemoteEnvironmentService(ctx); + return { + profiles: await service.list(), + ...activeRemoteEnvironmentState(ctx), + statuses: service.getStatuses(), + }; + } + if (command === "upsert_remote_environment") { + const profile = args?.profile; + if (!profile || typeof profile !== "object" || Array.isArray(profile)) { + throw new Error("profile is required"); + } + const saved = await getRemoteEnvironmentService(ctx).upsert( + profile as RemoteEnvironmentInput, + ); + broadcastEvent(ctx, "remote_environment_profiles_changed", {}); + return { profile: saved }; + } + if (command === "test_remote_environment") { + const id = String(args?.id ?? "").trim(); + if (!id) { + throw new Error("remote environment id is required"); + } + const service = getRemoteEnvironmentService(ctx); + const profile = (await service.list()).find((item) => item.id === id); + const result = await service.test(id); + return { + profile, + status: result.state === "available" ? "passed" : "failed", + message: result.message, + remotePlatform: result.remotePlatform, + remoteArch: result.remoteArch, + }; + } + if (command === "connect_remote_environment") { + const id = String(args?.id ?? "").trim(); + if (!id) { + throw new Error("remote environment id is required"); + } + return await withRemoteEnvironmentTransition(ctx, async () => { + const service = getRemoteEnvironmentService(ctx); + const previousEnvironmentId = ctx.activeEnvironmentId; + const previousServiceProfileId = service.getActive()?.profileId; + const previousRuntimeProfileId = + ctx.runtimeBindings.get(previousEnvironmentId)?.kind === "ssh" + ? previousEnvironmentId + : undefined; + const targetWasConnected = Boolean(service.getConnection(id)); + let connection: RemoteEnvironmentConnection | undefined; + let runtimeCommitted = false; + + try { + connection = await service.connect(id); + await connectRemoteSessionRuntime(ctx, connection); + runtimeCommitted = true; + const currentConnection = service.getConnection(id); + if ( + !currentConnection || + currentConnection.endpoint !== connection.endpoint + ) { + throw new Error( + `Remote environment ${id} disconnected during initialization.`, + ); + } + } catch (error) { + if (runtimeCommitted && previousEnvironmentId !== id) { + await disconnectRemoteSessionRuntime(ctx, id); + } + if (!targetWasConnected && service.getConnection(id)) { + await service.disconnect(id).catch((disconnectError) => { + ctx.logger?.log("Failed to roll back SSH connection", { + error: disconnectError, + environmentId: id, + severity: "warn", + }); + }); + } + const rollbackProfileId = + previousRuntimeProfileId && + service.getConnection(previousRuntimeProfileId) + ? previousRuntimeProfileId + : previousServiceProfileId; + if (rollbackProfileId) { + service.activateConnection(rollbackProfileId); + } + ctx.activeEnvironmentId = ctx.runtimeBindings.has(previousEnvironmentId) + ? previousEnvironmentId + : LOCAL_ENVIRONMENT_ID; + throw error; + } + + if (!connection) { + throw new Error(`Remote environment ${id} failed to connect.`); + } + + const previousProfileIds = new Set( + [previousRuntimeProfileId, previousServiceProfileId].filter( + (profileId): profileId is string => + Boolean(profileId) && profileId !== id, + ), + ); + for (const previousProfileId of previousProfileIds) { + await disconnectRemoteSessionRuntime(ctx, previousProfileId); + await service.disconnect(previousProfileId).catch((disconnectError) => { + ctx.logger?.log("Failed to clean up previous SSH connection", { + error: disconnectError, + environmentId: previousProfileId, + severity: "warn", + }); + }); + } + + const result = { + profile: connection.profile, + status: "connected" as const, + environmentId: id, + activeEnvironmentId: id, + activeProfileId: id, + workspaceRoot: connection.workspaceRoot, + homeDir: connection.homeDir, + remotePlatform: connection.platform, + remoteArch: connection.arch, + }; + broadcastEvent(ctx, "remote_environment_changed", result); + return result; + }); + } + if (command === "disconnect_remote_environment") { + return await withRemoteEnvironmentTransition(ctx, async () => { + const service = getRemoteEnvironmentService(ctx); + const requestedId = String(args?.id ?? "").trim(); + const activeBefore = activeRemoteEnvironmentState(ctx); + const id = + requestedId || + activeBefore.activeProfileId || + service.getActive()?.profileId; + if (id) { + await disconnectRemoteSessionRuntime(ctx, id); + await service.disconnect(id); + } + const active = activeRemoteEnvironmentState(ctx); + const result = { + status: "disconnected" as const, + disconnectedProfileId: id ?? null, + ...active, + }; + if (id && activeBefore.activeProfileId === id) { + broadcastLocalEnvironment(ctx); + } + return result; + }); + } + if (command === "delete_remote_environment") { + const id = String(args?.id ?? "").trim(); + if (!id) { + throw new Error("remote environment id is required"); + } + return await withRemoteEnvironmentTransition(ctx, async () => { + const service = getRemoteEnvironmentService(ctx); + const wasActive = ctx.activeEnvironmentId === id; + await disconnectRemoteSessionRuntime(ctx, id); + const deleted = await service.delete(id); + const active = activeRemoteEnvironmentState(ctx); + if (deleted && wasActive) { + broadcastLocalEnvironment(ctx, { reason: "profile_deleted" }); + } + if (deleted) + broadcastEvent(ctx, "remote_environment_profiles_changed", {}); + return { deleted, ...active }; + }); + } + if (command === "list_workspace_directories") { + const environmentId = requestedEnvironmentId(args); + if (!environmentId) { + throw new Error("environmentId is required"); + } + return await listWorkspaceDirectories( + ctx, + environmentId, + typeof args?.path === "string" ? args.path : undefined, + ); + } + // ── Chat session commands ────────────────────────────────────────── if (command === "chat_session_command") { const { handleChatSessionCommand } = await import("./chat-session"); @@ -1363,10 +1922,10 @@ export async function handleCommand( throw new Error("sessionId is required"); } const toolCallId = asTrimmedString(args?.toolCallId); - const hubClient = await ensureSharedHubClient( - ctx, - ctx.sessionManager?.runtimeAddress, - ); + const binding = + (await getCommandSessionBinding(ctx, sessionId, args)) ?? + getCommandRuntimeBinding(ctx, args); + const hubClient = binding.hubClient; const reply = await hubClient.command( "run.proceed_while_running", { sessionId, ...(toolCallId ? { toolCallId } : {}) }, @@ -1387,45 +1946,84 @@ export async function handleCommand( // ── Session data reading ────────────────────────────────────────── if (command === "read_session_messages") { + const sessionId = String(args?.sessionId ?? "").trim(); + const binding = await getCommandSessionBinding(ctx, sessionId, args); + const remoteMessages = + binding?.kind === "ssh" + ? await binding.sessionManager.readMessages(sessionId) + : undefined; return await readSessionMessages( ctx, - String(args?.sessionId ?? ""), + sessionId, typeof args?.maxMessages === "number" ? args.maxMessages : 800, + remoteMessages, ); } if (command === "read_session_hooks") { + const sessionId = String(args?.sessionId ?? "").trim(); + const binding = await getCommandSessionBinding(ctx, sessionId, args); + if (binding?.kind === "ssh") { + throw new Error( + "Remote session hook artifacts are not available through the SSH runtime yet.", + ); + } return await readSessionHooks( - String(args?.sessionId ?? ""), + sessionId, typeof args?.limit === "number" ? args.limit : 300, ); } if (command === "list_session_agents") { + const sessionId = String(args?.sessionId ?? "").trim(); + const binding = await getCommandSessionBinding(ctx, sessionId, args); + if (binding?.kind === "ssh") { + throw new Error( + "Remote session agent artifacts are not available through the SSH runtime yet.", + ); + } return listSessionAgents( - String(args?.sessionId ?? ""), + sessionId, typeof args?.limit === "number" ? args.limit : 200, ); } // ── Process context ─────────────────────────────────────────────── if (command === "get_process_context") { + const binding = getCommandRuntimeBinding(ctx, args); const hubUrl = - ctx.hubClient?.getUrl() ?? - ctx.sessionManager?.runtimeAddress?.trim() ?? + binding.hubClient.getUrl() ?? + binding.sessionManager.runtimeAddress?.trim() ?? null; - const runningSessionCount = Array.from(ctx.liveSessions.values()).filter( - (session) => session.busy || session.status === "running", + const runningSessionCount = Array.from(ctx.liveSessions.entries()).filter( + ([sessionId, session]) => + (session.busy || session.status === "running") && + (session.environmentId ?? + ctx.sessionEnvironmentIds.get(sessionId) ?? + LOCAL_ENVIRONMENT_ID) === binding.environmentId, ).length; return { - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, - homeDir: homedir(), - platform: process.platform, + environmentId: binding.environmentId, + workspaceRoot: binding.workspaceRoot, + cwd: binding.workspaceRoot, + homeDir: binding.remote?.homeDir ?? homedir(), + platform: binding.remote?.platform ?? process.platform, appVersion: packageJson.version, runningSessionCount, + activeEnvironmentId: ctx.activeEnvironmentId, + remoteEnvironment: + binding.kind === "ssh" + ? { + id: binding.environmentId, + name: binding.remote?.profile.name, + host: binding.remote?.profile.host, + workspaceRoot: binding.workspaceRoot, + platform: binding.remote?.platform, + arch: binding.remote?.arch, + } + : null, hub: { - status: ctx.hubClient?.isConnected() ? "connected" : "disconnected", + status: binding.hubClient.isConnected() ? "connected" : "disconnected", url: hubUrl, - error: ctx.hubClient?.getConnectionError()?.message ?? null, + error: binding.hubClient.getConnectionError()?.message ?? null, }, }; } @@ -1447,7 +2045,7 @@ export async function handleCommand( // is still serving other clients' sessions. Drain-first semantics // still give in-flight turns the wait window to finish. const result = await upgradeManagedHub({ - workspaceRoot: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, force: true, reason: "Cline Desktop hub update", }); @@ -1566,10 +2164,12 @@ export async function handleCommand( typeof args?.workspaceRoot === "string" ? args.workspaceRoot.trim() || undefined : undefined; - if (ctx.hubClient) { + const searchClient = + ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient; + if (searchClient) { try { const reply = await withSearchDeadline( - ctx.hubClient.command("session.search", { + searchClient.command("session.search", { query, limit, workspaceRoot, @@ -1601,7 +2201,13 @@ export async function handleCommand( if (command === "get_discovered_session") { const sessionId = String(args?.sessionId ?? args?.session_id ?? "").trim(); if (!sessionId) throw new Error("session id is required"); - return (await getSessionFromSidecarManager(ctx, sessionId)) ?? null; + return ( + (await getSessionFromSidecarManager( + ctx, + sessionId, + requestedEnvironmentId(args), + )) ?? null + ); } // ── Session import from other coding tools ──────────────────────── @@ -1658,8 +2264,9 @@ export async function handleCommand( const sessionId = String(args?.sessionId ?? "").trim(); if (!sessionId) throw new Error("session id is required"); const title = normalizeSessionTitle(String(args?.title ?? "")); - const backend = await resolveSessionBackend({ backendMode: "local" }); - const result = await backend.updateSession({ sessionId, title }); + const binding = await getCommandSessionBinding(ctx, sessionId, args); + if (!binding) throw new Error(`Session ${sessionId} not found`); + const result = await binding.sessionManager.update(sessionId, { title }); if (!result.updated) throw new Error(`Session ${sessionId} not found`); const liveSession = ctx.liveSessions.get(sessionId); if (liveSession) liveSession.title = title; @@ -1675,27 +2282,35 @@ export async function handleCommand( // updateSession replaces metadata wholesale in both the session row and // the manifest, so merge over what each already holds. A null value // removes the key, which is how callers clear a flag. + const binding = await getCommandSessionBinding(ctx, sessionId, args); + if (!binding) throw new Error(`Session ${sessionId} not found`); const store = new SqliteSessionStore(); const asRecord = (value: unknown): JsonRecord => value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; - const existing = store.get(sessionId); + const existingSession = await binding.sessionManager.get(sessionId); + const existing = + binding.kind === "local" ? store.get(sessionId) : undefined; const merged: JsonRecord = { - ...asRecord(readSessionManifest(sessionId)?.metadata), + ...(binding.kind === "local" + ? asRecord(readSessionManifest(sessionId)?.metadata) + : {}), + ...asRecord(existingSession?.metadata), ...asRecord(existing?.metadata), }; for (const [key, value] of Object.entries(patch as JsonRecord)) { if (value === null) delete merged[key]; else merged[key] = value; } - const backend = await resolveSessionBackend({ backendMode: "local" }); - const result = await backend.updateSession({ sessionId, metadata: merged }); + const result = await binding.sessionManager.update(sessionId, { + metadata: merged, + }); if (!result.updated) throw new Error(`Session ${sessionId} not found`); // Annotating a session is not session activity. updateSession stamps // updated_at, which clients sort and label rows by, so a pin would // otherwise make an old session look like it just ran. - if (existing?.updatedAt) { + if (binding.kind === "local" && existing?.updatedAt) { store.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [ existing.updatedAt, sessionId, @@ -1710,11 +2325,12 @@ export async function handleCommand( const store = new SqliteSessionStore(); const row = store.get(sessionId); const manifest = readSessionManifest(sessionId); + const binding = await getCommandSessionBinding(ctx, sessionId, args); let deleted = false; let deleteError: Error | null = null; try { - if (ctx.sessionManager) { - deleted = await ctx.sessionManager.delete(sessionId); + if (binding) { + deleted = await binding.sessionManager.delete(sessionId); } else { const backend = await resolveSessionBackend({ backendMode: "local" }); const deleteSession = ( @@ -1734,10 +2350,22 @@ export async function handleCommand( } catch (error) { deleteError = error instanceof Error ? error : new Error(String(error)); } - if (store.delete(sessionId, true)) { + if (binding?.kind !== "ssh" && store.delete(sessionId, true)) { deleted = true; } ctx.liveSessions.delete(sessionId); + ctx.sessionEnvironmentIds.delete(sessionId); + if (binding?.kind === "ssh") { + if (!deleted && deleteError) throw deleteError; + if (deleted) { + broadcastEvent(ctx, "session_deleted", { + sessionId, + command, + deleted: true, + }); + } + return deleted; + } const directoryCandidates = new Set([ join(sharedSessionDataDir(), sessionId), ]); @@ -1806,7 +2434,8 @@ export async function handleCommand( // ── Workspace file search ───────────────────────────────────────── if (command === "search_workspace_files") { - return await searchWorkspaceFiles(ctx, args); + const binding = getCommandRuntimeBinding(ctx, args); + return await searchRemoteWorkspaceFiles(ctx, binding, args); } // ── External links ───────────────────────────────────────────────── @@ -2211,10 +2840,10 @@ export async function handleCommand( return connectorChannelsPayload(); } if (command === "start_connector_channel") { - return await startConnectorChannel(ctx.workspaceRoot, args); + return await startConnectorChannel(ctx.localWorkspaceRoot, args); } if (command === "stop_connector_channel") { - return await stopConnectorChannel(ctx.workspaceRoot, args); + return await stopConnectorChannel(ctx.localWorkspaceRoot, args); } // ── MCP server management ───────────────────────────────────────── @@ -2426,37 +3055,55 @@ export async function handleCommand( return await getPullRequestStatus( typeof args?.cwd === "string" && args.cwd.trim() ? args.cwd.trim() - : ctx.workspaceRoot, + : ctx.localWorkspaceRoot, ); } if (command === "get_git_branch") { + const binding = getCommandRuntimeBinding(ctx, args); const cwd = typeof args?.cwd === "string" && args.cwd.trim() ? args.cwd.trim() - : ctx.workspaceRoot; - const branches = await listGitBranches(ctx, cwd); - const { prewarmWorkspaceMetadata } = await import("./chat-session"); - prewarmWorkspaceMetadata(cwd); - return { branch: branches.current }; + : binding.workspaceRoot; + const branches = await listGitBranches(ctx, binding, cwd); + if (binding.kind === "local") { + const { prewarmWorkspaceMetadata } = await import("./chat-session"); + prewarmWorkspaceMetadata(cwd); + } + return { environmentId: binding.environmentId, branch: branches.current }; } if (command === "list_git_branches") { - return await listGitBranches( + const binding = getCommandRuntimeBinding(ctx, args); + const branches = await listGitBranches( ctx, + binding, typeof args?.cwd === "string" ? args.cwd : undefined, ); + return { environmentId: binding.environmentId, ...branches }; } if (command === "checkout_git_branch") { const cwd = typeof args?.cwd === "string" ? args.cwd : undefined; const branch = String(args?.branch ?? "").trim(); if (!branch) throw new Error("branch is required"); - const targetCwd = cwd?.trim() || ctx.workspaceRoot; - await execFileAsync("git", ["checkout", branch], { - cwd: targetCwd, - encoding: "utf8", - }); + const binding = getCommandRuntimeBinding(ctx, args); + const targetCwd = cwd?.trim() || binding.workspaceRoot; + if (binding.kind === "ssh") { + if (!ctx.remoteEnvironments) { + throw new Error("Remote environment service is unavailable"); + } + await ctx.remoteEnvironments.run(binding.environmentId, { + command: "git", + args: ["checkout", branch], + cwd: targetCwd, + }); + } else { + await execFileAsync("git", ["checkout", branch], { + cwd: targetCwd, + encoding: "utf8", + }); + } const { refreshWorkspaceMetadata } = await import("./chat-session"); - refreshWorkspaceMetadata(targetCwd); - return { branch }; + if (binding.kind === "local") refreshWorkspaceMetadata(targetCwd); + return { environmentId: binding.environmentId, branch }; } // ── Routine schedules ───────────────────────────────────────────── @@ -2503,7 +3150,7 @@ export async function handleCommand( } if (command === "uninstall_local_primitive") { const result = await uninstallLocalPrimitive(args, { - workspaceRoot: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, }); return result; } @@ -2563,8 +3210,25 @@ export async function handleCommand( // ── Native OS commands ──────────────────────────────────────────── if (command === "validate_workspace_directory") { + const binding = getCommandRuntimeBinding(ctx, args); const workspacePath = String(args?.path ?? "").trim(); - if (!workspacePath) return { valid: false }; + if (!workspacePath) { + return { environmentId: binding.environmentId, valid: false }; + } + if (binding.kind === "ssh") { + try { + if (!ctx.remoteEnvironments) { + throw new Error("Remote environment service is unavailable"); + } + await ctx.remoteEnvironments.run(binding.environmentId, { + command: "test", + args: ["-d", workspacePath], + }); + return { environmentId: binding.environmentId, valid: true }; + } catch { + return { environmentId: binding.environmentId, valid: false }; + } + } // Support typed/pasted paths like "~/projects/app" from the manual // path-entry fallback; return the resolved path so the caller adopts it. const resolved = @@ -2574,12 +3238,21 @@ export async function handleCommand( ? join(homedir(), workspacePath.slice(2)) : workspacePath; try { - return { valid: statSync(resolved).isDirectory(), path: resolved }; + return { + environmentId: binding.environmentId, + valid: statSync(resolved).isDirectory(), + path: resolved, + }; } catch { - return { valid: false, path: resolved }; + return { + environmentId: binding.environmentId, + valid: false, + path: resolved, + }; } } if (command === "pick_workspace_directory") { + if (getCommandRuntimeBinding(ctx, args).kind === "ssh") return null; return await pickWorkspaceDirectory(); } if (command === "open_mcp_settings_file") { @@ -2591,12 +3264,17 @@ export async function handleCommand( return await listAvailableCodeEditors(); } if (command === "open_file_in_editor") { + if (getCommandRuntimeBinding(ctx, args).kind === "ssh") { + throw new Error( + "Opening remote files in a local editor is not available in the SSH proof of concept yet.", + ); + } const rawPath = String(args?.path ?? "").trim(); if (!rawPath) throw new Error("path is required"); const baseDir = typeof args?.cwd === "string" && args.cwd.trim() ? args.cwd.trim() - : ctx.workspaceRoot; + : ctx.localWorkspaceRoot; const filePath = isAbsolute(rawPath) ? rawPath : join(baseDir, rawPath); if (!existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index 4823a5c5d8..becdde7eb0 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -194,7 +194,7 @@ describe("Code sidecar runtime capabilities", () => { const ctx = createSidecarContext("/workspace/project"); const hubClient = await ensureSharedHubClient(ctx); - expect(hubClient).toBe(ctx.hubClient); + expect(hubClient).toBeDefined(); expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({ strategy: "require-hub", @@ -230,8 +230,14 @@ describe("Code sidecar runtime capabilities", () => { ]; const command = vi.fn(async () => ({ ok: true, payload: { hits } })); const list = vi.fn(async () => []); - ctx.hubClient = { command } as never; - ctx.sessionManager = { list } as never; + ctx.runtimeBindings.set("local", { + environmentId: "local", + kind: "local", + workspaceRoot: "/workspace/project", + hubClient: { command } as never, + sessionManager: { list } as never, + unsubscribeSessionEvents: () => {}, + }); const results = (await handleCommand(ctx, "search_sessions", { query: "generate", @@ -269,8 +275,14 @@ describe("Code sidecar runtime capabilities", () => { metadata: { title: oversizedPrompt }, }, ]); - ctx.hubClient = { command } as never; - ctx.sessionManager = { list } as never; + ctx.runtimeBindings.set("local", { + environmentId: "local", + kind: "local", + workspaceRoot: "/workspace/project", + hubClient: { command } as never, + sessionManager: { list } as never, + unsubscribeSessionEvents: () => {}, + }); const results = (await handleCommand(ctx, "search_sessions", { query: "generate", @@ -304,8 +316,14 @@ describe("Code sidecar runtime capabilities", () => { metadata: { title: "generate an image of a puppy" }, }, ]); - ctx.hubClient = { command } as never; - ctx.sessionManager = { list } as never; + ctx.runtimeBindings.set("local", { + environmentId: "local", + kind: "local", + workspaceRoot: "/workspace/project", + hubClient: { command } as never, + sessionManager: { list } as never, + unsubscribeSessionEvents: () => {}, + }); const results = (await handleCommand(ctx, "search_sessions", { query: "generate", @@ -339,8 +357,14 @@ describe("Code sidecar runtime capabilities", () => { metadata: { title: "generate an image of a puppy" }, }, ]); - ctx.hubClient = { command } as never; - ctx.sessionManager = { list } as never; + ctx.runtimeBindings.set("local", { + environmentId: "local", + kind: "local", + workspaceRoot: "/workspace/project", + hubClient: { command } as never, + sessionManager: { list } as never, + unsubscribeSessionEvents: () => {}, + }); const pending = handleCommand(ctx, "search_sessions", { query: "generate", @@ -583,6 +607,7 @@ describe("Code sidecar runtime capabilities", () => { events.find((message) => message.event.name === "prompts_in_queue_state") ?.event.payload, ).toEqual({ + environmentId: "local", sessionId: "session-1", items: [ { id: "prompt-2", prompt: "second", steer: false, attachmentCount: 0 }, @@ -748,7 +773,7 @@ describe("Code sidecar runtime capabilities", () => { expect.objectContaining({ event: expect.objectContaining({ name: "ask_question_answered", - payload: { requestId }, + payload: { requestId, environmentId: "local" }, }), }), ); @@ -1245,6 +1270,7 @@ describe("Code sidecar runtime capabilities", () => { event: { name: "task.created", payload: { + environmentId: "local", taskId: "task-1", status: "pending_approval", }, @@ -1273,6 +1299,7 @@ describe("Code sidecar runtime capabilities", () => { event: { name: "settings.changed", payload: { + environmentId: "local", types: ["plugins", "skills", "mcp"], }, }, @@ -1348,9 +1375,11 @@ describe("Chat chunk pipe selection", () => { status: "running", attachedViaHub: true, }); - ctx.sessionManager = { - hasSessionSubscription: (id: string) => coreSubscriptions.has(id), - } as never; + ctx.runtimeBindings.set("local", { + sessionManager: { + hasSessionSubscription: (id: string) => coreSubscriptions.has(id), + }, + } as never); return ctx; } diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index f4dfc2fa28..d4c67981d7 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -36,9 +36,11 @@ import type { PendingAskQuestion, PendingToolApproval, PromptInQueue, + SessionRuntimeBinding, SidecarContext, SidecarWebSocketClient, } from "./types"; +import { LOCAL_ENVIRONMENT_ID } from "./types"; const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000; const hubClientInitialization = new WeakMap< @@ -51,6 +53,73 @@ const approvalReadinessUpdates = new WeakMap>(); // Helpers — WebSocket broadcast // --------------------------------------------------------------------------- +// Session state belongs to a runtime environment, not to a globally unique ID. +const environmentContexts = new WeakMap< + SidecarContext, + Map +>(); +const contextOwners = new WeakMap(); + +export function getEnvironmentContext( + ctx: SidecarContext, + environmentId: string, +): SidecarContext { + const owner = contextOwners.get(ctx) ?? ctx; + let contexts = environmentContexts.get(owner); + if (!contexts) { + contexts = new Map(); + environmentContexts.set(owner, contexts); + } + const existing = contexts.get(environmentId); + if (existing) return existing; + const local = environmentId === LOCAL_ENVIRONMENT_ID; + // Shared services are inherited so later initialization remains visible; + // session state and event identity are owned by this environment. + const scoped: SidecarContext = Object.assign(Object.create(owner), { + activeEnvironmentId: environmentId, + liveSessions: local ? owner.liveSessions : new Map(), + streamIndices: local ? owner.streamIndices : new Map(), + sessionEnvironmentIds: local ? owner.sessionEnvironmentIds : new Map(), + restoringWorkspacePaths: local ? owner.restoringWorkspacePaths : new Set(), + pendingApprovals: local ? owner.pendingApprovals : new Map(), + pendingQuestions: local ? owner.pendingQuestions : new Map(), + }); + contextOwners.set(scoped, owner); + contexts.set(environmentId, scoped); + return scoped; +} + +export function getEnvironmentContexts(ctx: SidecarContext): SidecarContext[] { + const owner = contextOwners.get(ctx) ?? ctx; + getEnvironmentContext(owner, LOCAL_ENVIRONMENT_ID); + return [...(environmentContexts.get(owner)?.values() ?? [])]; +} +function clearEnvironmentSessions(ctx: SidecarContext, reason: string): void { + for (const [id, session] of ctx.liveSessions) + discardAllTrackedAttachments(id, session); + ctx.liveSessions.clear(); + ctx.streamIndices.clear(); + ctx.sessionEnvironmentIds.clear(); + ctx.restoringWorkspacePaths.clear(); + for (const pending of ctx.pendingApprovals.values()) + pending.resolve({ approved: false, reason }); + ctx.pendingApprovals.clear(); + for (const pending of ctx.pendingQuestions.values()) { + if (pending.timeoutId) clearTimeout(pending.timeoutId); + pending.reject(new Error(reason)); + } + ctx.pendingQuestions.clear(); +} + +function sessionEventPayload(ctx: SidecarContext, payload: unknown): unknown { + return payload && typeof payload === "object" + ? { + environmentId: ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID, + ...payload, + } + : payload; +} + function nowMs(): number { return Date.now(); } @@ -63,7 +132,7 @@ export function encodeSidecarEvent(name: string, payload: unknown): string { } function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void { - const encoded = encodeSidecarEvent(name, payload); + const encoded = encodeSidecarEvent(name, sessionEventPayload(ctx, payload)); for (const client of ctx.wsClients) { try { client.send(encoded); @@ -84,7 +153,7 @@ export function sendEventToClient( payload: unknown, ): boolean { try { - client.send(encodeSidecarEvent(name, payload)); + client.send(encodeSidecarEvent(name, sessionEventPayload(ctx, payload))); return true; } catch { ctx.wsClients.delete(client); @@ -100,13 +169,15 @@ export function cancelSidecarToolApprovalsForOwner( ctx: SidecarContext, owner: SidecarWebSocketClient, ): void { - for (const [requestId, pending] of ctx.pendingApprovals) { - if (pending.owner !== owner) continue; - ctx.pendingApprovals.delete(requestId); - pending.resolve({ - approved: false, - reason: "Desktop approval surface disconnected", - }); + for (const scoped of getEnvironmentContexts(ctx)) { + for (const [requestId, pending] of scoped.pendingApprovals) { + if (pending.owner !== owner) continue; + scoped.pendingApprovals.delete(requestId); + pending.resolve({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + } } } @@ -117,21 +188,21 @@ export function syncSidecarApprovalReadiness( const update = previous .catch(() => undefined) .then(async () => { - const hubClient = ctx.hubClient; - if (!hubClient) return; - await hubClient.updateCapabilities( - [...ctx.wsClients].some( - (client) => client.data?.canApproveTools === true, - ) - ? [ - { - name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, - description: - "Cline Code has a live user surface for tool review.", - }, - ] - : [], - ); + for (const { hubClient } of ctx.runtimeBindings.values()) { + await hubClient.updateCapabilities( + [...ctx.wsClients].some( + (client) => client.data?.canApproveTools === true, + ) + ? [ + { + name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, + description: + "Cline Code has a live user surface for tool review.", + }, + ] + : [], + ); + } }); approvalReadinessUpdates.set(ctx, update); return update.finally(() => { @@ -178,7 +249,8 @@ function emitChunk( chunk: string, ): void { const ts = nowMs(); - appendSessionChunk(sessionId, stream, chunk, ts); + if (ctx.activeEnvironmentId === LOCAL_ENVIRONMENT_ID) + appendSessionChunk(sessionId, stream, chunk, ts); const nextIndex = (ctx.streamIndices.get(sessionId) ?? 0) + 1; ctx.streamIndices.set(sessionId, nextIndex); sendEvent(ctx, "chat_event", { @@ -585,13 +657,14 @@ export function createSidecarContext( wsClients: new Set(), pendingApprovals: new Map(), pendingQuestions: new Map(), - sessionManager: null, - hubClient: null, - workspaceRoot, + runtimeBindings: new Map(), + sessionEnvironmentIds: new Map(), + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + remoteEnvironments: null, + localWorkspaceRoot: workspaceRoot, logger: observability.logger, telemetry: observability.telemetry, telemetryUser: observability.telemetryUser, - unsubscribeSessionEvents: null, hubBuildMismatch: null, }; } @@ -602,13 +675,8 @@ export async function disposeSidecarContext( ): Promise { const cleanup: Array> = []; - ctx.unsubscribeSessionEvents?.(); - ctx.unsubscribeSessionEvents = null; - - for (const [sessionId, session] of ctx.liveSessions) { - discardAllTrackedAttachments(sessionId, session); - } - ctx.liveSessions.clear(); + for (const scoped of getEnvironmentContexts(ctx)) + clearEnvironmentSessions(scoped, reason); for (const client of ctx.wsClients) { try { @@ -618,26 +686,16 @@ export async function disposeSidecarContext( } } ctx.wsClients.clear(); - for (const pending of ctx.pendingApprovals.values()) { - pending.resolve({ approved: false, reason }); + for (const binding of ctx.runtimeBindings.values()) { + binding.unsubscribeSessionEvents(); + cleanup.push(binding.hubClient.dispose()); + cleanup.push(binding.sessionManager.dispose(reason)); } - ctx.pendingApprovals.clear(); - for (const pending of ctx.pendingQuestions.values()) { - if (pending.timeoutId) clearTimeout(pending.timeoutId); - pending.reject(new Error(reason)); - } - ctx.pendingQuestions.clear(); - - const hubClient = ctx.hubClient; - ctx.hubClient = null; - if (hubClient) { - cleanup.push(hubClient.dispose()); - } - - const sessionManager = ctx.sessionManager; - ctx.sessionManager = null; - if (sessionManager) { - cleanup.push(sessionManager.dispose(reason)); + ctx.runtimeBindings.clear(); + ctx.sessionEnvironmentIds.clear(); + if (ctx.remoteEnvironments) { + cleanup.push(ctx.remoteEnvironments.dispose()); + ctx.remoteEnvironments = null; } // Shuts down the PostHog client the feature flags service owns, flushing @@ -859,7 +917,11 @@ export function handleHubLiveEvent( // of start/send/pending_prompts and unsubscribes on stop); once it is, // `handleCoreSessionEvent` carries everything below and a second copy here // would double every delta, tool row, and status change. - if (ctx.sessionManager?.hasSessionSubscription(sessionId)) { + if ( + ctx.runtimeBindings + .get(ctx.activeEnvironmentId ?? LOCAL_ENVIRONMENT_ID) + ?.sessionManager.hasSessionSubscription(sessionId) + ) { return; } @@ -1049,7 +1111,7 @@ async function handleHubApprovalRequest( ? (event.payload.policy as ToolApprovalRequest["policy"]) : { autoApprove: false }, }); - const client = ctx.hubClient; + const client = getSessionRuntimeBinding(ctx, sessionId).hubClient; if (!client) throw new Error("Hub client disconnected before approval response"); await client.command( @@ -1070,7 +1132,9 @@ export async function initializeSessionManager( const sessionManager = await ClineCore.create({ clientName: "cline-code", backendMode: "hub", - capabilities: createSidecarRuntimeCapabilities(ctx), + capabilities: createSidecarRuntimeCapabilities( + getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID), + ), logger: ctx.logger, telemetry: ctx.telemetry, featureFlags: getDesktopFeatureFlagsService({ @@ -1079,8 +1143,8 @@ export async function initializeSessionManager( }), hub: { strategy: "require-hub", - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, + cwd: ctx.localWorkspaceRoot, clientType: "code-sidecar", displayName: "Cline Desktop sidecar", }, @@ -1088,27 +1152,193 @@ export async function initializeSessionManager( // Subscribe to all session events and relay them to WS clients const unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => { - handleCoreSessionEvent(ctx, event); + handleCoreSessionEvent( + getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID), + event, + ); }); + let hubClient: NodeHubClient; try { - await ensureSharedHubClient(ctx, sessionManager.runtimeAddress); + hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress); } catch (error) { unsubscribe(); await sessionManager.dispose("code_sidecar_hub_initialization_failed"); throw error; } - ctx.sessionManager = sessionManager; - ctx.unsubscribeSessionEvents = unsubscribe; + ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, { + environmentId: LOCAL_ENVIRONMENT_ID, + kind: "local", + workspaceRoot: ctx.localWorkspaceRoot, + sessionManager, + hubClient, + unsubscribeSessionEvents: unsubscribe, + }); + await syncSidecarApprovalReadiness(ctx); +} + +export function getRuntimeBinding( + ctx: SidecarContext, + environmentId = ctx.activeEnvironmentId, +): SessionRuntimeBinding { + const binding = ctx.runtimeBindings.get(environmentId); + if (!binding) { + throw new Error(`Environment ${environmentId} is not connected.`); + } + return binding; +} + +export function getSessionRuntimeBinding( + ctx: SidecarContext, + sessionId?: string, + requestedEnvironmentId?: string, +): SessionRuntimeBinding { + const environmentId = + requestedEnvironmentId?.trim() || + (sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) || + (sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) || + ctx.activeEnvironmentId; + return getRuntimeBinding(ctx, environmentId); +} + +export async function findSessionRuntimeBinding( + ctx: SidecarContext, + sessionId: string, + preferredEnvironmentId?: string, +): Promise { + if (preferredEnvironmentId?.trim()) + return getRuntimeBinding(ctx, preferredEnvironmentId.trim()); + const matches: SessionRuntimeBinding[] = []; + for (const binding of ctx.runtimeBindings.values()) { + try { + if (await binding.sessionManager.get(sessionId)) matches.push(binding); + } catch { + // Other connected runtimes remain readable. + } + } + if (matches.length > 1) + throw new Error( + `Session ${sessionId} exists in multiple environments; environmentId is required.`, + ); + return matches[0]; +} + +async function disposeRuntimeBinding( + binding: SessionRuntimeBinding, + reason: string, +): Promise { + try { + binding.unsubscribeSessionEvents(); + } catch { + // Continue disposing the Hub clients even if an event source has already + // torn down its subscription. + } + await Promise.allSettled([ + binding.hubClient.dispose(), + binding.sessionManager.dispose(reason), + ]); +} + +export async function connectRemoteSessionRuntime( + ctx: SidecarContext, + connection: NonNullable, +): Promise { + const environmentId = connection.profile.id; + const existing = ctx.runtimeBindings.get(environmentId); + const sessionManager = await ClineCore.create({ + clientName: "cline-code", + backendMode: "remote", + capabilities: createSidecarRuntimeCapabilities( + getEnvironmentContext(ctx, environmentId), + ), + logger: ctx.logger, + telemetry: ctx.telemetry, + remote: { + endpoint: connection.endpoint, + authToken: connection.authToken, + workspaceRoot: connection.workspaceRoot, + cwd: connection.workspaceRoot, + clientType: "code-sidecar-ssh", + displayName: `Code App (${connection.profile.name})`, + }, + }); + let unsubscribe: (() => void) | undefined; + let hubClient: NodeHubClient | undefined; + try { + unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => { + handleCoreSessionEvent(getEnvironmentContext(ctx, environmentId), event); + }); + hubClient = new NodeHubClient({ + url: connection.endpoint, + authToken: connection.authToken, + clientType: "code-sidecar-ssh-observer", + displayName: `Code App observer (${connection.profile.name})`, + workspaceRoot: connection.workspaceRoot, + cwd: connection.workspaceRoot, + }); + await hubClient.connect(); + hubClient.subscribe((event) => + handleHubLiveEvent(getEnvironmentContext(ctx, environmentId), event), + ); + } catch (error) { + try { + unsubscribe?.(); + } catch { + // Best effort; the failed runtime still needs to be disposed below. + } + const disposals: Promise[] = [ + sessionManager.dispose("code_sidecar_remote_initialization_failed"), + ]; + if (hubClient) disposals.push(hubClient.dispose()); + await Promise.allSettled(disposals); + throw error; + } + + const binding: SessionRuntimeBinding = { + environmentId, + kind: "ssh", + workspaceRoot: connection.workspaceRoot, + sessionManager, + hubClient, + unsubscribeSessionEvents: unsubscribe, + remote: connection, + }; + ctx.runtimeBindings.set(environmentId, binding); + await syncSidecarApprovalReadiness(ctx); + ctx.activeEnvironmentId = environmentId; + if (existing) { + await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect"); + } + return binding; +} + +export async function disconnectRemoteSessionRuntime( + ctx: SidecarContext, + environmentId: string, +): Promise { + const binding = ctx.runtimeBindings.get(environmentId); + const owner = contextOwners.get(ctx) ?? ctx; + const scoped = environmentContexts.get(owner)?.get(environmentId); + if (scoped) + clearEnvironmentSessions(scoped, "Remote environment disconnected"); + environmentContexts.get(owner)?.delete(environmentId); + if (binding?.kind === "ssh") { + ctx.runtimeBindings.delete(environmentId); + await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect"); + } + if (ctx.activeEnvironmentId === environmentId) { + ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID; + } } export async function ensureSharedHubClient( ctx: SidecarContext, preferredUrl?: string, ): Promise { - if (ctx.hubClient) { - return ctx.hubClient; + const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient; + if (existing) { + return existing; } const pending = hubClientInitialization.get(ctx); if (pending) { @@ -1120,8 +1350,8 @@ export async function ensureSharedHubClient( preferredUrl?.trim() || (await ensureCompatibleLocalHubUrl({ strategy: "require-hub", - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, + cwd: ctx.localWorkspaceRoot, })); if (!url) { throw new Error("Unable to start or connect to the shared Cline Hub."); @@ -1131,16 +1361,17 @@ export async function ensureSharedHubClient( url, clientType: "code-sidecar-observer", displayName: "Cline Desktop observer", - workspaceRoot: ctx.workspaceRoot, - cwd: ctx.workspaceRoot, + workspaceRoot: ctx.localWorkspaceRoot, + cwd: ctx.localWorkspaceRoot, }); try { await client.connect(); client.subscribe((event) => { - handleHubLiveEvent(ctx, event); + handleHubLiveEvent( + getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID), + event, + ); }); - ctx.hubClient = client; - await syncSidecarApprovalReadiness(ctx); return client; } catch (error) { await client.dispose().catch(() => undefined); diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index 4706d243f9..bdbc54f5ad 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -2,14 +2,15 @@ import { homedir } from "node:os"; import { checkManagedHubBuildMismatch, createClineTelemetryServiceConfig, + ensureLoginShellPath, readGlobalSettings, setHomeDirIfUnset, setModelToolEnabledGlobally, watchManagedHubBuildMismatch, } from "@cline/core"; +import { runRemoteHelperEntrypoint } from "@cline/core/remote/helper"; import { captureSdkError, - claimHubDaemonProcess, disableCurrentDirectoryExecutableSearch, } from "@cline/shared"; import { prewarmWorkspaceMetadata } from "./chat-session"; @@ -23,7 +24,6 @@ import { import { createDesktopObservability } from "./observability"; import { resolveWorkspaceRoot } from "./paths"; import { startServer } from "./server"; -import { ensureLoginShellPath } from "./shell-path"; import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck"; import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types"; @@ -237,10 +237,7 @@ async function runEntrypoint(): Promise { return; } disableCurrentDirectoryExecutableSearch(); - // Claim rather than read: consuming the sentinel keeps daemon-hosted sessions - // from handing it to every process they spawn. - if (claimHubDaemonProcess()) { - await import("@cline/core/hub/daemon-entry"); + if (await runRemoteHelperEntrypoint()) { return; } await main(); diff --git a/apps/examples/desktop-app/sidecar/mcp.test.ts b/apps/examples/desktop-app/sidecar/mcp.test.ts index a96a334648..d33ee08869 100644 --- a/apps/examples/desktop-app/sidecar/mcp.test.ts +++ b/apps/examples/desktop-app/sidecar/mcp.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { handleCommand } from "./commands"; +import { createSidecarContext } from "./context"; import { buildMcpServersResponse, shouldProbeMcpServerAfterUpsert, @@ -10,20 +11,7 @@ import { import type { JsonRecord, SidecarContext } from "./types"; function createContext(workspaceRoot: string): SidecarContext { - return { - liveSessions: new Map(), - restoringWorkspacePaths: new Set(), - streamIndices: new Map(), - bootId: "test-boot", - wsClients: new Set(), - pendingApprovals: new Map(), - pendingQuestions: new Map(), - sessionManager: null, - hubClient: null, - workspaceRoot, - unsubscribeSessionEvents: null, - hubBuildMismatch: null, - }; + return createSidecarContext(workspaceRoot); } describe("desktop MCP settings", () => { diff --git a/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts b/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts new file mode 100644 index 0000000000..5efb6778c8 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/remote-environment-commands.test.ts @@ -0,0 +1,950 @@ +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + RemoteEnvironmentConnection, + RemoteEnvironmentProfile, + RemoteEnvironmentService, + RemoteEnvironmentStatus, +} from "@cline/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionRuntimeBinding, SidecarContext } from "./types"; + +const coreCreateMock = vi.hoisted(() => vi.fn()); +const hubClientConstructorMock = vi.hoisted(() => vi.fn()); +const hubConnectMock = vi.hoisted(() => vi.fn()); +const hubSubscribeMock = vi.hoisted(() => vi.fn()); +const hubDisposeMock = vi.hoisted(() => vi.fn()); +const sessionStoreGetMock = vi.hoisted(() => vi.fn()); +const sessionStoreDeleteMock = vi.hoisted(() => vi.fn()); +const sessionStoreRunMock = vi.hoisted(() => vi.fn()); + +vi.mock("@cline/core", async () => { + const actual = + await vi.importActual("@cline/core"); + return { + ...actual, + ClineCore: { + create: coreCreateMock, + }, + SqliteSessionStore: class { + public get(sessionId: string): unknown { + return sessionStoreGetMock(sessionId); + } + + public delete(sessionId: string, cascade?: boolean): boolean { + return sessionStoreDeleteMock(sessionId, cascade); + } + + public run(sql: string, params?: unknown[]): void { + sessionStoreRunMock(sql, params); + } + }, + NodeHubClient: class { + public async updateCapabilities(): Promise {} + public constructor(options: unknown) { + hubClientConstructorMock(options); + } + + public connect(): Promise { + return hubConnectMock(); + } + + public subscribe(listener: unknown): () => void { + return hubSubscribeMock(listener); + } + + public dispose(): Promise { + return hubDisposeMock(); + } + }, + }; +}); + +const profile: RemoteEnvironmentProfile = { + id: "remote-1", + name: "Build box", + host: "build.example.com", + user: "alice", + port: 2222, + createdAt: "2026-08-06T12:00:00.000Z", + updatedAt: "2026-08-06T12:00:00.000Z", +}; + +const connection: RemoteEnvironmentConnection = { + profile, + profileId: profile.id, + state: "connected", + endpoint: "ws://127.0.0.1:40123/hub", + authToken: "remote-hub-token", + workspaceRoot: "/home/alice", + homeDir: "/home/alice", + platform: "linux", + arch: "arm64", + remoteHubUrl: "ws://127.0.0.1:25463/hub", + localPort: 40123, + connectedAt: "2026-08-06T12:01:00.000Z", +}; + +const secondProfile: RemoteEnvironmentProfile = { + ...profile, + id: "remote-2", + name: "Test box", + host: "test.example.com", + updatedAt: "2026-08-06T12:02:00.000Z", +}; + +const secondConnection: RemoteEnvironmentConnection = { + ...connection, + profile: secondProfile, + profileId: secondProfile.id, + endpoint: "ws://127.0.0.1:40124/hub", + authToken: "second-remote-hub-token", + workspaceRoot: "/home/tester", + homeDir: "/home/tester", + remoteHubUrl: "ws://127.0.0.1:25464/hub", + localPort: 40124, + connectedAt: "2026-08-06T12:03:00.000Z", +}; + +type FakeService = { + service: RemoteEnvironmentService; + list: ReturnType; + upsert: ReturnType; + test: ReturnType; + connect: ReturnType; + disconnect: ReturnType; + delete: ReturnType; + run: ReturnType; +}; + +function createFakeService( + availableConnections: RemoteEnvironmentConnection[] = [connection], +): FakeService { + const profiles = availableConnections.map((item) => item.profile); + const availableById = new Map( + availableConnections.map((item) => [item.profileId, item]), + ); + const connectedById = new Map(); + let activeProfileId: string | undefined; + const list = vi.fn(async () => profiles); + const upsert = vi.fn(async () => profile); + const test = vi.fn( + async (): Promise => ({ + profileId: profile.id, + state: "available", + updatedAt: "2026-08-06T12:00:30.000Z", + message: "SSH connection succeeded", + remotePlatform: "linux", + remoteArch: "arm64", + }), + ); + const connect = vi.fn(async (id: string) => { + const next = availableById.get(id); + if (!next) throw new Error(`Unknown fake remote environment: ${id}`); + connectedById.set(id, next); + activeProfileId = id; + return next; + }); + const disconnect = vi.fn(async (id?: string) => { + const targetId = id ?? activeProfileId; + if (!targetId) return false; + const deleted = connectedById.delete(targetId); + if (activeProfileId === targetId) activeProfileId = undefined; + return deleted; + }); + const deleteProfile = vi.fn(async () => true); + const run = vi.fn(async () => ({ stdout: "", stderr: "", exitCode: 0 })); + const service = { + list, + upsert, + test, + connect, + disconnect, + delete: deleteProfile, + run, + getActive: vi.fn(() => + activeProfileId ? connectedById.get(activeProfileId) : undefined, + ), + getConnection: vi.fn((id: string) => connectedById.get(id)), + activateConnection: vi.fn((id: string) => { + if (!connectedById.has(id)) return false; + activeProfileId = id; + return true; + }), + getStatuses: vi.fn(() => []), + } as unknown as RemoteEnvironmentService; + return { + service, + list, + upsert, + test, + connect, + disconnect, + delete: deleteProfile, + run, + }; +} + +function createManager() { + const unsubscribe = vi.fn(); + const manager = { + subscribe: vi.fn(() => unsubscribe), + dispose: vi.fn(async () => undefined), + }; + return { manager, unsubscribe }; +} + +function attachEventRecorder(ctx: SidecarContext): ReturnType { + const send = vi.fn(); + ctx.wsClients.add({ send }); + return send; +} + +function readEvent(send: ReturnType, index: number) { + return JSON.parse(String(send.mock.calls[index]?.[0])); +} + +function createExistingRemoteBinding( + environmentId: string, +): SessionRuntimeBinding { + const sessionManager = { + dispose: vi.fn(async () => undefined), + } as unknown as SessionRuntimeBinding["sessionManager"] & { + dispose: ReturnType; + }; + const hubClient = { + dispose: vi.fn(async () => undefined), + } as unknown as SessionRuntimeBinding["hubClient"] & { + dispose: ReturnType; + }; + return { + environmentId, + kind: "ssh", + workspaceRoot: "/old/workspace", + sessionManager, + hubClient, + unsubscribeSessionEvents: vi.fn(), + }; +} + +describe("remote environment command routing", () => { + beforeEach(() => { + coreCreateMock.mockReset(); + hubClientConstructorMock.mockReset(); + hubConnectMock.mockReset(); + hubSubscribeMock.mockReset(); + hubDisposeMock.mockReset(); + sessionStoreGetMock.mockReset(); + sessionStoreDeleteMock.mockReset(); + sessionStoreRunMock.mockReset(); + hubConnectMock.mockResolvedValue(undefined); + hubSubscribeMock.mockReturnValue(() => undefined); + hubDisposeMock.mockResolvedValue(undefined); + sessionStoreGetMock.mockReturnValue(undefined); + sessionStoreDeleteMock.mockReturnValue(false); + }); + + it("routes proceed-while-running exclusively to the requested SSH Hub", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const ctx = createSidecarContext("/local/project"); + const remoteCommand = vi.fn(async () => ({ + ok: true, + payload: { detachedCount: 1 }, + })); + const localCommand = vi.fn(); + ctx.runtimeBindings.set("local", { + ...createExistingRemoteBinding("local"), + kind: "local", + hubClient: { command: localCommand }, + } as unknown as SessionRuntimeBinding); + ctx.runtimeBindings.set(profile.id, { + ...createExistingRemoteBinding(profile.id), + hubClient: { command: remoteCommand }, + } as unknown as SessionRuntimeBinding); + await expect( + handleCommand(ctx, "proceed_while_running", { + environmentId: profile.id, + sessionId: "same-id", + toolCallId: "tool-1", + }), + ).resolves.toEqual({ detachedCount: 1 }); + expect(remoteCommand).toHaveBeenCalledWith( + "run.proceed_while_running", + { sessionId: "same-id", toolCallId: "tool-1" }, + "same-id", + ); + expect(localCommand).not.toHaveBeenCalled(); + await expect( + handleCommand(ctx, "proceed_while_running", { + environmentId: "disconnected", + sessionId: "same-id", + }), + ).rejects.toThrow(); + expect(localCommand).not.toHaveBeenCalled(); + }); + + it("routes list, upsert, and SSH test commands through the configured service", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService(); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + const send = attachEventRecorder(ctx); + + await expect( + handleCommand(ctx, "list_remote_environments"), + ).resolves.toEqual({ + profiles: [profile], + activeEnvironmentId: "local", + activeProfileId: null, + statuses: [], + }); + + const input = { + id: profile.id, + name: "Build box renamed", + host: profile.host, + }; + await expect( + handleCommand(ctx, "upsert_remote_environment", { profile: input }), + ).resolves.toEqual({ profile }); + expect(fake.upsert).toHaveBeenCalledWith(input); + expect(readEvent(send, 0)).toMatchObject({ + event: { name: "remote_environment_profiles_changed" }, + }); + + await expect( + handleCommand(ctx, "test_remote_environment", { id: ` ${profile.id} ` }), + ).resolves.toEqual({ + profile, + status: "passed", + message: "SSH connection succeeded", + remotePlatform: "linux", + remoteArch: "arm64", + }); + expect(fake.test).toHaveBeenCalledWith(profile.id); + }); + + it("connects an authenticated remote runtime, records its binding, and disconnects it cleanly", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService(); + const { manager, unsubscribe } = createManager(); + coreCreateMock.mockResolvedValue(manager); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + const send = attachEventRecorder(ctx); + + await expect( + handleCommand(ctx, "connect_remote_environment", { + id: profile.id, + }), + ).resolves.toEqual({ + profile, + status: "connected", + environmentId: profile.id, + activeEnvironmentId: profile.id, + activeProfileId: profile.id, + workspaceRoot: "/home/alice", + homeDir: "/home/alice", + remotePlatform: "linux", + remoteArch: "arm64", + }); + + expect(fake.connect).toHaveBeenCalledWith(profile.id); + expect(coreCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + clientName: "cline-code", + backendMode: "remote", + remote: { + endpoint: connection.endpoint, + authToken: connection.authToken, + workspaceRoot: connection.workspaceRoot, + cwd: connection.workspaceRoot, + clientType: "code-sidecar-ssh", + displayName: "Code App (Build box)", + }, + }), + ); + expect(hubClientConstructorMock).toHaveBeenCalledWith({ + url: connection.endpoint, + authToken: connection.authToken, + clientType: "code-sidecar-ssh-observer", + displayName: "Code App observer (Build box)", + workspaceRoot: connection.workspaceRoot, + cwd: connection.workspaceRoot, + }); + expect(ctx.activeEnvironmentId).toBe(profile.id); + expect(ctx.runtimeBindings.get(profile.id)).toMatchObject({ + environmentId: profile.id, + kind: "ssh", + workspaceRoot: connection.workspaceRoot, + remote: connection, + }); + expect(readEvent(send, 0)).toEqual({ + type: "event", + event: { + name: "remote_environment_changed", + payload: { + profile, + status: "connected", + environmentId: profile.id, + activeEnvironmentId: profile.id, + activeProfileId: profile.id, + workspaceRoot: "/home/alice", + homeDir: "/home/alice", + remotePlatform: "linux", + remoteArch: "arm64", + }, + }, + }); + + await expect( + handleCommand(ctx, "disconnect_remote_environment"), + ).resolves.toEqual({ + status: "disconnected", + disconnectedProfileId: profile.id, + activeEnvironmentId: "local", + activeProfileId: null, + }); + expect(fake.disconnect).toHaveBeenCalledWith(profile.id); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(manager.dispose).toHaveBeenCalledWith( + "code_sidecar_remote_disconnect", + ); + expect(hubDisposeMock).toHaveBeenCalledOnce(); + expect(ctx.runtimeBindings.has(profile.id)).toBe(false); + expect(ctx.activeEnvironmentId).toBe("local"); + expect(readEvent(send, 1)).toEqual({ + type: "event", + event: { + name: "remote_environment_changed", + payload: { + status: "disconnected", + activeProfileId: null, + activeEnvironmentId: "local", + environmentId: "local", + workspaceRoot: "/local/project", + }, + }, + }); + }); + + it("rolls back the SSH tunnel and partial runtime when observer authentication fails", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService(); + const { manager, unsubscribe } = createManager(); + coreCreateMock.mockResolvedValue(manager); + hubConnectMock.mockRejectedValue(new Error("remote auth rejected")); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + const send = attachEventRecorder(ctx); + + await expect( + handleCommand(ctx, "connect_remote_environment", { id: profile.id }), + ).rejects.toThrow("remote auth rejected"); + + expect(fake.disconnect).toHaveBeenCalledWith(profile.id); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(manager.dispose).toHaveBeenCalledWith( + "code_sidecar_remote_initialization_failed", + ); + expect(hubDisposeMock).toHaveBeenCalledOnce(); + expect(ctx.runtimeBindings.has(profile.id)).toBe(false); + expect(ctx.activeEnvironmentId).toBe("local"); + expect(send).not.toHaveBeenCalled(); + }); + + it("preserves the previous environment when switching hosts fails", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService([connection, secondConnection]); + const firstRuntime = createManager(); + const failedRuntime = createManager(); + coreCreateMock + .mockResolvedValueOnce(firstRuntime.manager) + .mockResolvedValueOnce(failedRuntime.manager); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + const send = attachEventRecorder(ctx); + + await handleCommand(ctx, "connect_remote_environment", { id: profile.id }); + const firstBinding = ctx.runtimeBindings.get(profile.id); + send.mockClear(); + hubConnectMock.mockRejectedValueOnce( + new Error("second host auth rejected"), + ); + + await expect( + handleCommand(ctx, "connect_remote_environment", { + id: secondProfile.id, + }), + ).rejects.toThrow("second host auth rejected"); + + expect(ctx.activeEnvironmentId).toBe(profile.id); + expect(ctx.runtimeBindings.get(profile.id)).toBe(firstBinding); + expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(false); + expect(fake.service.getActive()?.profileId).toBe(profile.id); + expect(fake.disconnect).toHaveBeenCalledWith(secondProfile.id); + expect(fake.disconnect).not.toHaveBeenCalledWith(profile.id); + expect(firstRuntime.unsubscribe).not.toHaveBeenCalled(); + expect(firstRuntime.manager.dispose).not.toHaveBeenCalled(); + expect(failedRuntime.unsubscribe).toHaveBeenCalledOnce(); + expect(failedRuntime.manager.dispose).toHaveBeenCalledWith( + "code_sidecar_remote_initialization_failed", + ); + expect(send).not.toHaveBeenCalled(); + + await expect( + handleCommand(ctx, "list_remote_environments"), + ).resolves.toMatchObject({ + activeEnvironmentId: profile.id, + activeProfileId: profile.id, + }); + }); + + it("retires the previous runtime only after a host switch commits", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService([connection, secondConnection]); + const firstRuntime = createManager(); + const secondRuntime = createManager(); + coreCreateMock + .mockResolvedValueOnce(firstRuntime.manager) + .mockResolvedValueOnce(secondRuntime.manager); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + + await handleCommand(ctx, "connect_remote_environment", { id: profile.id }); + await expect( + handleCommand(ctx, "connect_remote_environment", { + id: secondProfile.id, + }), + ).resolves.toMatchObject({ + environmentId: secondProfile.id, + activeEnvironmentId: secondProfile.id, + activeProfileId: secondProfile.id, + }); + + expect(ctx.activeEnvironmentId).toBe(secondProfile.id); + expect(ctx.runtimeBindings.has(profile.id)).toBe(false); + expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true); + expect(firstRuntime.unsubscribe).toHaveBeenCalledOnce(); + expect(firstRuntime.manager.dispose).toHaveBeenCalledWith( + "code_sidecar_remote_disconnect", + ); + expect(secondRuntime.manager.dispose).not.toHaveBeenCalled(); + expect(fake.disconnect).toHaveBeenCalledWith(profile.id); + expect(fake.service.getActive()?.profileId).toBe(secondProfile.id); + }); + + it("disconnecting an inactive profile does not switch the active environment", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService([connection, secondConnection]); + await fake.service.connect(profile.id); + await fake.service.connect(secondProfile.id); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + ctx.runtimeBindings.set( + profile.id, + createExistingRemoteBinding(profile.id), + ); + ctx.runtimeBindings.set( + secondProfile.id, + createExistingRemoteBinding(secondProfile.id), + ); + ctx.activeEnvironmentId = secondProfile.id; + const send = attachEventRecorder(ctx); + + await expect( + handleCommand(ctx, "disconnect_remote_environment", { id: profile.id }), + ).resolves.toEqual({ + status: "disconnected", + disconnectedProfileId: profile.id, + activeEnvironmentId: secondProfile.id, + activeProfileId: secondProfile.id, + }); + + expect(ctx.activeEnvironmentId).toBe(secondProfile.id); + expect(ctx.runtimeBindings.has(secondProfile.id)).toBe(true); + expect(fake.service.getActive()?.profileId).toBe(secondProfile.id); + expect(send).not.toHaveBeenCalled(); + }); + + it("deletes a profile only after removing its runtime binding", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService(); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + const send = attachEventRecorder(ctx); + const binding = createExistingRemoteBinding(profile.id); + ctx.runtimeBindings.set(profile.id, binding); + ctx.activeEnvironmentId = profile.id; + + await expect( + handleCommand(ctx, "delete_remote_environment", { id: profile.id }), + ).resolves.toEqual({ + deleted: true, + activeEnvironmentId: "local", + activeProfileId: null, + }); + + expect(binding.unsubscribeSessionEvents).toHaveBeenCalledOnce(); + expect(binding.sessionManager.dispose).toHaveBeenCalledWith( + "code_sidecar_remote_disconnect", + ); + expect(binding.hubClient.dispose).toHaveBeenCalledOnce(); + expect(fake.delete).toHaveBeenCalledWith(profile.id); + expect( + send.mock.calls.map((call) => JSON.parse(String(call[0])).event.name), + ).toContain("remote_environment_profiles_changed"); + expect(ctx.runtimeBindings.has(profile.id)).toBe(false); + expect(ctx.activeEnvironmentId).toBe("local"); + expect(readEvent(send, 0)).toEqual({ + type: "event", + event: { + name: "remote_environment_changed", + payload: { + status: "disconnected", + activeProfileId: null, + activeEnvironmentId: "local", + environmentId: "local", + workspaceRoot: "/local/project", + reason: "profile_deleted", + }, + }, + }); + }); + + it("routes remote workspace browsing and operations to the explicitly selected directory", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const fake = createFakeService(); + const ctx = createSidecarContext("/local/project"); + ctx.remoteEnvironments = fake.service; + ctx.runtimeBindings.set( + profile.id, + createExistingRemoteBinding(profile.id), + ); + expect(ctx.activeEnvironmentId).toBe("local"); + + fake.run.mockImplementation(async (_id, input) => { + if (input.command === "pwd") { + return { stdout: "/srv/code\n", stderr: "", exitCode: 0 }; + } + if (input.command === "sh" && !input.args[1]?.includes("ls-files")) { + return { + stdout: "/srv/code/zeta\0/srv/code/project\0", + stderr: "", + exitCode: 0, + }; + } + if (input.command === "sh" && input.args[1]?.includes("ls-files")) { + return { + stdout: "src/remote.ts\nREADME.md\n", + stderr: "", + exitCode: 0, + }; + } + if (input.command === "git" && input.args[0] === "branch") { + return { stdout: "feature/ssh\n", stderr: "", exitCode: 0 }; + } + return { stdout: "main\nfeature/ssh\n", stderr: "", exitCode: 0 }; + }); + + await expect( + handleCommand(ctx, "list_workspace_directories", { + environmentId: profile.id, + path: "/srv/code", + }), + ).resolves.toEqual({ + environmentId: profile.id, + currentPath: "/srv/code", + parentPath: "/srv", + entries: [ + { name: "project", path: "/srv/code/project" }, + { name: "zeta", path: "/srv/code/zeta" }, + ], + truncated: false, + }); + expect(fake.run).toHaveBeenCalledWith(profile.id, { + command: "pwd", + args: ["-P"], + cwd: "/srv/code", + }); + const listInvocation = fake.run.mock.calls.find( + ([, input]) => input.command === "sh", + )?.[1]; + expect(listInvocation).toMatchObject({ + command: "sh", + args: [ + "-c", + expect.stringContaining("find -L"), + "cline-list-workspace-directories", + "/srv/code", + ], + }); + expect(String(listInvocation?.args[1])).not.toContain("/srv/code"); + + await expect( + handleCommand(ctx, "validate_workspace_directory", { + environmentId: profile.id, + path: "/srv/code/project", + }), + ).resolves.toEqual({ environmentId: profile.id, valid: true }); + expect(fake.run).toHaveBeenCalledWith(profile.id, { + command: "test", + args: ["-d", "/srv/code/project"], + }); + + await expect( + handleCommand(ctx, "search_workspace_files", { + environmentId: profile.id, + workspaceRoot: "/srv/code/project", + query: "remote", + }), + ).resolves.toEqual(["src/remote.ts"]); + expect(fake.run).toHaveBeenCalledWith(profile.id, { + command: "sh", + args: ["-c", expect.stringContaining("head -c 262144")], + cwd: "/srv/code/project", + }); + + await expect( + handleCommand(ctx, "get_git_branch", { + environmentId: profile.id, + cwd: "/srv/code/project", + }), + ).resolves.toEqual({ + environmentId: profile.id, + branch: "feature/ssh", + }); + expect(fake.run).toHaveBeenCalledWith(profile.id, { + command: "git", + args: ["branch", "--show-current"], + cwd: "/srv/code/project", + }); + }); + + it("bounds remote search output before transfer and drops a truncated filename", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const root = mkdtempSync(join(tmpdir(), "remote-search-limit-")); + try { + for (let i = 0; i < 2800; i++) + writeFileSync(join(root, `${i}-${"x".repeat(100)}.ts`), ""); + const ctx = createSidecarContext(root); + const fake = createFakeService(); + ctx.remoteEnvironments = fake.service; + ctx.runtimeBindings.set( + profile.id, + createExistingRemoteBinding(profile.id), + ); + let transferredBytes = 0; + fake.run.mockImplementation(async (_id, input) => { + const stdout = execFileSync(input.command, input.args, { + cwd: input.cwd, + encoding: "utf8", + }); + transferredBytes = Buffer.byteLength(stdout); + return { stdout, stderr: "", exitCode: 0 }; + }); + const result = (await handleCommand(ctx, "search_workspace_files", { + environmentId: profile.id, + workspaceRoot: root, + limit: 200, + })) as string[]; + expect(transferredBytes).toBe(262144); + expect(result.length).toBeGreaterThan(0); + expect(result.length).toBeLessThanOrEqual(200); + expect(result.every((path) => path.endsWith(".ts"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("lists and bounds local workspace directories through the local binding", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-workspaces-")); + try { + for (let index = 0; index < 201; index += 1) { + mkdirSync( + join(temporaryRoot, `project-${String(index).padStart(3, "0")}`), + ); + } + writeFileSync(join(temporaryRoot, "not-a-directory.txt"), "ignored"); + const ctx = createSidecarContext("/local/project"); + ctx.runtimeBindings.set("local", { + ...createExistingRemoteBinding("local"), + kind: "local", + workspaceRoot: "/local/project", + }); + + const currentPath = realpathSync(temporaryRoot); + await expect( + handleCommand(ctx, "list_workspace_directories", { + environmentId: "local", + path: temporaryRoot, + }), + ).resolves.toEqual({ + environmentId: "local", + currentPath, + parentPath: realpathSync(tmpdir()), + entries: expect.arrayContaining([ + { + name: "project-000", + path: join(currentPath, "project-000"), + }, + ]), + truncated: true, + }); + const result = (await handleCommand(ctx, "list_workspace_directories", { + environmentId: "local", + path: temporaryRoot, + })) as { entries: unknown[] }; + expect(result.entries).toHaveLength(200); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + + it("lists duplicate session IDs separately and rejects ambiguous routing", async () => { + const { handleCommand } = await import("./commands"); + const { + createSidecarContext, + getEnvironmentContext, + findSessionRuntimeBinding, + emitChunk, + } = await import("./context"); + const ctx = createSidecarContext("/local/project"); + for (const environmentId of [profile.id, secondProfile.id]) { + const record = { + id: "same-id", + sessionId: "same-id", + status: "idle", + createdAt: "2026-09-14T00:00:00Z", + }; + ctx.runtimeBindings.set(environmentId, { + ...createExistingRemoteBinding(environmentId), + sessionManager: { + list: vi.fn(async () => [record]), + get: vi.fn(async () => record), + } as unknown as SessionRuntimeBinding["sessionManager"], + }); + } + const sessions = (await handleCommand( + ctx, + "list_discovered_sessions", + {}, + )) as Array<{ sessionId: string; environmentId: string }>; + expect( + sessions + .filter((session) => session.sessionId === "same-id") + .map((session) => session.environmentId) + .sort(), + ).toEqual([profile.id, secondProfile.id]); + await expect(findSessionRuntimeBinding(ctx, "same-id")).rejects.toThrow( + "environmentId is required", + ); + await expect( + findSessionRuntimeBinding(ctx, "same-id", secondProfile.id), + ).resolves.toMatchObject({ environmentId: secondProfile.id }); + const first = getEnvironmentContext(ctx, profile.id); + const second = getEnvironmentContext(ctx, secondProfile.id); + const send = attachEventRecorder(ctx); + emitChunk(first, "same-id", "chat_text", "first host"); + emitChunk(second, "same-id", "chat_text", "second host"); + expect(readEvent(send, 0).event.payload).toMatchObject({ + sessionId: "same-id", + environmentId: profile.id, + index: 1, + chunk: "first host", + }); + expect(readEvent(send, 1).event.payload).toMatchObject({ + sessionId: "same-id", + environmentId: secondProfile.id, + index: 1, + chunk: "second host", + }); + }); + + it("routes session reads, title updates, and deletes to the requested environment", async () => { + const { handleCommand } = await import("./commands"); + const { createSidecarContext } = await import("./context"); + const ctx = createSidecarContext("/local/project"); + const readMessages = vi.fn(async () => [ + { role: "user", content: "remote session message" }, + ]); + const update = vi.fn(async () => ({ updated: true })); + const deleteSession = vi.fn(async () => true); + const sessionManager = { + readMessages, + update, + delete: deleteSession, + dispose: vi.fn(async () => undefined), + } as unknown as SessionRuntimeBinding["sessionManager"]; + ctx.runtimeBindings.set(profile.id, { + ...createExistingRemoteBinding(profile.id), + sessionManager, + }); + expect(ctx.activeEnvironmentId).toBe("local"); + + await expect( + handleCommand(ctx, "read_session_messages", { + environmentId: profile.id, + sessionId: "remote-session", + }), + ).resolves.toHaveLength(1); + expect(readMessages).toHaveBeenCalledWith("remote-session"); + + ctx.liveSessions.set("same-id", { + environmentId: "local", + config: {}, + messages: [{ role: "user", content: "local-only message" }], + promptsInQueue: [], + busy: false, + startedAt: Date.now(), + status: "idle", + }); + readMessages.mockResolvedValueOnce([]); + await expect( + handleCommand(ctx, "read_session_messages", { + environmentId: profile.id, + sessionId: "same-id", + }), + ).resolves.toEqual([]); + + await expect( + handleCommand(ctx, "update_chat_session_title", { + environmentId: profile.id, + sessionId: "remote-session", + title: "Remote title", + }), + ).resolves.toBe(true); + expect(update).toHaveBeenCalledWith("remote-session", { + title: "Remote title", + }); + + await expect( + handleCommand(ctx, "delete_chat_session", { + environmentId: profile.id, + sessionId: "remote-session", + }), + ).resolves.toBe(true); + expect(deleteSession).toHaveBeenCalledWith("remote-session"); + expect(sessionStoreDeleteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/examples/desktop-app/sidecar/remote-helper.test.ts b/apps/examples/desktop-app/sidecar/remote-helper.test.ts new file mode 100644 index 0000000000..b5ad0fcc8e --- /dev/null +++ b/apps/examples/desktop-app/sidecar/remote-helper.test.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { remoteHelperBinaryFilename } from "@cline/core"; +import { expect, it } from "vitest"; +import { resolveDesktopRemoteHelper } from "./remote-helper"; + +it("finds SSH helpers in the installed Windows resource layout", () => { + const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-")); + try { + const target = { platform: "linux", arch: "x64" } as const; + const directory = join(root, "bin", "remote-helpers"); + mkdirSync(directory, { recursive: true }); + const helper = join(directory, remoteHelperBinaryFilename(target)); + writeFileSync(helper, "helper"); + expect( + resolveDesktopRemoteHelper(target, { + execPath: join(root, "code-sidecar.exe"), + cwd: tmpdir(), + env: {}, + }), + ).toBe(helper); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +it("finds SSH helpers in the installed Linux resource layout", () => { + const root = mkdtempSync(join(tmpdir(), "cline-packaged-helpers-")); + try { + const target = { platform: "linux", arch: "arm64" } as const; + const directory = join( + root, + "usr", + "lib", + "Cline Beta", + "bin", + "remote-helpers", + ); + mkdirSync(directory, { recursive: true }); + const helper = join(directory, remoteHelperBinaryFilename(target)); + writeFileSync(helper, "helper"); + expect( + resolveDesktopRemoteHelper(target, { + execPath: join(root, "usr", "bin", "code-sidecar"), + cwd: tmpdir(), + env: {}, + }), + ).toBe(helper); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/apps/examples/desktop-app/sidecar/remote-helper.ts b/apps/examples/desktop-app/sidecar/remote-helper.ts new file mode 100644 index 0000000000..5e9e2b43a6 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/remote-helper.ts @@ -0,0 +1,46 @@ +import { existsSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + type RemoteHelperTarget, + remoteHelperBinaryFilename, +} from "@cline/core"; + +// Tauri's Linux bundles (deb, rpm, AppImage) install binaries under `usr/bin` +// and resources under `usr/lib/`. The product name differs per +// release channel ("Cline", "Cline Beta"), so scan the sibling lib directory. +function linuxResourceCandidates( + executableDirectory: string, + relativePath: string, +): string[] { + const libDirectory = join(executableDirectory, "..", "lib"); + try { + return readdirSync(libDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(libDirectory, entry.name, relativePath)); + } catch { + return []; + } +} + +export function resolveDesktopRemoteHelper( + target: RemoteHelperTarget, + options: { execPath?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): string | undefined { + const env = options.env ?? process.env; + if (env.CLINE_REMOTE_HELPER_BINARY) return env.CLINE_REMOTE_HELPER_BINARY; + const filename = remoteHelperBinaryFilename(target); + const executableDirectory = dirname(options.execPath ?? process.execPath); + const cwd = options.cwd ?? process.cwd(); + const bundledPath = join("bin", "remote-helpers", filename); + return [ + ...(env.CLINE_REMOTE_HELPER_DIRECTORY + ? [join(env.CLINE_REMOTE_HELPER_DIRECTORY, filename)] + : []), + join(executableDirectory, "remote-helpers", filename), + join(executableDirectory, bundledPath), + join(executableDirectory, "..", "Resources", bundledPath), + ...linuxResourceCandidates(executableDirectory, bundledPath), + join(cwd, "src-tauri", bundledPath), + join(cwd, "apps", "examples", "desktop-app", "src-tauri", bundledPath), + ].find(existsSync); +} diff --git a/apps/examples/desktop-app/sidecar/remote-session-credentials.test.ts b/apps/examples/desktop-app/sidecar/remote-session-credentials.test.ts new file mode 100644 index 0000000000..ae105f42b1 --- /dev/null +++ b/apps/examples/desktop-app/sidecar/remote-session-credentials.test.ts @@ -0,0 +1,266 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleChatSessionCommand } from "./chat-session"; +import { createSidecarContext, getEnvironmentContext } from "./context"; +import type { SessionRuntimeBinding } from "./types"; + +const mocks = vi.hoisted(() => ({ + settings: new Map>(), + refresh: vi.fn(), +})); +vi.mock("@cline/core", async () => { + const actual = + await vi.importActual("@cline/core"); + return { + ...actual, + ProviderSettingsManager: class { + getProviderSettings(id: string) { + return mocks.settings.get(id); + } + }, + RuntimeOAuthTokenManager: class { + resolveProviderApiKey(input: unknown) { + return mocks.refresh(input); + } + }, + }; +}); + +beforeEach(() => { + mocks.settings.clear(); + mocks.settings.set("cline", { + provider: "cline", + model: "model-a", + auth: { accessToken: "expired", refreshToken: "private-refresh" }, + }); + mocks.settings.set("openai", { + provider: "openai", + model: "model-b", + apiKey: "openai-key", + baseUrl: "https://openai.example/v1", + }); + mocks.refresh + .mockReset() + .mockImplementation(async ({ providerId }: { providerId: string }) => { + if (providerId !== "cline") return null; + return { apiKey: "fresh-token" }; + }); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function runtime() { + const ctx = createSidecarContext("/local/workspace"); + const manager = { + start: vi.fn(async (input: { config?: { sessionId?: string } }) => ({ + sessionId: input.config?.sessionId ?? "remote-session", + manifest: { + cwd: "/remote/workspace", + workspace_root: "/remote/workspace", + }, + })), + get: vi.fn(async () => ({ + sessionId: "shared-id", + provider: "cline", + model: "model-a", + cwd: "/remote/workspace", + metadata: { title: "Remote conversation" }, + })), + updateSessionConnection: vi.fn(async () => undefined), + send: vi.fn(async () => ({ text: "done", messages: [] })), + readMessages: vi.fn(async () => [ + { role: "user", content: "remote message" }, + ]), + readSessionCompactionState: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + }; + ctx.runtimeBindings.set("remote", { + environmentId: "remote", + kind: "ssh", + workspaceRoot: "/remote/workspace", + sessionManager: manager, + hubClient: { command: vi.fn(async () => undefined) }, + } as unknown as SessionRuntimeBinding); + const config = { + environmentId: "remote", + provider: "cline", + model: "model-a", + cwd: "/remote/workspace", + }; + return { ctx, manager, config }; +} + +describe("SSH session credentials and history", () => { + it("seeds reopened SSH sessions before sending a follow-up", async () => { + const { ctx, manager, config } = runtime(); + const history = [ + { role: "user", content: "Remember my project" }, + { role: "assistant", content: "I remember" }, + ]; + manager.readMessages.mockResolvedValueOnce(history); + await handleChatSessionCommand(ctx, { + action: "start", + config: { ...config, sessionId: "shared-id" }, + }); + expect(manager.start).toHaveBeenCalledWith( + expect.objectContaining({ + initialMessages: history, + config: expect.objectContaining({ sessionId: "shared-id" }), + }), + ); + expect( + getEnvironmentContext(ctx, "remote").liveSessions.get("shared-id") + ?.messages, + ).toEqual(history); + manager.send.mockResolvedValueOnce({ + text: "done", + messages: [...history, { role: "user", content: "Continue" }], + } as never); + await handleChatSessionCommand(ctx, { + action: "send", + sessionId: "shared-id", + prompt: "Continue", + config, + }); + await vi.waitFor(() => + expect( + getEnvironmentContext(ctx, "remote").liveSessions.get("shared-id") + ?.messages, + ).toEqual([...history, { role: "user", content: "Continue" }]), + ); + expect(manager.start).toHaveBeenCalledTimes(1); + }); + + it("does not start an empty replacement when remote history cannot be read", async () => { + const { ctx, manager, config } = runtime(); + manager.readMessages.mockRejectedValueOnce( + new Error("History unavailable"), + ); + await expect( + handleChatSessionCommand(ctx, { + action: "start", + config: { ...config, sessionId: "shared-id" }, + }), + ).rejects.toThrow("History unavailable"); + expect(manager.start).not.toHaveBeenCalled(); + }); + + it("uses refreshed tokens in both configs and refreshes again for the next send", async () => { + const { ctx, manager, config } = runtime(); + await handleChatSessionCommand(ctx, { action: "start", config }); + expect(manager.start).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + apiKey: "fresh-token", + providerConfig: expect.objectContaining({ + apiKey: "fresh-token", + accessToken: "fresh-token", + }), + }), + }), + ); + expect(JSON.stringify(manager.start.mock.calls)).not.toContain( + "private-refresh", + ); + expect( + getEnvironmentContext(ctx, "remote").liveSessions.get("remote-session") + ?.config.apiKey, + ).toBeUndefined(); + mocks.refresh.mockResolvedValueOnce({ apiKey: "newer-token" }); + await handleChatSessionCommand(ctx, { + action: "send", + sessionId: "remote-session", + prompt: "hello", + config, + }); + expect(manager.updateSessionConnection).toHaveBeenLastCalledWith( + "remote-session", + expect.objectContaining({ + apiKey: "newer-token", + providerConfig: expect.objectContaining({ + apiKey: "newer-token", + accessToken: "newer-token", + }), + }), + ); + }); + + it("drops the previous provider's credentials, headers, and endpoint on switch", async () => { + const { ctx, manager, config } = runtime(); + await handleChatSessionCommand(ctx, { + action: "start", + config: { + ...config, + apiKey: "old-provider-key", + baseUrl: "https://old-provider.example", + headers: { Authorization: "old-secret" }, + providerConfig: { providerId: "cline", apiKey: "old-provider-key" }, + }, + }); + await handleChatSessionCommand(ctx, { + action: "send", + sessionId: "remote-session", + prompt: "hello", + config: { environmentId: "remote", provider: "openai", model: "model-b" }, + }); + expect(manager.start).toHaveBeenLastCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + providerId: "openai", + apiKey: "openai-key", + baseUrl: "https://openai.example/v1", + }), + }), + ); + expect(JSON.stringify(manager.start.mock.calls.at(-1))).not.toContain( + "old-provider", + ); + expect(JSON.stringify(manager.start.mock.calls.at(-1))).not.toContain( + "old-secret", + ); + }); + + it("never reads a same-ID local transcript when starting or forking remotely", async () => { + const directory = mkdtempSync(join(tmpdir(), "cline-remote-fork-")); + vi.stubEnv("CLINE_SESSION_DATA_DIR", directory); + try { + mkdirSync(join(directory, "shared-id")); + writeFileSync( + join(directory, "shared-id", "shared-id.messages.json"), + JSON.stringify([{ role: "user", content: "private local message" }]), + ); + const { ctx, manager, config } = runtime(); + await handleChatSessionCommand(ctx, { + action: "start", + config: { ...config, sessionId: "shared-id" }, + }); + expect(manager.readMessages).toHaveBeenCalledWith("shared-id"); + expect(manager.start).toHaveBeenCalledWith( + expect.objectContaining({ + initialMessages: [{ role: "user", content: "remote message" }], + }), + ); + expect(JSON.stringify(manager.start.mock.calls)).not.toContain( + "private local message", + ); + await handleChatSessionCommand(ctx, { + action: "fork", + sessionId: "shared-id", + config, + }); + expect(manager.start).toHaveBeenLastCalledWith( + expect.objectContaining({ + initialMessages: [{ role: "user", content: "remote message" }], + }), + ); + expect(JSON.stringify(manager.start.mock.calls)).not.toContain( + "private local message", + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/examples/desktop-app/sidecar/restore-checkpoint.test.ts b/apps/examples/desktop-app/sidecar/restore-checkpoint.test.ts index d6a9eba898..68136108a2 100644 --- a/apps/examples/desktop-app/sidecar/restore-checkpoint.test.ts +++ b/apps/examples/desktop-app/sidecar/restore-checkpoint.test.ts @@ -60,21 +60,33 @@ describe("restore_checkpoint", () => { pendingQuestions: new Map(), streamIndices: new Map(), wsClients: new Set(), - sessionManager: { - get: vi.fn(async () => ({ - sessionId, - status: "idle", - cwd: "/tmp/project", - workspaceRoot: "/tmp/project", - })), - // A restore that reuses the source id is what the hub does today. - restore: vi.fn(async () => ({ - sessionId, - messages: restoredMessages, - checkpoint: { ref: "first", createdAt: 1, runCount: 1 }, - })), - pendingPrompts: { list: vi.fn(async () => []) }, - }, + activeEnvironmentId: "local", + sessionEnvironmentIds: new Map(), + runtimeBindings: new Map([ + [ + "local", + { + environmentId: "local", + kind: "local", + workspaceRoot: "/tmp/project", + sessionManager: { + get: vi.fn(async () => ({ + sessionId, + status: "idle", + cwd: "/tmp/project", + workspaceRoot: "/tmp/project", + })), + // A restore that reuses the source id is what the hub does today. + restore: vi.fn(async () => ({ + sessionId, + messages: restoredMessages, + checkpoint: { ref: "first", createdAt: 1, runCount: 1 }, + })), + pendingPrompts: { list: vi.fn(async () => []) }, + }, + }, + ], + ]), } as unknown as SidecarContext; await handleChatSessionCommand(ctx, { diff --git a/apps/examples/desktop-app/sidecar/session-data/messages.ts b/apps/examples/desktop-app/sidecar/session-data/messages.ts index 06efe6bfce..5f2daabc93 100644 --- a/apps/examples/desktop-app/sidecar/session-data/messages.ts +++ b/apps/examples/desktop-app/sidecar/session-data/messages.ts @@ -333,17 +333,22 @@ export async function readSessionMessages( ctx: Pick, sessionId: string, maxMessages = 800, + remoteMessages?: MessageWithMetadata[], ): Promise { - const persisted = - readPersistedChatMessages(sessionId) ?? - // A child agent's transcript is not stored under its own session - // directory — it lives beside the root session's artifacts — so opening a - // subagent session has to resolve the path recorded on its row. - readChildSessionMessages(sessionId); + const isRemoteRead = remoteMessages !== undefined; + const persisted = isRemoteRead + ? remoteMessages + : (readPersistedChatMessages(sessionId) ?? + // A child agent's transcript is not stored under its own session + // directory — it lives beside the root session's artifacts — so opening a + // subagent session has to resolve the path recorded on its row. + readChildSessionMessages(sessionId)); const messages = persisted && persisted.length > 0 ? persisted - : (ctx.liveSessions.get(sessionId)?.messages ?? []); + : isRemoteRead + ? [] + : (ctx.liveSessions.get(sessionId)?.messages ?? []); const max = Math.max(1, maxMessages); const start = Math.max(0, messages.length - max); const displayMessages = projectSessionMessagesForDisplay( @@ -354,7 +359,11 @@ export async function readSessionMessages( })); const baseTs = nowMs() - messages.length; const out: JsonRecord[] = []; - const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId); + // Remote artifacts belong to the SSH host. Never decorate them with a + // same-id local session's live transcript or checkpoint metadata. + const checkpointsByRunCount = isRemoteRead + ? new Map() + : readCheckpointEntriesByRunCount(sessionId); const pendingToolMessages = new Map(); let userRunCount = 0; for (let idx = 0; idx < start; idx += 1) { diff --git a/apps/examples/desktop-app/sidecar/session-data/search.ts b/apps/examples/desktop-app/sidecar/session-data/search.ts index 076e7304d8..776fe2139a 100644 --- a/apps/examples/desktop-app/sidecar/session-data/search.ts +++ b/apps/examples/desktop-app/sidecar/session-data/search.ts @@ -2,13 +2,13 @@ import { getFileIndex } from "@cline/core"; import type { SidecarContext } from "../types"; export function searchWorkspaceFiles( - ctx: Pick, + ctx: Pick, args?: Record, ): Promise { const root = typeof args?.workspaceRoot === "string" && args.workspaceRoot.trim() ? args.workspaceRoot.trim() - : ctx.workspaceRoot; + : ctx.localWorkspaceRoot; const query = typeof args?.query === "string" ? args.query.trim().toLowerCase() : ""; const limit = diff --git a/apps/examples/desktop-app/sidecar/shell-path.test.ts b/apps/examples/desktop-app/sidecar/shell-path.test.ts deleted file mode 100644 index 31b2317807..0000000000 --- a/apps/examples/desktop-app/sidecar/shell-path.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - defaultShellFor, - ensureLoginShellPath, - extractMarkedPath, - loginShellFor, - mergePaths, - resolveLoginShellPath, - shellInvocation, -} from "./shell-path"; - -const MARKER_START = "__CLINE_SIDECAR_PATH_START__"; -const MARKER_END = "__CLINE_SIDECAR_PATH_END__"; - -let tempDirs: string[] = []; - -/** - * Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c `, - * so the command to run arrives as $4. The default body mimics a login shell - * whose profile prepends Homebrew before running the command. - */ -function writeFakeShell( - script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"', - name = "fake-shell", -): string { - const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-")); - tempDirs.push(dir); - const shellPath = join(dir, name); - writeFileSync(shellPath, `#!/bin/sh\n${script}\n`); - chmodSync(shellPath, 0o755); - return shellPath; -} - -afterEach(() => { - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - tempDirs = []; -}); - -describe("extractMarkedPath", () => { - it("extracts the PATH between markers", () => { - expect( - extractMarkedPath( - `${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`, - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell profile noise around the markers", () => { - const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`; - expect(extractMarkedPath(output)).toBe("/usr/local/bin"); - }); - - it("returns undefined when markers are missing or empty", () => { - expect(extractMarkedPath("no markers here")).toBeUndefined(); - expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined(); - expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined(); - }); -}); - -describe("mergePaths", () => { - it("puts shell entries first and keeps current-only entries", () => { - expect( - mergePaths( - "/opt/homebrew/bin:/usr/bin:/bin", - "/usr/bin:/bin:/custom/bin", - ), - ).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin"); - }); - - it("drops duplicate and empty entries", () => { - expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c"); - }); -}); - -describe("defaultShellFor", () => { - it("uses zsh on macOS and bash elsewhere", () => { - expect(defaultShellFor("darwin")).toBe("/bin/zsh"); - expect(defaultShellFor("linux")).toBe("/bin/bash"); - }); -}); - -describe("loginShellFor", () => { - it("returns the passwd-database shell when one exists", () => { - // The test runner's uid has a passwd entry, so $SHELL must lose. - const shell = loginShellFor(process.platform, { - SHELL: "/env/should-not-win", - }); - expect(shell.startsWith("/")).toBe(true); - expect(shell).not.toBe("/env/should-not-win"); - }); -}); - -describe("shellInvocation", () => { - it("uses separate login+interactive flags for posix-style shells", () => { - expect(shellInvocation("/bin/zsh", "cmd")).toEqual({ - args: ["-i", "-l", "-c", "cmd"], - }); - expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({ - args: ["-i", "-l", "-c", "cmd"], - }); - }); - - it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => { - expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({ - args: ["-c", "cmd"], - argv0: "-tcsh", - }); - expect(shellInvocation("/bin/csh", "cmd")).toEqual({ - args: ["-c", "cmd"], - argv0: "-csh", - }); - }); -}); - -describe("resolveLoginShellPath", () => { - it("captures PATH from the shell", async () => { - const shell = writeFakeShell(); - await expect(resolveLoginShellPath(shell)).resolves.toBe( - "/opt/homebrew/bin:/usr/bin", - ); - }); - - it("reads PATH from the environment, not the shell's own expansion", async () => { - // Mimics fish: its "$PATH" expansion would space-join the entries, - // but the printf runs inside /bin/sh, which reads the exported - // colon-delimited PATH env var — so the shell's expansion rules - // never apply. This fake shell never evals the command text; it - // only exports PATH and runs the command via sh, like fish would. - const shell = writeFakeShell( - 'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"', - ); - await expect(resolveLoginShellPath(shell)).resolves.toBe( - "/opt/homebrew/bin:/usr/bin", - ); - }); - - it("resolves undefined when the shell prints garbage", async () => { - const shell = writeFakeShell('echo "no markers"'); - await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined(); - }); - - it("resolves undefined when the shell is missing", async () => { - await expect( - resolveLoginShellPath("/nonexistent/shell"), - ).resolves.toBeUndefined(); - }); - - it("times out hung shells without rejecting", async () => { - const shell = writeFakeShell("sleep 60"); - await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined(); - }); - - it("invokes csh-family shells without login/interactive flags", async () => { - // A csh stand-in that rejects any first flag other than -c. - const shell = writeFakeShell( - '[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"', - "tcsh", - ); - await expect(resolveLoginShellPath(shell)).resolves.toBe( - "/opt/homebrew/bin:/usr/bin", - ); - }); -}); - -describe("ensureLoginShellPath", () => { - it("merges the login shell PATH into env.PATH", async () => { - const shell = writeFakeShell(); - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" }; - const result = await ensureLoginShellPath({ - platform: "darwin", - env, - userShell: shell, - }); - expect(result).toEqual({ - status: "applied", - pathEntries: 3, - shell, - }); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin"); - }); - - it("falls back to the default shell when $SHELL can't resolve", async () => { - const fallbackShell = writeFakeShell(); - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; - const result = await ensureLoginShellPath({ - platform: "darwin", - env, - userShell: "/nonexistent/shell", - fallbackShell, - }); - expect(result.status).toBe("applied"); - expect(result).toMatchObject({ shell: fallbackShell }); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("leaves PATH untouched when every shell fails", async () => { - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; - const result = await ensureLoginShellPath({ - platform: "darwin", - env, - userShell: "/nonexistent/shell", - fallbackShell: "/nonexistent/other-shell", - }); - expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" }); - expect(env.PATH).toBe("/usr/bin"); - }); - - it("skips on windows", async () => { - const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" }; - const result = await ensureLoginShellPath({ platform: "win32", env }); - expect(result).toEqual({ status: "skipped", reason: "windows" }); - }); - - it("skips when the escape hatch is set", async () => { - const env: NodeJS.ProcessEnv = { - PATH: "/usr/bin", - CLINE_SIDECAR_SKIP_SHELL_PATH: "1", - }; - const result = await ensureLoginShellPath({ platform: "darwin", env }); - expect(result.status).toBe("skipped"); - expect(env.PATH).toBe("/usr/bin"); - }); - - it("never exposes the resolved PATH in its result", async () => { - const shell = writeFakeShell(); - const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" }; - const result = await ensureLoginShellPath({ - platform: "darwin", - env, - userShell: shell, - }); - expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin"); - }); - - it("resolves against a real shell end to end", async () => { - const env: NodeJS.ProcessEnv = { PATH: "/bin" }; - const result = await ensureLoginShellPath({ - platform: "linux", - env, - userShell: "/bin/sh", - }); - expect(result.status).toBe("applied"); - expect(env.PATH).toContain("/bin"); - }); -}); diff --git a/apps/examples/desktop-app/sidecar/shell-path.ts b/apps/examples/desktop-app/sidecar/shell-path.ts deleted file mode 100644 index 01337a82cd..0000000000 --- a/apps/examples/desktop-app/sidecar/shell-path.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Login-shell PATH resolution for the desktop sidecar. - * - * When the Tauri app is launched from Finder/the Dock on macOS, it inherits - * launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the - * user's shell PATH. The sidecar — and every process it spawns for the agent - * (bash tool, MCP servers) — then can't find tools like `gh` that live in - * /opt/homebrew/bin or other shell-profile-added directories, even though - * the same task works from the CLI in a terminal. - * - * At startup we ask the user's login shell for its PATH and merge it into - * process.env.PATH, so child processes see the same PATH a terminal would. - */ - -import { spawn } from "node:child_process"; -import { userInfo } from "node:os"; -import { basename, delimiter } from "node:path"; - -const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__"; -const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__"; - -/** - * Kept well under the Tauri shell's 5s endpoint-readiness poll: this - * resolution overlaps sidecar startup but is awaited before the server - * starts, so a pathological shell profile must not eat the whole window. - */ -const SHELL_TIMEOUT_MS = 2_000; - -/** - * The command every shell is asked to run. $PATH expansion happens inside - * POSIX sh — not the user's shell — so shells with different expansion rules - * (fish would space-join "$PATH") still produce a colon-delimited value; sh - * reads the PATH environment variable the login shell exported. - */ -const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`; - -/** - * Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched - * (e.g. if a broken shell profile makes resolution misbehave). - */ -const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH"; - -export function defaultShellFor(platform: NodeJS.Platform): string { - return platform === "darwin" ? "/bin/zsh" : "/bin/bash"; -} - -/** - * The user's configured login shell. The account database is authoritative: - * a GUI-launched process has no parent shell, so $SHELL may be unset there. - * userInfo() reads getpwuid(), which on macOS goes through DirectoryServices - * — the same source `dscl . -read /Users/$USER UserShell` reports — and on - * Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform - * default are fallbacks for environments with no passwd entry. - */ -export function loginShellFor( - platform: NodeJS.Platform, - env: NodeJS.ProcessEnv, -): string { - try { - const shell = userInfo().shell?.trim(); - if (shell) { - return shell; - } - } catch { - // No passwd entry for the current uid (some containers) — fall through. - } - return env.SHELL?.trim() || defaultShellFor(platform); -} - -export interface ShellInvocation { - args: string[]; - /** - * argv[0] the shell should see. A leading dash is the historical "you - * are a login shell" signal, used where -l can't be passed as a flag. - */ - argv0?: string; -} - -/** - * How to invoke a shell so it sources its profiles and runs a command. - * csh/tcsh accept -l only as the sole flag, so they're marked login via the - * argv[0] dash convention instead (sources ~/.login on top of the always-read - * ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile — - * Homebrew's shellenv) plus interactive (-i, ~/.zshrc — nvm-style version - * managers) as separate flags. - */ -export function shellInvocation( - shell: string, - command: string, -): ShellInvocation { - const kind = basename(shell); - if (kind === "csh" || kind === "tcsh") { - return { args: ["-c", command], argv0: `-${kind}` }; - } - return { args: ["-i", "-l", "-c", command] }; -} - -/** - * Extract the PATH value printed between the sentinel markers, ignoring any - * noise a shell profile writes to stdout around it. - */ -export function extractMarkedPath(output: string): string | undefined { - const start = output.indexOf(PATH_MARKER_START); - if (start === -1) { - return undefined; - } - const end = output.indexOf(PATH_MARKER_END, start); - if (end === -1) { - return undefined; - } - const value = output.slice(start + PATH_MARKER_START.length, end).trim(); - return value.length > 0 ? value : undefined; -} - -/** - * Merge the login shell's PATH with the current one: shell entries first (so - * profile-managed dirs like /opt/homebrew/bin win), then any current entries - * the shell PATH doesn't already contain (so explicitly-injected dirs from - * the launching environment aren't lost). Duplicates are dropped. - */ -export function mergePaths(shellPath: string, currentPath: string): string { - const entries = [ - ...shellPath.split(delimiter), - ...currentPath.split(delimiter), - ] - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - return Array.from(new Set(entries)).join(delimiter); -} - -/** - * Run the user's shell with its profiles sourced and capture its PATH. - * Resolves to undefined on any failure (missing shell, timeout, profile - * error) — callers should treat that as "keep the current PATH". - */ -export function resolveLoginShellPath( - shell: string, - timeoutMs = SHELL_TIMEOUT_MS, -): Promise { - return new Promise((resolve) => { - const invocation = shellInvocation(shell, PRINT_PATH_COMMAND); - const child = spawn(shell, invocation.args, { - argv0: invocation.argv0, - stdio: ["ignore", "pipe", "ignore"], - detached: true, - }); - - let output = ""; - let settled = false; - const settle = (value: string | undefined) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(value); - }; - - const timeout = setTimeout(() => { - try { - if (child.pid) { - process.kill(-child.pid, "SIGKILL"); - } - } catch { - child.kill("SIGKILL"); - } - settle(undefined); - }, timeoutMs); - - child.stdout?.on("data", (data: Buffer) => { - output += data.toString("utf8"); - }); - child.on("error", () => settle(undefined)); - child.on("close", () => settle(extractMarkedPath(output))); - }); -} - -/** - * Resolve the login shell's PATH and merge it into process.env.PATH. The - * shell comes from the account database (see loginShellFor); if it can't - * produce a PATH (exotic shell, broken profile), retry once with the - * platform default shell before giving up. - * - * No-op on Windows (the GUI PATH comes from the registry there) and when - * CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the - * returned status but never block startup. The result never contains the - * resolved PATH itself so it is safe to log verbatim. - */ -export async function ensureLoginShellPath(options?: { - platform?: NodeJS.Platform; - env?: NodeJS.ProcessEnv; - timeoutMs?: number; - /** Test seam: overrides passwd/$SHELL discovery of the user's shell. */ - userShell?: string; - /** Test seam: overrides the platform-default fallback shell. */ - fallbackShell?: string; -}): Promise< - | { status: "applied"; pathEntries: number; shell: string } - | { status: "skipped"; reason: string } - | { status: "failed"; shell: string } -> { - const platform = options?.platform ?? process.platform; - const env = options?.env ?? process.env; - - if (platform === "win32") { - return { status: "skipped", reason: "windows" }; - } - if (env[SKIP_ENV_VAR]?.trim()) { - return { status: "skipped", reason: SKIP_ENV_VAR }; - } - - const userShell = options?.userShell ?? loginShellFor(platform, env); - const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform); - const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS; - // The fallback gets half the budget so the combined worst case stays - // bounded even when both shells hang (see SHELL_TIMEOUT_MS). - const attempts: Array<[shell: string, timeoutMs: number]> = - userShell === fallbackShell - ? [[userShell, baseTimeoutMs]] - : [ - [userShell, baseTimeoutMs], - [fallbackShell, baseTimeoutMs / 2], - ]; - - for (const [shell, timeoutMs] of attempts) { - const shellPath = await resolveLoginShellPath(shell, timeoutMs); - if (!shellPath) { - continue; - } - const merged = mergePaths(shellPath, env.PATH ?? ""); - env.PATH = merged; - return { - status: "applied", - pathEntries: merged.split(delimiter).length, - shell, - }; - } - return { status: "failed", shell: userShell }; -} diff --git a/apps/examples/desktop-app/sidecar/types.ts b/apps/examples/desktop-app/sidecar/types.ts index 70aac2e9a4..cdde457879 100644 --- a/apps/examples/desktop-app/sidecar/types.ts +++ b/apps/examples/desktop-app/sidecar/types.ts @@ -5,11 +5,15 @@ import type { ITelemetryService, ManagedHubBuildMismatchEvent, NodeHubClient, + RemoteEnvironmentConnection, + RemoteEnvironmentService, ToolApprovalResult, } from "@cline/core"; import type { MessageWithMetadata } from "@cline/llms"; import type { UserContext } from "@cline/shared"; +export const LOCAL_ENVIRONMENT_ID = "local"; + export type JsonRecord = Record; export type ChatTurnAttachments = { @@ -50,6 +54,7 @@ export type PromptInQueue = { }; export type LiveSession = { + environmentId?: string; config: JsonRecord; messages: MessageWithMetadata[]; promptsInQueue: PromptInQueue[]; @@ -74,6 +79,16 @@ export type LiveSession = { consumedAttachmentFiles?: Map; }; +export type SessionRuntimeBinding = { + environmentId: string; + kind: "local" | "ssh"; + workspaceRoot: string; + sessionManager: ClineCore; + hubClient: NodeHubClient; + unsubscribeSessionEvents: () => void; + remote?: RemoteEnvironmentConnection; +}; + export type ToolApprovalRequestItem = { requestId: string; sessionId: string; @@ -130,14 +145,15 @@ export type SidecarContext = { wsClients: Set; pendingApprovals: Map; pendingQuestions: Map; - sessionManager: ClineCore | null; - hubClient: NodeHubClient | null; - workspaceRoot: string; + runtimeBindings: Map; + sessionEnvironmentIds: Map; + activeEnvironmentId: string; + remoteEnvironments: RemoteEnvironmentService | null; + localWorkspaceRoot: string; logger?: BasicLogger; telemetry?: ITelemetryService; /** Analytics identity and explicit account state forwarded with each session. */ telemetryUser?: UserContext; - unsubscribeSessionEvents: (() => void) | null; /** * Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and * replayed to webviews that connect after the event fired. diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index 19215ded06..37d950f215 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -38,7 +38,11 @@ "active": true, "targets": "all", "externalBin": ["bin/code-sidecar"], - "resources": ["icons/app/*.png", "icons/app/macos/*.png"], + "resources": [ + "icons/app/*.png", + "icons/app/macos/*.png", + "bin/remote-helpers/*" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/apps/examples/desktop-app/webview/app/globals.css b/apps/examples/desktop-app/webview/app/globals.css index 522058d804..0da7f17b9b 100644 --- a/apps/examples/desktop-app/webview/app/globals.css +++ b/apps/examples/desktop-app/webview/app/globals.css @@ -54,10 +54,11 @@ } /* Selectable content surfaces: chat message bodies (incl. markdown and - * code blocks), reasoning/tool panels, and diff text. */ + * code blocks), reasoning/tool panels, settings, and diff text. */ .cline-chat-message-content, .cline-markdown, .cline-chat-selectable, + .cline-settings-content, pre, code { -webkit-user-select: text; diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 13fce8fd35..496170df57 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -34,6 +34,8 @@ import { } from "@/components/ui/sidebar"; import { ChatInputBar } from "@/components/views/chat/chat-input-bar"; import { ChatMessages } from "@/components/views/chat/chat-messages"; +import { EnvironmentSelector } from "@/components/views/chat/environment-selector"; +import { RemoteDirectoryPicker } from "@/components/views/chat/remote-directory-picker"; import { WelcomeScreen } from "@/components/views/chat/welcome-chat"; import { WelcomeSetupNotice } from "@/components/views/chat/welcome-setup-notice"; import type { OnboardingStep } from "@/components/views/onboarding/onboarding-view"; @@ -45,6 +47,7 @@ import { } from "@/components/window-title-bar"; import { AccountProvider } from "@/contexts/account-context"; import { WorkspaceProvider } from "@/contexts/workspace-context"; +import type { ProcessContext } from "@/hooks/chat-session/types"; import { useAppUpdate } from "@/hooks/use-app-update"; import { useChatSession } from "@/hooks/use-chat-session"; import { useSessionAgents } from "@/hooks/use-session-agents"; @@ -84,6 +87,11 @@ import { subscribeToProviderCatalogInvalidation, writeProviderCatalogSnapshot, } from "@/lib/provider-model-catalog"; +import type { + RemoteEnvironmentConnectResult, + RemoteEnvironmentListResult, + RemoteEnvironmentProfile, +} from "@/lib/remote-environments"; import { buildSessionAgentActivity, mergeAgentActivity, @@ -93,10 +101,16 @@ import { type SessionHistoryItem, type SessionMetadata, } from "@/lib/session-history"; +import { eventEnvironmentId, sessionKey } from "@/lib/session-identity"; import { readImportedFromTool } from "@/lib/session-import"; import { syncHubAccent, syncHubTheme, watchSystemHubTheme } from "@/lib/theme"; +import { + type RemoteWorkspaceEnvironment, + remoteWorkspaceEnvironmentFromContext, +} from "@/lib/workspace-environment"; import { filterWorkspacePaths, + LOCAL_WORKSPACE_ENVIRONMENT_ID, mergeWorkspacePaths, normalizeWorkspacePath, readWorkspaceSelectionFromWindow, @@ -172,7 +186,12 @@ export default function Home() { const [appState, dispatchApp] = useReducer( desktopAppReducer, initialThreadId, - (threadId) => createDesktopAppState(threadId, "General"), + (threadId) => + createDesktopAppState( + threadId, + "General", + LOCAL_WORKSPACE_ENVIRONMENT_ID, + ), ); // Starts false on both server and first client render (hydration-safe); // the effect below reads the persisted state right after mount. @@ -185,8 +204,26 @@ export default function Home() { // provider setup step. const [onboardingInitialStep, setOnboardingInitialStep] = useState("welcome"); + const environmentSelectionRevision = useRef(0); + const [activeRemoteEnvironment, setActiveRemoteEnvironment] = + useState(null); + const [remoteEnvironmentProfiles, setRemoteEnvironmentProfiles] = useState< + RemoteEnvironmentProfile[] + >([]); + const [ + remoteEnvironmentProfilesLoading, + setRemoteEnvironmentProfilesLoading, + ] = useState(true); + const [remoteDirectoryPicker, setRemoteDirectoryPicker] = + useState(null); + const remoteDirectoryPickerResolverRef = useRef< + ((path: string | null) => void) | null + >(null); + const selectLocalDraftWhenChatVisibleRef = useRef(false); const { navigation, threads } = appState; const { activeThreadId, settingsSection, view } = navigation.current; + const activeEnvironmentId = + activeRemoteEnvironment?.id ?? LOCAL_WORKSPACE_ENVIRONMENT_ID; const navigate = useCallback((destination: AppLocation) => { dispatchApp({ type: "navigate", destination }); @@ -231,13 +268,223 @@ 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, + }); + } + } + }) + .catch(() => { + // The chat bootstrap reports backend availability separately. + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (view !== "chat") return; + let revision = 0; + const refresh = () => { + const requestRevision = ++revision; + setRemoteEnvironmentProfilesLoading(true); + void desktopClient + .invoke("list_remote_environments") + .then((result) => { + if (requestRevision === revision) + setRemoteEnvironmentProfiles(result.profiles); + }) + .catch(() => { + // The Settings > Remote surface owns profile-management errors. + }) + .finally(() => { + if (requestRevision === revision) + setRemoteEnvironmentProfilesLoading(false); + }); + }; + const unsubscribe = desktopClient.subscribe( + "remote_environment_profiles_changed", + refresh, + ); + refresh(); + return () => { + ++revision; + unsubscribe(); + }; + }, [view]); + + useEffect( + () => () => { + remoteDirectoryPickerResolverRef.current?.(null); + remoteDirectoryPickerResolverRef.current = null; + }, + [], + ); + useEffect(() => watchDesktopTrayStatus(), []); useEffect(() => watchDesktopNotifications(), []); - const handleNewThread = useCallback(() => { - dispatchApp({ type: "new-thread", threadId: makeThreadId() }); + const createThreadForEnvironment = useCallback((environmentId: string) => { + dispatchApp({ + type: "new-thread", + threadId: makeThreadId(), + environmentId, + }); requestPromptInputFocus(); }, []); + const handleNewThread = useCallback(() => { + createThreadForEnvironment(activeEnvironmentId); + }, [activeEnvironmentId, createThreadForEnvironment]); + const selectEnvironmentDraft = useCallback((environmentId: string) => { + dispatchApp({ + type: "select-environment-draft", + environmentId, + threadId: makeThreadId(), + }); + }, []); + const handleSelectEnvironment = useCallback( + async (environmentId: string) => { + environmentSelectionRevision.current += 1; + if (environmentId === activeRemoteEnvironment?.id) { + // Already connected (e.g. after navigating Back to a local draft); + // reconnecting would tear down and rebuild the remote runtime. + selectEnvironmentDraft(environmentId); + return; + } + try { + if (environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID) { + if (activeRemoteEnvironment) { + await desktopClient.invoke( + "disconnect_remote_environment", + { id: activeRemoteEnvironment.id }, + { timeoutMs: null }, + ); + } + setActiveRemoteEnvironment(null); + selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID); + return; + } + + const result = + await desktopClient.invoke( + "connect_remote_environment", + { id: environmentId }, + { timeoutMs: null }, + ); + const connectedEnvironmentId = result.environmentId.trim(); + const homeDir = result.homeDir.trim() || result.workspaceRoot.trim(); + if ( + connectedEnvironmentId !== environmentId || + result.activeEnvironmentId !== connectedEnvironmentId || + result.activeProfileId !== connectedEnvironmentId || + !homeDir + ) { + throw new Error( + "The SSH host connected without a valid environment identity or home directory.", + ); + } + + const storedWorkspace = readWorkspaceSelectionFromWindow( + connectedEnvironmentId, + ); + if (!storedWorkspace.lastWorkspace) { + writeWorkspaceSelectionToWindow(connectedEnvironmentId, { + ...storedWorkspace, + lastWorkspace: homeDir, + }); + } + setActiveRemoteEnvironment({ + id: connectedEnvironmentId, + homeDir, + }); + selectEnvironmentDraft(connectedEnvironmentId); + } catch (error) { + toast({ + title: + environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID + ? "Unable to switch to Local" + : "Unable to connect to SSH host", + description: error instanceof Error ? error.message : String(error), + variant: "destructive", + }); + throw error; + } + }, + [activeRemoteEnvironment, selectEnvironmentDraft], + ); + const pickRemoteWorkspaceDirectory = useCallback( + (environment: RemoteWorkspaceEnvironment): Promise => { + remoteDirectoryPickerResolverRef.current?.(null); + return new Promise((resolve) => { + remoteDirectoryPickerResolverRef.current = resolve; + setRemoteDirectoryPicker(environment); + }); + }, + [], + ); + const completeRemoteDirectoryPicker = useCallback((path: string | null) => { + const resolve = remoteDirectoryPickerResolverRef.current; + remoteDirectoryPickerResolverRef.current = null; + setRemoteDirectoryPicker(null); + resolve?.(path); + }, []); + + useEffect( + () => + desktopClient.subscribe("remote_environment_changed", (payload) => { + if (!payload || typeof payload !== "object") return; + const event = payload as { + status?: unknown; + environmentId?: unknown; + homeDir?: unknown; + workspaceRoot?: unknown; + }; + if ( + event.status === "connected" && + typeof event.environmentId === "string" && + typeof event.homeDir === "string" + ) { + environmentSelectionRevision.current += 1; + selectLocalDraftWhenChatVisibleRef.current = false; + setActiveRemoteEnvironment({ + id: event.environmentId, + homeDir: event.homeDir, + }); + } + if (event.status === "disconnected") { + environmentSelectionRevision.current += 1; + completeRemoteDirectoryPicker(null); + setActiveRemoteEnvironment(null); + if (view === "chat") { + selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID); + } else { + selectLocalDraftWhenChatVisibleRef.current = true; + } + } + }), + [completeRemoteDirectoryPicker, selectEnvironmentDraft, view], + ); + + useEffect(() => { + if (view !== "chat" || !selectLocalDraftWhenChatVisibleRef.current) { + return; + } + selectLocalDraftWhenChatVisibleRef.current = false; + selectEnvironmentDraft(LOCAL_WORKSPACE_ENVIRONMENT_ID); + }, [selectEnvironmentDraft, view]); const completeOnboarding = useCallback(() => { markOnboardingCompleted(); @@ -255,26 +502,46 @@ export default function Home() { const handleOpenSession = useCallback( (session: SessionHistoryItem, initialPromptDraft?: string) => { - dispatchApp({ type: "open-session", session, initialPromptDraft }); - }, - [], - ); - - const handleDeleteSession = useCallback( - (deletedSessionId: string, deletedThreadId?: string) => { dispatchApp({ - type: "delete-session", - deletedSessionId, - deletedThreadId, - fallbackThreadId: makeThreadId(), + type: "open-session", + session, + environmentId: session.environmentId, + initialPromptDraft, }); }, [], ); + const handleDeleteSession = useCallback( + ( + deletedSessionId: string, + deletedThreadId?: string, + environmentId = LOCAL_WORKSPACE_ENVIRONMENT_ID, + ) => { + dispatchApp({ + type: "delete-session", + environmentId, + deletedSessionId, + deletedThreadId, + fallbackThreadId: makeThreadId(), + fallbackEnvironmentId: activeEnvironmentId, + }); + }, + [activeEnvironmentId], + ); + const handleUpdateSessionMetadata = useCallback( - (sessionId: string, metadata: SessionMetadata) => { - dispatchApp({ type: "update-session-metadata", sessionId, metadata }); + ( + sessionId: string, + metadata: SessionMetadata, + environmentId = LOCAL_WORKSPACE_ENVIRONMENT_ID, + ) => { + dispatchApp({ + type: "update-session-metadata", + sessionId, + metadata, + environmentId, + }); }, [], ); @@ -291,13 +558,16 @@ export default function Home() { if (!sessionId) { return; } - handleDeleteSession(sessionId); + handleDeleteSession(sessionId, undefined, eventEnvironmentId(payload)); }); }, [handleDeleteSession]); - const activeHistorySessionId = - threads.find((thread) => thread.id === activeThreadId)?.historySession - ?.sessionId ?? null; + const activeHistorySession = threads.find( + (thread) => thread.id === activeThreadId, + )?.historySession; + const activeHistorySessionId = activeHistorySession + ? sessionKey(activeHistorySession) + : null; const activeThread = threads.find((thread) => thread.id === activeThreadId) ?? threads[0]; const handleHome = useCallback(() => { @@ -359,7 +629,8 @@ export default function Home() { }, []); const sessionHistory = useSessionHistory({ activeSessionId: activeHistorySessionId, - onDeleteSession: handleDeleteSession, + onDeleteSession: (sessionId, environmentId) => + handleDeleteSession(sessionId, undefined, environmentId), onOpenSession: handleOpenSession, onUpdateSessionMetadata: handleUpdateSessionMetadata, }); @@ -368,9 +639,12 @@ export default function Home() { sessionHistoryRef.current = sessionHistory.sessions; }, [sessionHistory.sessions]); const handleOpenSessionById = useCallback( - async (sessionId: string) => { + async (sessionId: string, environmentId?: string) => { const cachedSession = sessionHistoryRef.current.find( - (session) => session.sessionId === sessionId, + (session) => + session.sessionId === sessionId && + session.environmentId === + (environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID), ); if (cachedSession) { handleOpenSession(cachedSession); @@ -379,11 +653,22 @@ export default function Home() { try { const session = await desktopClient.invoke( "get_discovered_session", - { sessionId }, + { + environmentId: environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, + sessionId: sessionId, + }, ); if (!session) { throw new Error("The session for this run is no longer available."); } + if ( + environmentId !== undefined && + session.environmentId !== environmentId + ) { + throw new Error( + `The session belongs to environment ${session.environmentId}, not ${environmentId}.`, + ); + } handleOpenSession(session); } catch (error) { toast({ @@ -418,8 +703,12 @@ export default function Home() { [handleNewThread, handleOpenSessionById, handleViewChange], ); const historyWorkspacePaths = useMemo( - () => workspacePathsFromSessions(sessionHistory.sessions), - [sessionHistory.sessions], + () => + workspacePathsFromSessions( + sessionHistory.sessions, + activeThread?.environmentId ?? activeEnvironmentId, + ), + [activeEnvironmentId, activeThread?.environmentId, sessionHistory.sessions], ); // A child agent session names its parent, but only the history list knows the // parent's title — resolve it here so the chat header can point back to it. @@ -430,10 +719,19 @@ export default function Home() { return undefined; } const title = sessionHistory.threads.find( - (thread) => thread.id === parentSessionId, + (thread) => + thread.id === + sessionKey({ + sessionId: parentSessionId, + environmentId: activeThread.environmentId, + }), )?.title; return { sessionId: parentSessionId, title }; - }, [activeThread?.historySession?.parentSessionId, sessionHistory.threads]); + }, [ + activeThread?.historySession?.parentSessionId, + activeThread?.environmentId, + sessionHistory.threads, + ]); return ( @@ -490,16 +788,44 @@ export default function Home() { inert={view === "settings" ? true : undefined} > handleSettingsSectionChange("Remote")} + onPickRemoteWorkspaceDirectory={ + pickRemoteWorkspaceDirectory + } + onSelectEnvironment={handleSelectEnvironment} + remoteEnvironment={ + activeRemoteEnvironment?.id === + activeThread.environmentId + ? activeRemoteEnvironment + : null + } historySession={activeThread.historySession} initialPromptDraft={activeThread.initialPromptDraft} knownWorkspacePaths={historyWorkspacePaths} onInitialPromptDraftConsumed={ handleInitialPromptDraftConsumed } - onUpdateSessionMetadata={handleUpdateSessionMetadata} + onUpdateSessionMetadata={(sessionId, metadata) => + handleUpdateSessionMetadata( + sessionId, + metadata, + activeThread.environmentId, + ) + } threadId={activeThread.id} - onDeleteSession={handleDeleteSession} + onDeleteSession={(sessionId, threadId) => + handleDeleteSession( + sessionId, + threadId, + activeThread.environmentId, + ) + } onNewThread={handleNewThread} onOpenSession={handleOpenSession} onOpenSessionById={handleOpenSessionById} @@ -549,6 +875,15 @@ export default function Home() { onOpenSession={handleOpenSessionById} open={commandBarOpen && !showOnboarding} /> + {remoteDirectoryPicker ? ( + completeRemoteDirectoryPicker(null)} + onSelect={completeRemoteDirectoryPicker} + open + /> + ) : null} ); } @@ -562,11 +897,15 @@ let workspacesLoadedOnce = false; function ChatThreadPane({ threadId, + environmentId, + environmentProfiles, + environmentProfilesLoading, historySession, initialPromptDraft, knownWorkspacePaths, onInitialPromptDraftConsumed, onUpdateSessionMetadata, + onAddSshHost, onDeleteSession, onNewThread, onOpenSession, @@ -574,10 +913,16 @@ function ChatThreadPane({ onOpenSetup, onOpenModelSettings, onOpenAccountSettings, + onPickRemoteWorkspaceDirectory, + onSelectEnvironment, parentSession, + remoteEnvironment, onThreadStarted, }: { threadId: string; + environmentId: string; + environmentProfiles: RemoteEnvironmentProfile[]; + environmentProfilesLoading: boolean; historySession?: SessionHistoryItem; initialPromptDraft?: string; knownWorkspacePaths: string[]; @@ -586,17 +931,26 @@ function ChatThreadPane({ sessionId: string, metadata: SessionMetadata, ) => void; + onAddSshHost: () => void; onDeleteSession?: (sessionId: string, threadId?: string) => void; onNewThread?: () => void; onOpenSession?: ( session: SessionHistoryItem, initialPromptDraft?: string, ) => void; - onOpenSessionById?: (sessionId: string) => void | Promise; + onOpenSessionById?: ( + sessionId: string, + environmentId?: string, + ) => void | Promise; + onPickRemoteWorkspaceDirectory: ( + environment: RemoteWorkspaceEnvironment, + ) => Promise; + onSelectEnvironment: (environmentId: string) => Promise; onOpenSetup?: () => void; onOpenModelSettings?: () => void; onOpenAccountSettings?: () => void; parentSession?: { sessionId: string; title?: string }; + remoteEnvironment: RemoteWorkspaceEnvironment | null; onThreadStarted?: (threadId: string) => void; }) { const { @@ -630,7 +984,7 @@ function ChatThreadPane({ reset, abort, hydrateSession, - } = useChatSession(); + } = useChatSession(environmentId); // 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. @@ -675,7 +1029,7 @@ function ChatThreadPane({ filterWorkspacePaths( mergeWorkspacePaths( knownWorkspacePaths, - readWorkspaceSelectionFromWindow().workspaces, + readWorkspaceSelectionFromWindow(environmentId).workspaces, ), ), ); @@ -699,23 +1053,35 @@ function ChatThreadPane({ useEffect(() => { setWorkspaces((current) => { + const stored = readWorkspaceSelectionFromWindow(environmentId); const merged = filterWorkspacePaths( - mergeWorkspacePaths(knownWorkspacePaths, current), + mergeWorkspacePaths(knownWorkspacePaths, stored.workspaces), ); return current.length === merged.length && current.every((workspace, index) => workspace === merged[index]) ? current : merged; }); - }, [knownWorkspacePaths]); + }, [environmentId, knownWorkspacePaths]); useEffect(() => { + if ( + (config.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID) !== environmentId + ) { + return; + } const lastWorkspace = (config.workspaceRoot || config.cwd || "").trim(); - writeWorkspaceSelectionToWindow({ + writeWorkspaceSelectionToWindow(environmentId, { lastWorkspace, workspaces: mergeWorkspacePaths(workspaces, [lastWorkspace]), }); - }, [config.cwd, config.workspaceRoot, workspaces]); + }, [ + config.cwd, + config.environmentId, + config.workspaceRoot, + environmentId, + workspaces, + ]); const providerCredentialsRequestRef = useRef(0); const loadProviderCredentials = useCallback(async () => { @@ -814,10 +1180,13 @@ function ChatThreadPane({ return; } try { - const payload = await desktopClient.invoke<{ branch?: string }>( - "get_git_branch", - { cwd }, - ); + const payload = await desktopClient.invoke<{ + environmentId: string; + branch?: string; + }>("get_git_branch", { cwd, environmentId }); + if (payload.environmentId !== environmentId) { + return; + } if (!gitBranchRequestGateRef.current.commit(requestId)) { return; } @@ -826,7 +1195,7 @@ function ChatThreadPane({ } catch { // Preserve the latest successful branch through transient failures. } - }, [getWorkspaceCwd]); + }, [environmentId, getWorkspaceCwd]); const invalidateGitBranch = useCallback(() => { gitBranchRequestGateRef.current.invalidate(); @@ -845,9 +1214,13 @@ function ChatThreadPane({ } try { const payload = await desktopClient.invoke<{ + environmentId: string; current?: string; branches?: string[]; - }>("list_git_branches", { cwd }); + }>("list_git_branches", { cwd, environmentId }); + if (payload.environmentId !== environmentId) { + return { current: "no-git", branches: [] }; + } const current = payload?.current?.trim() || "no-git"; const branches = Array.isArray(payload?.branches) ? payload.branches.filter((item) => item.trim().length > 0) @@ -856,7 +1229,7 @@ function ChatThreadPane({ } catch { return { current: "no-git", branches: [] }; } - }, [getWorkspaceCwd]); + }, [environmentId, getWorkspaceCwd]); const switchGitBranch = useCallback( async (nextBranch: string): Promise => { @@ -865,10 +1238,17 @@ function ChatThreadPane({ return false; } try { - await desktopClient.invoke<{ branch?: string }>("checkout_git_branch", { + const payload = await desktopClient.invoke<{ + environmentId: string; + branch?: string; + }>("checkout_git_branch", { cwd, branch: nextBranch, + environmentId, }); + if (payload.environmentId !== environmentId) { + return false; + } invalidateGitBranch(); await refreshGitBranch(); return true; @@ -876,7 +1256,7 @@ function ChatThreadPane({ return false; } }, - [getWorkspaceCwd, invalidateGitBranch, refreshGitBranch], + [environmentId, getWorkspaceCwd, invalidateGitBranch, refreshGitBranch], ); const listWorkspaces = useCallback( @@ -891,10 +1271,14 @@ function ChatThreadPane({ // process cwd fallback); it renders via its own registration in the // selector and welcome screen instead of joining the catalog. return filterWorkspacePaths( - mergeWorkspacePaths(knownWorkspacePaths, [preferred, current]), + mergeWorkspacePaths( + knownWorkspacePaths, + readWorkspaceSelectionFromWindow(environmentId).workspaces, + [preferred, current], + ), ); }, - [knownWorkspacePaths], + [environmentId, knownWorkspacePaths], ); const refreshWorkspaces = useCallback( @@ -902,7 +1286,7 @@ function ChatThreadPane({ try { const results = await listWorkspaces(preferredWorkspace); setWorkspaces((current) => { - const merged = mergeWorkspacePaths(results, current); + const merged = results; return current.length === merged.length && current.every((workspace, index) => workspace === merged[index]) ? current @@ -939,6 +1323,7 @@ function ChatThreadPane({ valid?: boolean; path?: string; }>("validate_workspace_directory", { + environmentId, path: nextWorkspace, }) .catch(() => ({ valid: false, path: undefined })); @@ -967,7 +1352,7 @@ function ChatThreadPane({ return true; }, - [invalidateGitBranch, refreshWorkspaces, setWorkspacePath], + [environmentId, invalidateGitBranch, refreshWorkspaces, setWorkspacePath], ); const selectChat = useCallback(async (): Promise => { @@ -982,10 +1367,14 @@ function ChatThreadPane({ // Resolves to null when the user cancels; rethrows picker failures // (e.g. no zenity/kdialog on Linux) so callers can surface an error // and offer manual path entry instead of a silent no-op. + if (remoteEnvironment) { + return await onPickRemoteWorkspaceDirectory(remoteEnvironment); + } try { const selected = await desktopClient.invoke( "pick_workspace_directory", { + environmentId, initialPath: initialPath?.trim() || undefined, }, ); @@ -1002,7 +1391,7 @@ function ChatThreadPane({ ); } }, - [], + [environmentId, onPickRemoteWorkspaceDirectory, remoteEnvironment], ); useEffect(() => { @@ -1206,6 +1595,7 @@ function ChatThreadPane({ const cwd = config.cwd ?? workspaceRoot; const forkedHistorySession: SessionHistoryItem = { sessionId: result.newSessionId, + environmentId: config.environmentId, status: "completed", provider: config.provider, model: config.model, @@ -1278,6 +1668,7 @@ function ChatThreadPane({ "delete_chat_session", { sessionId: activeSessionToDelete, + environmentId, }, ); if (!deleted) { @@ -1296,6 +1687,7 @@ function ChatThreadPane({ new CustomEvent("cline:session-deleted", { detail: { sessionId: activeSessionToDelete, + environmentId, }, }), ); @@ -1321,6 +1713,7 @@ function ChatThreadPane({ }, [ activeSessionToDelete, deletingSession, + environmentId, onDeleteSession, reset, threadId, @@ -1444,6 +1837,7 @@ function ChatThreadPane({ loading: agentsLoading, error: agentsError, } = useSessionAgents({ + environmentId, sessionId: displayedSessionId, panelOpen: agentPanelOpen, sessionActive: isSessionActive, @@ -1458,8 +1852,9 @@ function ChatThreadPane({ // A child agent has its own session row, so opening it goes through the same // path as any other session — it is just never listed in the sidebar. const onOpenAgentSession = useCallback( - (agentSessionId: string) => onOpenSessionById?.(agentSessionId), - [onOpenSessionById], + (agentSessionId: string) => + onOpenSessionById?.(agentSessionId, environmentId), + [environmentId, onOpenSessionById], ); const handleRenameTitle = useCallback( @@ -1471,6 +1866,7 @@ function ChatThreadPane({ try { await desktopClient.invoke("update_chat_session_title", { sessionId: activeSessionForTitle, + environmentId, title: nextTitle, }); const normalizedTitle = nextTitle.trim(); @@ -1483,6 +1879,7 @@ function ChatThreadPane({ new CustomEvent("cline:session-title-updated", { detail: { sessionId: activeSessionForTitle, + environmentId, title: normalizedTitle, }, }), @@ -1493,6 +1890,7 @@ function ChatThreadPane({ }, [ activeSessionForTitle, + environmentId, historySession?.metadata, onUpdateSessionMetadata, renamingSession, @@ -1553,6 +1951,7 @@ function ChatThreadPane({ const composer = ( 0} onAbort={handleAbort} onAttachFiles={handleAttachFiles} @@ -1605,7 +2004,7 @@ function ChatThreadPane({ agentsLoading={agentsLoading} onAgentsOpenChange={setAgentPanelOpen} onOpenAgentSession={onOpenAgentSession} - onOpenParentSession={onOpenSessionById} + onOpenParentSession={onOpenAgentSession} parentSession={hideDeletedSessionUi ? undefined : parentSession} canEditTitle={Boolean(activeSessionForTitle)} canDeleteSession={Boolean(activeSessionToDelete)} @@ -1628,6 +2027,7 @@ function ChatThreadPane({ showDiffView ? ( setShowDiffView(false)} /> @@ -1656,6 +2056,15 @@ function ChatThreadPane({ ) } composer={composer} + environmentSelector={ + + } gitBranch={gitBranch} notice={ providersLoaded && diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx index 3508167f55..d18d63e5f0 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx @@ -13,6 +13,7 @@ import { Import, Loader2, Mic, + Network, PanelLeftOpen, Pencil, Pin, @@ -147,6 +148,7 @@ const SETTINGS_SECTION_ICONS = { Channels: Radio, Schedules: Clock3, Import: Import, + Remote: Network, Account: CircleUserRound, Customize: Blocks, Marketplace: Store, diff --git a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx index 6c7bcf0cdc..10f5ef94df 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx @@ -13,6 +13,7 @@ import { import type { ProviderModel } from "@/lib/provider-schema"; import { buildUserInstructionSlashCommands, + buildWorkspaceFileSearchKey, ChatInputBar, } from "./chat-input-bar"; @@ -342,6 +343,24 @@ describe("ChatInputBar", () => { expect(onAbort).toHaveBeenCalledOnce(); }); + it("isolates workspace file search caches by environment", () => { + const localKey = buildWorkspaceFileSearchKey( + "local", + "/workspace/shared", + "src", + ); + const remoteKey = buildWorkspaceFileSearchKey( + "pi-server", + "/workspace/shared", + "src", + ); + + expect(remoteKey).not.toBe(localKey); + expect( + buildWorkspaceFileSearchKey("pi-server", "/workspace/shared", "src"), + ).toBe(remoteKey); + }); + it("builds slash commands from both workflows and skills", () => { expect( buildUserInstructionSlashCommands({ @@ -923,6 +942,7 @@ describe("ChatInputBar", () => { > { > { > { > ( "search_workspace_files", { + environmentId, workspaceRoot, query: activeMention.query, limit: 10, @@ -979,7 +994,13 @@ function ChatInputBarImpl({ cancelled = true; window.clearTimeout(timeoutId); }; - }, [activeMention, mentionOpen, workspaceRoot, mentionFiles.length]); + }, [ + activeMention, + environmentId, + mentionOpen, + workspaceRoot, + mentionFiles.length, + ]); const insertMentionFile = useCallback( (filePath: string) => { diff --git a/apps/examples/desktop-app/webview/components/views/chat/diff-view.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/diff-view.test.tsx index 77f6a1aff8..7e4a6dc54a 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/diff-view.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/diff-view.test.tsx @@ -143,6 +143,7 @@ describe("DiffView file actions", () => { root.render( , @@ -159,6 +160,7 @@ describe("DiffView file actions", () => { root.render( , @@ -176,6 +178,7 @@ describe("DiffView file actions", () => { await click(vscodeItem as Element); expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", { + environmentId: "local", path: "docs/a.mdx", cwd: "/Users/renee/cline", editor: "vscode", @@ -191,7 +194,13 @@ describe("DiffView file actions", () => { }); await act(async () => { - root.render(); + root.render( + , + ); }); await pointerDown(buttonWithLabel("Open docs/a.mdx in editor")); @@ -202,6 +211,7 @@ describe("DiffView file actions", () => { await click(menuItems()[0] as Element); expect(invokeMock).toHaveBeenCalledWith("open_file_in_editor", { + environmentId: "local", path: "docs/a.mdx", editor: "default", }); @@ -209,7 +219,13 @@ describe("DiffView file actions", () => { it("copies the path as-is when no cwd is available", async () => { await act(async () => { - root.render(); + root.render( + , + ); }); await click(buttonWithLabel("Copy file path for docs/a.mdx")); diff --git a/apps/examples/desktop-app/webview/components/views/chat/diff-view.tsx b/apps/examples/desktop-app/webview/components/views/chat/diff-view.tsx index 7652d7529b..5ba9e9ec12 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/diff-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/diff-view.tsx @@ -28,6 +28,7 @@ import { resolveWorkspaceFilePath } from "@/lib/workspace-paths"; import { EditorIcon } from "./editor-icons"; type DiffViewProps = { + environmentId: string; fileDiffs: SessionFileDiff[]; cwd?: string; onClose: () => void; @@ -38,7 +39,12 @@ type EditorOption = { label: string; }; -export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) { +export function DiffView({ + environmentId, + fileDiffs, + cwd, + onClose, +}: DiffViewProps) { const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); const [editors, setEditors] = useState([]); @@ -120,6 +126,7 @@ export function DiffView({ fileDiffs, cwd, onClose }: DiffViewProps) { collapsed={collapsedFiles.has(file.path)} cwd={cwd} editors={editors} + environmentId={environmentId} file={file} key={file.path} onToggle={() => toggleFileCollapse(file.path)} @@ -137,12 +144,14 @@ function DiffFileSection({ collapsed, cwd, editors, + environmentId, onToggle, }: { file: SessionFileDiff; collapsed: boolean; cwd?: string; editors: EditorOption[]; + environmentId: string; onToggle: () => void; }) { const [copied, setCopied] = useState(false); @@ -175,6 +184,7 @@ function DiffFileSection({ setOpening(true); try { await desktopClient.invoke("open_file_in_editor", { + environmentId, path: file.path, ...(cwd?.trim() ? { cwd } : {}), ...(editor ? { editor } : {}), @@ -192,7 +202,7 @@ function DiffFileSection({ setOpening(false); } }, - [file.path, cwd], + [cwd, environmentId, file.path], ); return ( diff --git a/apps/examples/desktop-app/webview/components/views/chat/environment-selector.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/environment-selector.test.tsx new file mode 100644 index 0000000000..971ffada4e --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/chat/environment-selector.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteEnvironmentProfile } from "@/lib/remote-environments"; +import { + buildEnvironmentSelectorModel, + EnvironmentSelector, +} from "./environment-selector"; + +const profiles: RemoteEnvironmentProfile[] = [ + { + id: "pi-server", + name: "Raspberry Pi", + host: "pi.example.com", + user: "pi", + }, + { + id: "build-box", + name: "Build box", + host: "builder.example.com", + user: "ubuntu", + port: 2200, + }, +]; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + if (!("ResizeObserver" in globalThis)) { + Object.assign(globalThis, { + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); + } + Element.prototype.scrollIntoView ??= () => {}; + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => {}; + Element.prototype.releasePointerCapture ??= () => {}; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +async function click(element: Element): Promise { + await act(async () => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + await Promise.resolve(); + }); +} + +async function pointerDown(element: Element): Promise { + await act(async () => { + element.dispatchEvent( + new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + }), + ); + await Promise.resolve(); + }); +} + +function trigger(): HTMLButtonElement { + const element = container.querySelector( + "#environment-selector-btn", + ); + expect(element).not.toBeNull(); + return element as HTMLButtonElement; +} + +function menuItemContaining(text: string): HTMLElement { + const item = Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).find((candidate) => candidate.textContent?.includes(text)); + expect(item).toBeDefined(); + return item as HTMLElement; +} + +describe("buildEnvironmentSelectorModel", () => { + it("builds a sorted remote catalog and identifies the connected profile", () => { + const model = buildEnvironmentSelectorModel("pi-server", [ + ...profiles, + { ...profiles[0], name: "Duplicate Pi" }, + { ...profiles[0], id: undefined, name: "Unsaved" }, + ]); + + expect(model).toMatchObject({ + activeKind: "remote", + activeLabel: "Raspberry Pi", + local: { id: "local", selected: false }, + }); + expect(model.remotes).toEqual([ + expect.objectContaining({ + id: "build-box", + label: "Build box", + selected: false, + }), + expect.objectContaining({ + id: "pi-server", + label: "Raspberry Pi", + selected: true, + }), + ]); + }); + + it("does not mislabel an unloaded remote environment as Local", () => { + expect(buildEnvironmentSelectorModel("remote-loading", [])).toMatchObject({ + activeKind: "remote", + activeLabel: "Remote", + local: { selected: false }, + }); + }); +}); + +describe("EnvironmentSelector", () => { + it("renders every environment tier and routes selections and host setup", async () => { + const onSelectEnvironment = vi.fn(async () => undefined); + const onAddSshHost = vi.fn(); + await act(async () => { + root.render( + , + ); + }); + + expect(trigger().textContent?.trim()).toBe(""); + expect(trigger().getAttribute("aria-label")).toBe( + "Environment: Raspberry Pi", + ); + expect(trigger().title).toBe("Environment: Raspberry Pi"); + expect(document.body.textContent).not.toContain("Raspberry Pi"); + await pointerDown(trigger()); + expect(document.body.textContent).toContain("Raspberry Pi"); + expect(document.body.textContent).toContain("Local"); + expect(document.body.textContent).toContain("Remote"); + expect(document.body.textContent).toContain("Build box"); + expect(document.body.textContent).not.toContain( + "ubuntu@builder.example.com:2200", + ); + expect(document.body.textContent).not.toContain("Connected"); + expect(document.body.textContent).toContain("Cloud"); + expect(document.body.textContent).toContain("Coming soon"); + expect( + Array.from(document.querySelectorAll('[role="menuitem"]')).some((item) => + item.textContent?.includes("Cloud"), + ), + ).toBe(false); + + await click(menuItemContaining("Local")); + await vi.waitFor(() => { + expect(onSelectEnvironment).toHaveBeenCalledWith("local"); + }); + + await pointerDown(trigger()); + const addHost = document.querySelector( + '[role="menuitem"][aria-label="Add SSH Host"]', + ); + expect(addHost).not.toBeNull(); + expect(addHost?.textContent?.trim()).toBe(""); + expect(addHost?.parentElement?.textContent).toContain("Remote"); + expect(document.body.textContent?.indexOf("Cloud")).toBeLessThan( + document.body.textContent?.indexOf("Remote") ?? 0, + ); + await click(addHost as HTMLElement); + expect(onAddSshHost).toHaveBeenCalledTimes(1); + }); + + it("reopens the menu after a rejected environment switch", async () => { + const onSelectEnvironment = vi + .fn() + .mockRejectedValue(new Error("SSH unavailable")); + await act(async () => { + root.render( + , + ); + }); + + await pointerDown(trigger()); + await click(menuItemContaining("Build box")); + await vi.waitFor(() => { + expect(onSelectEnvironment).toHaveBeenCalledWith("build-box"); + expect(menuItemContaining("Build box")).toBeDefined(); + }); + expect(trigger().disabled).toBe(false); + }); +}); diff --git a/apps/examples/desktop-app/webview/components/views/chat/environment-selector.tsx b/apps/examples/desktop-app/webview/components/views/chat/environment-selector.tsx new file mode 100644 index 0000000000..bcf8d337f8 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/chat/environment-selector.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Check, Cloud, Laptop, Loader2, Server, Settings } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { RemoteEnvironmentProfile } from "@/lib/remote-environments"; +import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths"; + +export type EnvironmentSelectorOption = { + id: string; + label: string; + kind: "local" | "remote"; + selected: boolean; +}; + +export type EnvironmentSelectorModel = { + activeKind: "local" | "remote"; + activeLabel: string; + local: EnvironmentSelectorOption; + remotes: EnvironmentSelectorOption[]; +}; + +export type EnvironmentSelectorProps = { + activeEnvironmentId: string; + profiles: RemoteEnvironmentProfile[]; + loading?: boolean; + switchingEnvironmentId?: string | null; + onSelectEnvironment: (environmentId: string) => void | Promise; + onAddSshHost: () => void; +}; + +export function buildEnvironmentSelectorModel( + activeEnvironmentId: string, + profiles: RemoteEnvironmentProfile[], +): EnvironmentSelectorModel { + const remoteById = new Map(); + for (const profile of profiles) { + const id = profile.id?.trim(); + if (!id || remoteById.has(id)) continue; + const selected = id === activeEnvironmentId; + remoteById.set(id, { + id, + label: profile.name.trim() || profile.host.trim() || "SSH host", + kind: "remote", + selected, + }); + } + const remotes = [...remoteById.values()].sort( + (left, right) => + left.label.localeCompare(right.label) || left.id.localeCompare(right.id), + ); + const localSelected = activeEnvironmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID; + const activeRemote = remotes.find((option) => option.selected); + + return { + activeKind: localSelected ? "local" : "remote", + activeLabel: activeRemote?.label ?? (localSelected ? "Local" : "Remote"), + local: { + id: LOCAL_WORKSPACE_ENVIRONMENT_ID, + label: "Local", + kind: "local", + selected: localSelected, + }, + remotes, + }; +} + +export function EnvironmentSelector({ + activeEnvironmentId, + profiles, + loading = false, + switchingEnvironmentId, + onSelectEnvironment, + onAddSshHost, +}: EnvironmentSelectorProps) { + const model = useMemo( + () => buildEnvironmentSelectorModel(activeEnvironmentId, profiles), + [activeEnvironmentId, profiles], + ); + const [internalSwitchingId, setInternalSwitchingId] = useState( + null, + ); + const [open, setOpen] = useState(false); + const pendingEnvironmentId = switchingEnvironmentId ?? internalSwitchingId; + const busy = loading || pendingEnvironmentId !== null; + const ActiveIcon = model.activeKind === "remote" ? Server : Laptop; + + const selectEnvironment = async (environmentId: string) => { + if (busy || environmentId === activeEnvironmentId) return; + setInternalSwitchingId(environmentId); + try { + await onSelectEnvironment(environmentId); + } catch { + // The parent owns connection errors and their user-facing presentation; + // reopen so the failed choice does not strand the user at a closed menu. + setOpen(true); + } finally { + setInternalSwitchingId(null); + } + }; + + const optionStatus = (option: EnvironmentSelectorOption) => { + if (pendingEnvironmentId === option.id) { + return ( + + + Connecting + + ); + } + return null; + }; + + return ( + + + + + + void selectEnvironment(model.local.id)} + > + + {model.local.label} + {optionStatus(model.local)} + {model.local.selected ? : null} + + + + + + Cloud + + Coming soon + + + + +
+ + + Remote + + + + +
+ {model.remotes.length > 0 ? ( + model.remotes.map((option) => ( + void selectEnvironment(option.id)} + > + {option.label} + {optionStatus(option)} + {option.selected ? : null} + + )) + ) : ( + + No SSH hosts saved + + )} +
+
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.test.tsx new file mode 100644 index 0000000000..6932a1dacf --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoteDirectoryPicker } from "./remote-directory-picker"; + +const { invokeMock } = vi.hoisted(() => ({ + invokeMock: vi.fn(), +})); + +vi.mock("@/lib/desktop-client", () => ({ + desktopClient: { invoke: invokeMock }, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + invokeMock.mockReset(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +async function clickButton(text: string): Promise { + const button = [ + ...document.querySelectorAll("button"), + ].find((candidate) => candidate.textContent?.includes(text)); + expect(button).toBeDefined(); + await act(async () => { + button?.click(); + await Promise.resolve(); + }); +} + +describe("RemoteDirectoryPicker", () => { + it("browses from remote home and returns the selected directory", async () => { + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + expect(command).toBe("list_workspace_directories"); + if (args?.path === "/home/pi") { + return { + environmentId: "pi-host", + currentPath: "/home/pi", + parentPath: "/home", + entries: [{ name: "projects", path: "/home/pi/projects" }], + truncated: true, + }; + } + if (args?.path === "/home/pi/projects") { + return { + environmentId: "pi-host", + currentPath: "/srv/projects", + parentPath: "/srv", + entries: [{ name: "cline", path: "/srv/projects/cline" }], + truncated: false, + }; + } + return { + environmentId: "pi-host", + currentPath: String(args?.path), + parentPath: "/srv/projects", + entries: [], + truncated: false, + }; + }, + ); + const onSelect = vi.fn(); + + await act(async () => { + root.render( + , + ); + }); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain("projects"); + expect(document.body.textContent).toContain( + "Only the first directories are shown", + ); + }); + expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", { + environmentId: "pi-host", + path: "/home/pi", + }); + + await clickButton("projects"); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("cline"); + }); + expect(invokeMock).toHaveBeenCalledWith("list_workspace_directories", { + environmentId: "pi-host", + path: "/home/pi/projects", + }); + + await clickButton("cline"); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("/srv/projects/cline"); + }); + await clickButton("Use this folder"); + expect(onSelect).toHaveBeenCalledWith("/srv/projects/cline"); + }); + + it("rejects a directory response from another environment", async () => { + invokeMock.mockResolvedValue({ + environmentId: "other-host", + currentPath: "/home/other", + parentPath: "/home", + entries: [], + truncated: false, + }); + + await act(async () => { + root.render( + , + ); + }); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "Directory response belongs to other-host, not pi-host.", + ); + }); + }); +}); diff --git a/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.tsx b/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.tsx new file mode 100644 index 0000000000..c8d1fb1913 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/chat/remote-directory-picker.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { + ArrowUp, + CircleAlert, + Folder, + Home, + Loader2, + RefreshCw, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { desktopClient } from "@/lib/desktop-client"; + +type WorkspaceDirectoryListResult = { + environmentId: string; + currentPath: string; + parentPath: string | null; + entries: Array<{ name: string; path: string }>; + truncated: boolean; +}; + +function normalizeRemotePath(path: string): string { + const trimmed = path.trim(); + if (trimmed === "/") return trimmed; + return trimmed.replace(/\/+$/, ""); +} + +export function RemoteDirectoryPicker({ + open, + environmentId, + homeDir, + onCancel, + onSelect, +}: { + open: boolean; + environmentId: string; + homeDir: string; + onCancel: () => void; + onSelect: (path: string) => void; +}) { + const normalizedHome = normalizeRemotePath(homeDir) || "/"; + const [currentPath, setCurrentPath] = useState(normalizedHome); + const [requestedPath, setRequestedPath] = useState(normalizedHome); + const [parentPath, setParentPath] = useState(null); + const [directories, setDirectories] = useState< + Array<{ name: string; path: string }> + >([]); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [reloadVersion, setReloadVersion] = useState(0); + + useEffect(() => { + if (!open) return; + setCurrentPath(normalizedHome); + setRequestedPath(normalizedHome); + setParentPath(null); + setDirectories([]); + setTruncated(false); + }, [normalizedHome, open]); + + useEffect(() => { + if (!open) return; + const request = { + environmentId, + path: requestedPath, + reloadVersion, + }; + let cancelled = false; + setLoading(true); + setError(null); + desktopClient + .invoke("list_workspace_directories", { + environmentId: request.environmentId, + path: request.path, + }) + .then((result) => { + if (cancelled) return; + if (result.environmentId !== request.environmentId) { + throw new Error( + `Directory response belongs to ${result.environmentId}, not ${request.environmentId}.`, + ); + } + const canonicalPath = normalizeRemotePath(result.currentPath); + if (!canonicalPath) { + throw new Error("Remote host returned an empty directory path."); + } + setCurrentPath(canonicalPath); + setParentPath( + result.parentPath ? normalizeRemotePath(result.parentPath) : null, + ); + setDirectories( + (result.entries ?? []).filter( + (entry) => entry.name.trim() && normalizeRemotePath(entry.path), + ), + ); + setTruncated(result.truncated === true); + }) + .catch((listError: unknown) => { + if (cancelled) return; + setDirectories([]); + setTruncated(false); + setError( + listError instanceof Error ? listError.message : String(listError), + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [environmentId, open, reloadVersion, requestedPath]); + + const canGoUp = Boolean(parentPath && parentPath !== currentPath); + + return ( + !nextOpen && onCancel()}> + + + Choose remote workspace + + Browse directories on the connected SSH host. No local folders are + shown here. + + + +
+ + +

+ {currentPath} +

+ +
+ +
+ {loading ? ( +
+ + Loading remote directories… +
+ ) : error ? ( +
+ + {error} +
+ ) : directories.length === 0 ? ( +
+ No subdirectories +
+ ) : ( +
+ {directories.map((entry) => { + return ( + + ); + })} +
+ )} +
+ {truncated && !loading && !error ? ( +

+ Only the first directories are shown. Open a folder to continue + browsing. +

+ ) : null} + + + + + +
+
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx index e367861303..0255035d9a 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import type { AgendaTaskRecord } from "@cline/shared"; -import { act } from "react"; +import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceProvider } from "@/contexts/workspace-context"; @@ -55,6 +55,7 @@ async function renderWelcomeScreen({ workspaceRoot, workspaces, gitBranch = "main", + environmentSelector = null, selectChat = vi.fn(async () => true), onListGitBranches = vi.fn(async () => ({ current: "main", @@ -65,6 +66,7 @@ async function renderWelcomeScreen({ workspaceRoot: string; workspaces: string[]; gitBranch?: string | null; + environmentSelector?: ReactNode; selectChat?: () => Promise; onListGitBranches?: () => Promise<{ current: string; @@ -90,6 +92,7 @@ async function renderWelcomeScreen({ body={null} composer={null} gitBranch={gitBranch} + environmentSelector={environmentSelector} onListGitBranches={onListGitBranches} onOpenSession={onOpenSession} onSwitchGitBranch={vi.fn(async () => true)} @@ -117,6 +120,31 @@ async function clickButton( } describe("WelcomeScreen", () => { + it("places the environment selector before the workspace selector", async () => { + await renderWelcomeScreen({ + environmentSelector: ( + + ), + workspaceRoot: "/projects/project-1", + workspaces: ["/projects/project-1"], + }); + + const environmentSelector = container.querySelector( + '[data-testid="environment-selector"]', + ); + const workspaceSelector = container.querySelector( + 'button[title="project-1"]', + ); + expect(environmentSelector).not.toBeNull(); + expect(workspaceSelector).not.toBeNull(); + expect( + environmentSelector?.compareDocumentPosition(workspaceSelector as Node) ?? + 0, + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + it("does not render static prompt suggestions", async () => { await renderWelcomeScreen({ gitBranch: "main", diff --git a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx index edff0c55dc..8e2d14f490 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx @@ -21,6 +21,7 @@ export function WelcomeScreen({ body, composer, notice, + environmentSelector, gitBranch, onListGitBranches, onSwitchGitBranch, @@ -33,6 +34,7 @@ export function WelcomeScreen({ notice?: ReactNode; /** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */ gitBranch: string | null; + environmentSelector: ReactNode; onListGitBranches: () => Promise<{ current: string; branches: string[] }>; onSwitchGitBranch: (branch: string) => Promise; onOpenSession?: (sessionId: string) => void | Promise; @@ -136,7 +138,8 @@ export function WelcomeScreen({

What would you like to build?

-
+
+ {environmentSelector} ({ + invokeMock: vi.fn(), +})); + +vi.mock("@/lib/desktop-client", () => ({ + desktopClient: { invoke: invokeMock }, +})); + +vi.mock("@/components/ui/scroll-area", () => ({ + ScrollArea: ({ children, ...props }: HTMLAttributes) => ( +
{children}
+ ), +})); + +const profile: RemoteEnvironmentProfile = { + id: "build-box", + name: "Build box", + host: "builder.example.com", + user: "ubuntu", + port: 22, + identityFile: "~/.ssh/id_ed25519", +}; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + invokeMock.mockReset(); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +function buttonWithText(text: string): HTMLButtonElement { + const button = [ + ...container.querySelectorAll("button"), + ].find((candidate) => candidate.textContent?.includes(text)); + expect(button).toBeDefined(); + return button as HTMLButtonElement; +} + +function inputById(id: string): HTMLInputElement { + const input = container.querySelector(`#${id}`); + expect(input).not.toBeNull(); + return input as HTMLInputElement; +} + +async function click(element: Element): Promise { + await act(async () => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + await Promise.resolve(); + }); +} + +describe("RemoteEnvironmentsContent", () => { + it("locks a saved profile destination while leaving editable metadata available", async () => { + invokeMock.mockImplementation(async (command: string) => { + if (command === "list_remote_environments") { + return { profiles: [profile], activeProfileId: null }; + } + throw new Error(`Unexpected command: ${command}`); + }); + + await act(async () => { + root.render(); + }); + await vi.waitFor(() => { + expect(inputById("remote-name").value).toBe("Build box"); + }); + + expect(inputById("remote-host").disabled).toBe(true); + expect(inputById("remote-user").disabled).toBe(true); + expect(inputById("remote-port").disabled).toBe(true); + expect(inputById("remote-name").disabled).toBe(false); + expect(inputById("remote-identity").disabled).toBe(false); + expect(container.textContent).toContain( + "Create a new host to change the SSH host, user, or port.", + ); + + await click(buttonWithText("New Host")); + + expect(inputById("remote-host").disabled).toBe(false); + expect(inputById("remote-user").disabled).toBe(false); + expect(inputById("remote-port").disabled).toBe(false); + expect(container.textContent).not.toContain( + "Create a new host to change the SSH host, user, or port.", + ); + }); + + it("keeps settings limited to saving and testing SSH hosts", async () => { + invokeMock.mockImplementation(async (command: string) => { + switch (command) { + case "list_remote_environments": + return { profiles: [profile], activeProfileId: profile.id }; + case "upsert_remote_environment": + return { profile }; + default: + throw new Error(`Unexpected command: ${command}`); + } + }); + + await act(async () => { + root.render(); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain("Build box"); + expect(buttonWithText("Save").disabled).toBe(false); + }); + expect(container.querySelector("#remote-workspace")).toBeNull(); + expect(container.textContent).not.toContain("Connect & Open"); + expect(container.textContent).not.toContain("Disconnect"); + expect(container.textContent).toContain( + "Manage your remote SSH hosts and their configurations.", + ); + expect(container.textContent).toContain( + "Password sign-in is not supported.", + ); + + await click(buttonWithText("Save")); + + await vi.waitFor(() => { + expect(invokeMock).toHaveBeenCalledTimes(2); + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, "upsert_remote_environment", { + profile, + }); + expect(container.textContent).toContain("Connected"); + expect(container.textContent).toContain("Ready"); + }); + + it("keeps a failed SSH test visible on its profile", async () => { + invokeMock.mockImplementation(async (command: string) => { + switch (command) { + case "list_remote_environments": + return { profiles: [profile], activeProfileId: null }; + case "upsert_remote_environment": + return { profile }; + case "test_remote_environment": + throw new Error("Permission denied (publickey)"); + default: + throw new Error(`Unexpected command: ${command}`); + } + }); + + await act(async () => { + root.render(); + }); + await vi.waitFor(() => { + expect(buttonWithText("Test Connection").disabled).toBe(false); + }); + await click(buttonWithText("Test Connection")); + + await vi.waitFor(() => { + expect(container.textContent).toContain("Permission denied (publickey)"); + }); + expect(container.textContent).toContain("Failed"); + expect(invokeMock).toHaveBeenNthCalledWith(3, "test_remote_environment", { + id: profile.id, + }); + }); +}); diff --git a/apps/examples/desktop-app/webview/components/views/settings/remote-environments-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/remote-environments-view.tsx new file mode 100644 index 0000000000..2d2d810722 --- /dev/null +++ b/apps/examples/desktop-app/webview/components/views/settings/remote-environments-view.tsx @@ -0,0 +1,685 @@ +"use client"; + +import { + CheckCircle2, + CircleAlert, + Loader2, + Plug, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { desktopClient } from "@/lib/desktop-client"; +import { + createRemoteEnvironmentDraft, + DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE, + formatRemoteEnvironmentDestination, + normalizeRemoteEnvironmentProfile, + type RemoteEnvironmentDeleteResult, + type RemoteEnvironmentListResult, + type RemoteEnvironmentProfile, + type RemoteEnvironmentRuntimeState, + type RemoteEnvironmentTestResult, + type RemoteEnvironmentUpsertResult, + validateRemoteEnvironmentProfile, +} from "@/lib/remote-environments"; +import { cn } from "@/lib/utils"; +import { PageEmptyState, PageFrame, PageHeader } from "../page-layout"; + +type RemoteAction = "save" | "test" | "delete"; + +type BusyAction = { + action: RemoteAction; + profileId: string; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function profileIdOrThrow(profile: RemoteEnvironmentProfile): string { + if (!profile.id) { + throw new Error("The desktop backend did not return an SSH profile ID."); + } + return profile.id; +} + +function statusLabel(value: string): string { + if (value === "untested") return "Not tested"; + return value + .split("-") + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function StatusBadge({ label, value }: { label: string; value: string }) { + const isPositive = + value === "connected" || value === "passed" || value === "ready"; + const isPending = + value === "connecting" || + value === "disconnecting" || + value === "testing" || + value === "installing"; + const isError = value === "failed" || value === "error"; + + return ( +
+ {label} + + {isPending ? : null} + {isPositive ? : null} + {isError ? : null} + {statusLabel(value)} + +
+ ); +} + +function runtimeStateFor( + states: Record, + profileId: string | undefined, + activeProfileId: string | null, +): RemoteEnvironmentRuntimeState { + if (profileId && states[profileId]) { + return states[profileId]; + } + if (profileId && profileId === activeProfileId) { + return { + ...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE, + bootstrap: "ready", + connection: "connected", + }; + } + return DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE; +} + +export function RemoteEnvironmentsContent() { + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveProfileId] = useState(null); + const [selectedProfileId, setSelectedProfileId] = useState( + null, + ); + const [draft, setDraft] = useState(() => + createRemoteEnvironmentDraft(), + ); + const [runtimeStates, setRuntimeStates] = useState< + Record + >({}); + const [isLoading, setIsLoading] = useState(true); + const [busyAction, setBusyAction] = useState(null); + const [error, setError] = useState(null); + const [formError, setFormError] = useState(null); + const [deleteTarget, setDeleteTarget] = + useState(null); + + const selectedProfile = useMemo( + () => profiles.find((profile) => profile.id === selectedProfileId), + [profiles, selectedProfileId], + ); + const selectedRuntime = runtimeStateFor( + runtimeStates, + selectedProfileId ?? draft.id, + activeProfileId, + ); + const isBusy = isLoading || busyAction !== null; + const hasSavedDestination = Boolean(draft.id); + + const setRuntimeState = useCallback( + ( + profileId: string, + updates: + | Partial + | (( + current: RemoteEnvironmentRuntimeState, + ) => Partial), + ) => { + setRuntimeStates((current) => { + const previous = runtimeStateFor(current, profileId, activeProfileId); + const nextUpdates = + typeof updates === "function" ? updates(previous) : updates; + return { + ...current, + [profileId]: { ...previous, ...nextUpdates }, + }; + }); + }, + [activeProfileId], + ); + + const selectProfile = useCallback((profile: RemoteEnvironmentProfile) => { + setSelectedProfileId(profile.id ?? null); + setDraft(createRemoteEnvironmentDraft(profile)); + setFormError(null); + setError(null); + }, []); + + const startNewProfile = useCallback(() => { + setSelectedProfileId(null); + setDraft(createRemoteEnvironmentDraft()); + setFormError(null); + setError(null); + }, []); + + const loadProfiles = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const result = await desktopClient.invoke( + "list_remote_environments", + ); + setProfiles(result.profiles); + setActiveProfileId(result.activeProfileId); + setRuntimeStates((current) => { + if (!result.activeProfileId) return current; + return { + ...current, + [result.activeProfileId]: { + ...DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE, + ...current[result.activeProfileId], + bootstrap: "ready", + connection: "connected", + }, + }; + }); + + const nextProfile = + result.profiles.find( + (profile) => profile.id === result.activeProfileId, + ) ?? result.profiles[0]; + if (nextProfile) { + setSelectedProfileId(nextProfile.id ?? null); + setDraft(createRemoteEnvironmentDraft(nextProfile)); + } else { + startNewProfile(); + } + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setIsLoading(false); + } + }, [startNewProfile]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + void loadProfiles(); + }, 0); + return () => window.clearTimeout(timeoutId); + }, [loadProfiles]); + + const updateDraft = ( + key: Key, + value: RemoteEnvironmentProfile[Key], + ) => { + setDraft((current) => ({ ...current, [key]: value })); + setFormError(null); + }; + + const upsertLocalProfile = useCallback( + (profile: RemoteEnvironmentProfile) => { + setProfiles((current) => { + const existingIndex = current.findIndex( + (candidate) => candidate.id === profile.id, + ); + if (existingIndex < 0) return [...current, profile]; + return current.map((candidate, index) => + index === existingIndex ? profile : candidate, + ); + }); + setSelectedProfileId(profile.id ?? null); + setDraft(createRemoteEnvironmentDraft(profile)); + }, + [], + ); + + const persistDraft = useCallback( + async (action: RemoteAction): Promise => { + const validationError = validateRemoteEnvironmentProfile(draft); + if (validationError) { + setFormError(validationError); + throw new Error(validationError); + } + const normalized = normalizeRemoteEnvironmentProfile(draft); + setFormError(null); + setError(null); + setBusyAction({ action, profileId: normalized.id ?? "new" }); + const result = await desktopClient.invoke( + "upsert_remote_environment", + { profile: normalized }, + ); + profileIdOrThrow(result.profile); + upsertLocalProfile(result.profile); + return result.profile; + }, + [draft, upsertLocalProfile], + ); + + const saveProfile = async () => { + try { + await persistDraft("save"); + } catch (saveError) { + if (!validateRemoteEnvironmentProfile(draft)) { + setError(errorMessage(saveError)); + } + } finally { + setBusyAction(null); + } + }; + + const testProfile = async () => { + let profile: RemoteEnvironmentProfile; + try { + profile = await persistDraft("test"); + } catch (saveError) { + if (!validateRemoteEnvironmentProfile(draft)) { + setError(errorMessage(saveError)); + } + setBusyAction(null); + return; + } + + const profileId = profileIdOrThrow(profile); + setRuntimeState(profileId, { test: "testing", message: undefined }); + setBusyAction({ action: "test", profileId }); + try { + const result = await desktopClient.invoke( + "test_remote_environment", + { id: profileId }, + ); + setRuntimeState(profileId, { + test: result.status === "failed" ? "failed" : "passed", + message: result.message, + remotePlatform: result.remotePlatform, + remoteArch: result.remoteArch, + }); + } catch (testError) { + setRuntimeState(profileId, { + test: "failed", + message: errorMessage(testError), + }); + } finally { + setBusyAction(null); + } + }; + + const deleteProfile = async (profile: RemoteEnvironmentProfile) => { + const profileId = profileIdOrThrow(profile); + setBusyAction({ action: "delete", profileId }); + setError(null); + try { + await desktopClient.invoke( + "delete_remote_environment", + { id: profileId }, + ); + const remaining = profiles.filter( + (candidate) => candidate.id !== profileId, + ); + setProfiles(remaining); + setRuntimeStates((current) => { + const next = { ...current }; + delete next[profileId]; + return next; + }); + if (activeProfileId === profileId) setActiveProfileId(null); + const nextProfile = remaining[0]; + if (nextProfile) selectProfile(nextProfile); + else startNewProfile(); + } catch (deleteError) { + setError(errorMessage(deleteError)); + } finally { + setBusyAction(null); + setDeleteTarget(null); + } + }; + + return ( + + void loadProfiles()} + variant="ghost" + size="icon-sm" + > + + + } + title="Remote Environments" + description="Manage your remote SSH hosts and their configurations." + /> + + {error ? ( + + + Remote environment error + {error} + + ) : null} + +
+ + + + SSH Hosts + {profiles.length} + + + + + {isLoading ? ( +
+ + Loading SSH hosts… +
+ ) : profiles.length === 0 ? ( + + No SSH hosts yet. Add the address for your first remote + environment. + + ) : ( + profiles.map((profile) => { + const runtime = runtimeStateFor( + runtimeStates, + profile.id, + activeProfileId, + ); + const isSelected = profile.id === selectedProfileId; + const isActive = profile.id === activeProfileId; + return ( + + ); + }) + )} +
+
+ + + +
+ + {selectedProfile?.name ?? "Adding New Host..."} + + {draft.id ? ( + + ) : null} +
+ {hasSavedDestination ? ( + + Create a new host to change the SSH host, user, or port. + + ) : null} +
+ +
+
+ + updateDraft("name", event.target.value)} + placeholder="Build server" + value={draft.name} + /> +
+
+ + updateDraft("host", event.target.value)} + placeholder="dev.example.com or ssh-config-alias" + spellCheck={false} + value={draft.host} + /> +
+
+ + updateDraft("user", event.target.value)} + placeholder="ubuntu" + spellCheck={false} + value={draft.user ?? ""} + /> +
+
+ + + updateDraft( + "port", + event.target.value === "" + ? undefined + : Number(event.target.value), + ) + } + placeholder="22 (from SSH config by default)" + type="number" + value={ + draft.port === undefined || Number.isNaN(draft.port) + ? "" + : draft.port + } + /> +
+
+ +
+ + + updateDraft("identityFile", event.target.value) + } + placeholder="~/.ssh/id_ed25519" + spellCheck={false} + value={draft.identityFile ?? ""} + /> +

+ Password sign-in is not supported. The host key must already be + trusted in your SSH known_hosts file. +

+
+ + {formError ? ( +

{formError}

+ ) : null} + +
+
+
+

Environment status

+
+ +
+
+ + + + +
+ + {selectedRuntime.remotePlatform || selectedRuntime.remoteArch ? ( +

+ Remote:{" "} + {[selectedRuntime.remotePlatform, selectedRuntime.remoteArch] + .filter(Boolean) + .join(" · ")} +

+ ) : null} + {selectedRuntime.message ? ( +

+ {selectedRuntime.message} +

+ ) : null} +
+ +
+
+ +
+
+
+
+
+ + { + if (!open) setDeleteTarget(null); + }} + open={deleteTarget !== null} + > + + + Delete SSH host? + + {deleteTarget + ? `Delete “${deleteTarget.name}” from remote environments? Projects and Cline session data remain on the remote host.` + : "Delete this SSH host from remote environments?"} + + + + Cancel + { + if (deleteTarget) void deleteProfile(deleteTarget); + }} + > + Delete + + + + +
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/views/settings/sections.ts b/apps/examples/desktop-app/webview/components/views/settings/sections.ts index 2d1ffbfc56..489844a62f 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/sections.ts +++ b/apps/examples/desktop-app/webview/components/views/settings/sections.ts @@ -12,6 +12,7 @@ const ALL_SETTINGS_SECTIONS = [ "Channels", "Schedules", "Import", + "Remote", "Account", ] as const; diff --git a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx index dca05b0eba..4c7a86da4c 100644 --- a/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx +++ b/apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx @@ -72,6 +72,7 @@ import { ProviderDetailContent, ProviderListContent, } from "./provider-list-view"; +import { RemoteEnvironmentsContent } from "./remote-environments-view"; import { RoutineSchedulesContent } from "./routine-view"; import type { SettingsSection } from "./sections"; import { toSettingsPatch } from "./settings-patch"; @@ -625,6 +626,8 @@ export function SettingsView({ ) : activeNav === "Import" ? ( + ) : activeNav === "Remote" ? ( + ) : activeNav === "Account" ? ( ) : activeNav === "General" ? ( @@ -640,7 +643,7 @@ export function SettingsView({ ); return ( -
+
{content}
); diff --git a/apps/examples/desktop-app/webview/hooks/chat-session/constants.ts b/apps/examples/desktop-app/webview/hooks/chat-session/constants.ts index 3ee5db6017..1f873bba90 100644 --- a/apps/examples/desktop-app/webview/hooks/chat-session/constants.ts +++ b/apps/examples/desktop-app/webview/hooks/chat-session/constants.ts @@ -2,7 +2,10 @@ import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser"; import type { ChatSessionConfig } from "@/lib/chat-schema"; import { readModelSelectionStorageFromWindow } from "@/lib/model-selection"; import { normalizeProviderId } from "@/lib/provider-id"; -import { readWorkspaceSelectionFromWindow } from "@/lib/workspace-paths"; +import { + LOCAL_WORKSPACE_ENVIRONMENT_ID, + readWorkspaceSelectionFromWindow, +} from "@/lib/workspace-paths"; export const CHAT_TRANSPORT_UNAVAILABLE_MESSAGE = "Chat connection is unavailable. Reopen the app window to restore realtime chat."; @@ -17,6 +20,7 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = { sessionId: undefined, workspaceRoot: "", cwd: "", + environmentId: LOCAL_WORKSPACE_ENVIRONMENT_ID, provider: "cline", model: CLINE_DEFAULT_MODEL_ID, apiKey: process.env.CLINE_API_KEY || "", @@ -31,9 +35,9 @@ export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = { missionTimeIntervalMs: undefined, }; -export function getInitialChatConfig(): ChatSessionConfig { +export function getInitialChatConfig(environmentId: string): ChatSessionConfig { const selection = readModelSelectionStorageFromWindow(); - const workspaceSelection = readWorkspaceSelectionFromWindow(); + const workspaceSelection = readWorkspaceSelectionFromWindow(environmentId); const rememberedProvider = normalizeProviderId(selection.lastProvider); const rememberedModelForProvider = rememberedProvider ? (selection.lastModelByProvider[rememberedProvider] ?? @@ -51,6 +55,7 @@ export function getInitialChatConfig(): ChatSessionConfig { return { ...DEFAULT_CHAT_CONFIG, + environmentId, provider, model, workspaceRoot: workspaceSelection.lastWorkspace, diff --git a/apps/examples/desktop-app/webview/hooks/chat-session/types.ts b/apps/examples/desktop-app/webview/hooks/chat-session/types.ts index a92ea92eeb..aa7c604649 100644 --- a/apps/examples/desktop-app/webview/hooks/chat-session/types.ts +++ b/apps/examples/desktop-app/webview/hooks/chat-session/types.ts @@ -1,11 +1,21 @@ import type { SessionHookEvent } from "@/lib/session-diff"; export type ProcessContext = { + environmentId: string; workspaceRoot: string; cwd: string; homeDir?: string; platform?: string; appVersion?: string; + activeEnvironmentId?: string; + remoteEnvironment?: { + id: string; + name?: string; + host?: string; + workspaceRoot?: string; + platform?: string; + arch?: string; + } | null; }; export type AgentChunkEvent = { @@ -89,9 +99,11 @@ export type ChatApiResult = { text: string; inputTokens?: number; outputTokens?: number; + cacheReadTokens?: number; usage?: { inputTokens?: number; outputTokens?: number; + cacheReadTokens?: number; totalCost?: number; }; iterations?: number; @@ -112,6 +124,7 @@ export type ChatApiResult = { }; export type ChatSessionCommandResponse = { + environmentId?: string; sessionId?: string; cwd?: string; workspaceRoot?: string; diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx index 21c49d27c2..9197423d50 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx @@ -39,8 +39,8 @@ let container: HTMLDivElement; let root: Root; let current: ChatSessionHook; -function HookHarness() { - current = useChatSession(); +function HookHarness({ environmentId = "local" }: { environmentId?: string }) { + current = useChatSession(environmentId); return null; } @@ -70,7 +70,11 @@ beforeEach(async () => { subscribeMock.mockClear(); invokeMock.mockImplementation(async (command: string) => { if (command === "get_process_context") { - return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + return { + environmentId: "local", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + }; } return []; }); @@ -84,6 +88,29 @@ afterEach(async () => { }); describe("useChatSession", () => { + it("ignores another environment's question for the same session", async () => { + invokeMock.mockImplementation(async (command: string) => + command === "chat_session_command" ? { sessionId: "same-id" } : [], + ); + await act(async () => current.start(current.config)); + const handler = handlerFor("ask_question_requested"); + const question = { + sessionId: "same-id", + requestId: "question-id", + question: "Which branch?", + options: [], + createdAt: new Date().toISOString(), + }; + await act(async () => { + handler({ ...question, environmentId: "remote" }); + }); + expect(current.pendingAskQuestions).toEqual([]); + await act(async () => { + handler({ ...question, environmentId: "local" }); + }); + expect(current.pendingAskQuestions).toHaveLength(1); + }); + it("sends first-prompt steering intent without reading a queue snapshot", async () => { const requests: Record[] = []; invokeMock.mockImplementation( @@ -99,7 +126,11 @@ describe("useChatSession", () => { requests.length = 0; await act(async () => current.steerPromptInQueue()); expect(requests).toEqual([ - { action: "steer_prompt", sessionId: "atomic-steer" }, + { + action: "steer_prompt", + sessionId: "atomic-steer", + config: { environmentId: "local" }, + }, ]); }); @@ -163,7 +194,9 @@ describe("useChatSession", () => { }); expect( requests.filter((request) => request.action === "steer_prompt"), - ).toEqual([{ action: "steer_prompt", sessionId }]); + ).toEqual([ + { action: "steer_prompt", sessionId, config: { environmentId: "local" } }, + ]); expect(current.status).toBe("running"); await act(async () => { activeResponse.resolve({ ok: true }); @@ -227,7 +260,13 @@ describe("useChatSession", () => { }); expect( requests.filter((request) => request.action === "steer_prompt"), - ).toEqual([{ action: "steer_prompt", sessionId }]); + ).toEqual([ + { + action: "steer_prompt", + sessionId, + config: { environmentId: "local" }, + }, + ]); await act(async () => { activeResponse.resolve({ ok: true }); await activeTask; @@ -751,12 +790,19 @@ describe("useChatSession", () => { current.proceedWhileRunning(current.sessionId as string, "call-output"), ); expect(invokeMock).toHaveBeenCalledWith("proceed_while_running", { + environmentId: "local", sessionId: current.sessionId, toolCallId: "call-output", }); }); - it("heals a running attached session with a dead event stream by polling history", async () => { + it.each([ + "local", + "remote", + ])("heals a running attached session in %s with a dead event stream by polling history", async (environmentId) => { + await act(async () => + root.render(), + ); // Scheduled runs can execute on a host whose live events never reach // this client; the transcript must still settle without a remount. const hydratedSessionId = "session-dead-stream"; @@ -768,6 +814,7 @@ describe("useChatSession", () => { return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; } if (command === "read_session_messages") { + expect(args?.environmentId).toBe(environmentId); readCount += 1; const base = [ { @@ -792,6 +839,7 @@ describe("useChatSession", () => { ]; } if (command === "get_discovered_session") { + expect(args?.environmentId).toBe(environmentId); recordReads += 1; // Still running on the first poll — the snapshot already // ends on assistant narration, which must NOT read as @@ -826,6 +874,7 @@ describe("useChatSession", () => { try { await act(async () => { await current.hydrateSession({ + environmentId, sessionId: hydratedSessionId, status: "running", provider: "cline", @@ -2348,6 +2397,23 @@ describe("useChatSession", () => { ).not.toBeUndefined(); }); + it("rejects hydration for a session owned by another environment", async () => { + await expect( + current.hydrateSession({ + sessionId: "remote-session", + environmentId: "pi-server", + status: "completed", + provider: "cline", + model: "test-model", + cwd: "/home/pi/project", + workspaceRoot: "/home/pi/project", + startedAt: "2026-07-31T00:00:00.000Z", + }), + ).rejects.toThrow("belongs to environment pi-server, not local"); + expect(current.sessionId).toBeNull(); + expect(current.config.environmentId).toBe("local"); + }); + it("preserves consecutive queued costs while the preceding turn is persisted", async () => { type SendResponse = { ok: true; @@ -2554,6 +2620,7 @@ describe("useChatSession", () => { await act(async () => { await current.hydrateSession({ sessionId: hydratedSessionId, + environmentId: "local", status: "completed", provider: "cline", model: "test-model", @@ -2569,6 +2636,21 @@ describe("useChatSession", () => { cacheReadTokens: 8_000, }); expect(current.summary.totalCostUsd).toBeCloseTo(0.03); + expect(invokeMock).toHaveBeenCalledWith("read_session_messages", { + environmentId: "local", + sessionId: hydratedSessionId, + maxMessages: 800, + }); + expect(invokeMock).toHaveBeenCalledWith( + "chat_session_command", + expect.objectContaining({ + request: expect.objectContaining({ + action: "attach", + config: expect.objectContaining({ environmentId: "local" }), + sessionId: hydratedSessionId, + }), + }), + ); }); it("restores a pending question when switching to its session", async () => { @@ -2639,6 +2721,7 @@ describe("useChatSession", () => { expect(current.pendingAskQuestions).toEqual([pendingQuestion]), ); expect(invokeMock).toHaveBeenCalledWith("poll_ask_questions", { + environmentId: "local", sessionId: hydratedSessionId, }); }); @@ -2809,6 +2892,7 @@ describe("useChatSession", () => { return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; } if (command === "read_session_messages") { + expect(args?.environmentId).toBe("local"); return canonicalMessages; } if (command === "chat_session_command") { @@ -3964,18 +4048,26 @@ describe("useChatSession", () => { it("falls back to process context when the remembered workspace is stale", async () => { await act(async () => root.unmount()); window.localStorage.setItem( - "cline.code.workspace-selection.v1", + "cline.code.workspace-selection.v2", JSON.stringify({ - lastWorkspace: "/workspace/deleted", - workspaces: ["/workspace/deleted"], + environments: { + local: { + lastWorkspace: "/workspace/deleted", + workspaces: ["/workspace/deleted"], + }, + }, }), ); invokeMock.mockImplementation(async (command: string) => { if (command === "get_process_context") { - return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" }; + return { + environmentId: "local", + cwd: "/workspace/cline", + workspaceRoot: "/workspace/cline", + }; } if (command === "validate_workspace_directory") { - return { valid: false }; + return { environmentId: "local", valid: false }; } return []; }); @@ -3987,16 +4079,74 @@ describe("useChatSession", () => { expect(current.config.cwd).toBe("/workspace/cline"); }); expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", { + environmentId: "local", path: "/workspace/deleted", }); }); + it("binds process context and remembered workspace to the requested remote environment", async () => { + await act(async () => root.unmount()); + window.localStorage.setItem( + "cline.code.workspace-selection.v2", + JSON.stringify({ + environments: { + local: { + lastWorkspace: "/Users/local/project", + workspaces: ["/Users/local/project"], + }, + "pi-server": { + lastWorkspace: "/home/pi/project", + workspaces: ["/home/pi/project"], + }, + }, + }), + ); + invokeMock.mockImplementation( + async (command: string, args?: Record) => { + if (command === "get_process_context") { + expect(args).toEqual({ environmentId: "pi-server" }); + return { + environmentId: "pi-server", + activeEnvironmentId: "another-host", + cwd: "/home/pi", + workspaceRoot: "/home/pi", + }; + } + if (command === "validate_workspace_directory") { + return { environmentId: "pi-server", valid: true }; + } + return []; + }, + ); + root = createRoot(container); + await act(async () => + root.render(), + ); + + await vi.waitFor(() => { + expect(current.config).toMatchObject({ + environmentId: "pi-server", + cwd: "/home/pi/project", + workspaceRoot: "/home/pi/project", + }); + }); + expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", { + environmentId: "pi-server", + path: "/home/pi/project", + }); + }); + it("applies a remembered workspace that becomes available while process context is loading", async () => { await act(async () => root.unmount()); let resolveContext: - | ((value: { cwd: string; workspaceRoot: string }) => void) + | ((value: { + environmentId: string; + cwd: string; + workspaceRoot: string; + }) => void) | undefined; const contextResponse = new Promise<{ + environmentId: string; cwd: string; workspaceRoot: string; }>((resolve) => { @@ -4007,25 +4157,32 @@ describe("useChatSession", () => { return await contextResponse; } if (command === "validate_workspace_directory") { - return { valid: true }; + return { environmentId: "local", valid: true }; } return []; }); root = createRoot(container); await act(async () => root.render()); await vi.waitFor(() => { - expect(invokeMock).toHaveBeenCalledWith("get_process_context"); + expect(invokeMock).toHaveBeenCalledWith("get_process_context", { + environmentId: "local", + }); }); window.localStorage.setItem( - "cline.code.workspace-selection.v1", + "cline.code.workspace-selection.v2", JSON.stringify({ - lastWorkspace: "/workspace/remembered", - workspaces: ["/workspace/remembered"], + environments: { + local: { + lastWorkspace: "/workspace/remembered", + workspaces: ["/workspace/remembered"], + }, + }, }), ); await act(async () => { resolveContext?.({ + environmentId: "local", cwd: "/workspace/default", workspaceRoot: "/workspace/default", }); @@ -4037,6 +4194,7 @@ describe("useChatSession", () => { expect(current.config.cwd).toBe("/workspace/remembered"); }); expect(invokeMock).toHaveBeenCalledWith("validate_workspace_directory", { + environmentId: "local", path: "/workspace/remembered", }); }); @@ -4044,9 +4202,14 @@ describe("useChatSession", () => { it("preserves a workspace selected while process context is loading", async () => { await act(async () => root.unmount()); let resolveContext: - | ((value: { cwd: string; workspaceRoot: string }) => void) + | ((value: { + environmentId: string; + cwd: string; + workspaceRoot: string; + }) => void) | undefined; const contextResponse = new Promise<{ + environmentId: string; cwd: string; workspaceRoot: string; }>((resolve) => { @@ -4064,6 +4227,7 @@ describe("useChatSession", () => { await act(async () => { resolveContext?.({ + environmentId: "local", cwd: "/workspace/default", workspaceRoot: "/workspace/default", }); @@ -4076,9 +4240,14 @@ describe("useChatSession", () => { it("preserves a chat selection while process context is loading", async () => { await act(async () => root.unmount()); let resolveContext: - | ((value: { cwd: string; workspaceRoot: string }) => void) + | ((value: { + environmentId: string; + cwd: string; + workspaceRoot: string; + }) => void) | undefined; const contextResponse = new Promise<{ + environmentId: string; cwd: string; workspaceRoot: string; }>((resolve) => { @@ -4096,6 +4265,7 @@ describe("useChatSession", () => { await act(async () => { resolveContext?.({ + environmentId: "local", cwd: "/workspace/default", workspaceRoot: "/workspace/default", }); diff --git a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts index 18164d724f..93dc07be85 100644 --- a/apps/examples/desktop-app/webview/hooks/use-chat-session.ts +++ b/apps/examples/desktop-app/webview/hooks/use-chat-session.ts @@ -57,8 +57,10 @@ import type { SessionHistoryItem, SessionHistoryStatus, } from "@/lib/session-history"; +import { eventEnvironmentId } from "@/lib/session-identity"; import { readImportedHistorySummaryActivity } from "@/lib/session-import"; import { + LOCAL_WORKSPACE_ENVIRONMENT_ID, normalizeWorkspacePath, readWorkspaceSelectionFromWindow, registerHostHomeDirectory, @@ -376,11 +378,20 @@ function dispatchCoreLog(chunk: string): void { // Hook // --------------------------------------------------------------------------- -export function useChatSession() { +export function useChatSession(environmentId: string) { + const subscribeToEnvironment = useCallback( + (name: string, listener: (payload: unknown) => void) => + desktopClient.subscribe(name, (payload) => { + if (eventEnvironmentId(payload) === environmentId) listener(payload); + }), + [environmentId], + ); const [sessionId, setSessionId] = useState(null); const [status, setStatus] = useState("idle"); const [isHydratingSession, setIsHydratingSession] = useState(false); - const [config, setConfig] = useState(getInitialChatConfig); + const [config, setConfig] = useState(() => + getInitialChatConfig(environmentId), + ); const [messages, setMessages] = useState([]); const [rawTranscript, setRawTranscript] = useState(""); const [error, setError] = useState(null); @@ -750,6 +761,7 @@ export function useChatSession() { turnEndReconcileTimerRef.current = null; void desktopClient .invoke("read_session_messages", { + environmentId, sessionId: sid, maxMessages: MAX_MESSAGES, }) @@ -778,7 +790,7 @@ export function useChatSession() { }); }, TURN_END_RECONCILE_DELAY_MS); }, - [applyCanonicalHistory], + [applyCanonicalHistory, environmentId], ); useEffect(() => { @@ -791,20 +803,34 @@ export function useChatSession() { // ---- Data fetching ---- - const postSession = useCallback(async (body: Record) => { - const request = { request: body }; - if (body.action === "send") { + const postSession = useCallback( + async (body: Record) => { + const bodyConfig = + body.config && + typeof body.config === "object" && + !Array.isArray(body.config) + ? (body.config as Record) + : {}; + const request = { + request: { + ...body, + config: { ...bodyConfig, environmentId }, + }, + }; + if (body.action === "send") { + return await desktopClient.invoke( + "chat_session_command", + request, + { timeoutMs: null }, + ); + } return await desktopClient.invoke( "chat_session_command", request, - { timeoutMs: null }, ); - } - return await desktopClient.invoke( - "chat_session_command", - request, - ); - }, []); + }, + [environmentId], + ); // Confirms a "still running because prompts are queued" status against the // server. The local queue snapshot can be stale when the dequeue @@ -880,7 +906,7 @@ export function useChatSession() { try { const events = await desktopClient.invoke( "read_session_hooks", - { sessionId: targetSessionId, limit: MAX_MESSAGES }, + { environmentId, sessionId: targetSessionId, limit: MAX_MESSAGES }, ); const diffState = buildSessionDiffState(events, sessionDiffCwd); setFileDiffs(diffState.fileDiffs); @@ -895,7 +921,7 @@ export function useChatSession() { // Ignore in non-Tauri mode. } }, - [sessionDiffCwd], + [environmentId, sessionDiffCwd], ); // ---- Message helpers ---- @@ -1124,19 +1150,33 @@ export function useChatSession() { try { const ctx = await desktopClient.invoke( "get_process_context", + { environmentId }, ); + if (ctx.environmentId !== environmentId) { + return; + } if (ctx.homeDir) { registerHostHomeDirectory(ctx.homeDir); } const rememberedWorkspace = - readWorkspaceSelectionFromWindow().lastWorkspace; + readWorkspaceSelectionFromWindow(environmentId).lastWorkspace; const validation = rememberedWorkspace ? await desktopClient - .invoke<{ valid?: boolean }>("validate_workspace_directory", { - path: rememberedWorkspace, - }) + .invoke<{ environmentId: string; valid: boolean }>( + "validate_workspace_directory", + { + environmentId, + path: rememberedWorkspace, + }, + ) .catch(() => ({ valid: false })) : { valid: false }; + if ( + "environmentId" in validation && + validation.environmentId !== environmentId + ) { + return; + } if (requestId !== workspaceSelectionRequestRef.current) { return; } @@ -1154,6 +1194,7 @@ export function useChatSession() { : ctx.workspaceRoot || ctx.cwd; return { ...prev, + environmentId, workspaceRoot: workspace, cwd: workspace, }; @@ -1161,7 +1202,7 @@ export function useChatSession() { } catch { // Ignore in non-Tauri mode. } - }, []); + }, [environmentId]); useEffect(() => { void applyProcessContext(); @@ -1242,6 +1283,7 @@ export function useChatSession() { void desktopClient .invoke("poll_tool_approvals", { + environmentId, sessionId: activeSessionId, limit: 20, }) @@ -1254,6 +1296,7 @@ export function useChatSession() { void desktopClient .invoke("poll_ask_questions", { + environmentId, sessionId: activeSessionId, }) .then((pending) => { @@ -1263,7 +1306,7 @@ export function useChatSession() { }) .catch(() => {}); - const unsubscribe = desktopClient.subscribe( + const unsubscribe = subscribeToEnvironment( "tool_approval_state", (payload) => { if (!payload || typeof payload !== "object") return; @@ -1282,10 +1325,10 @@ export function useChatSession() { cancelled = true; unsubscribe(); }; - }, [sessionId]); + }, [environmentId, sessionId, subscribeToEnvironment]); useEffect(() => { - return desktopClient.subscribe("ask_question_requested", (payload) => { + return subscribeToEnvironment("ask_question_requested", (payload) => { if (!payload || typeof payload !== "object") return; const item = payload as AskQuestionRequestItem; if ( @@ -1303,10 +1346,10 @@ export function useChatSession() { return [...prev, item]; }); }); - }, []); + }, [subscribeToEnvironment]); useEffect(() => { - return desktopClient.subscribe("ask_question_answered", (payload) => { + return subscribeToEnvironment("ask_question_answered", (payload) => { if (!payload || typeof payload !== "object") return; const requestId = String( (payload as { requestId?: unknown }).requestId ?? "", @@ -1316,10 +1359,10 @@ export function useChatSession() { prev.filter((item) => item.requestId !== requestId), ); }); - }, []); + }, [subscribeToEnvironment]); useEffect(() => { - return desktopClient.subscribe("ask_question_cancelled", (payload) => { + return subscribeToEnvironment("ask_question_cancelled", (payload) => { if (!payload || typeof payload !== "object") return; const requestId = String( (payload as { requestId?: unknown }).requestId ?? "", @@ -1329,10 +1372,10 @@ export function useChatSession() { prev.filter((item) => item.requestId !== requestId), ); }); - }, []); + }, [subscribeToEnvironment]); useEffect(() => { - return desktopClient.subscribe("prompts_in_queue_state", (payload) => { + return subscribeToEnvironment("prompts_in_queue_state", (payload) => { if (!payload || typeof payload !== "object") return; const record = payload as { sessionId?: string; @@ -1341,7 +1384,7 @@ export function useChatSession() { if (record.sessionId !== activeSessionIdRef.current) return; setPromptsInQueue(Array.isArray(record.items) ? record.items : []); }); - }, [setPromptsInQueue]); + }, [setPromptsInQueue, subscribeToEnvironment]); // ---- Incoming chunk handler ---- @@ -1872,7 +1915,7 @@ export function useChatSession() { setChatTransportError(desktopClient.getTransportError()); }, ); - const unsubscribeEvents = desktopClient.subscribe( + const unsubscribeEvents = subscribeToEnvironment( "chat_event", (payload) => { if (payload && typeof payload === "object") { @@ -1884,10 +1927,10 @@ export function useChatSession() { unsubscribeTransport(); unsubscribeEvents(); }; - }, [handleIncomingChunk]); + }, [handleIncomingChunk, subscribeToEnvironment]); useEffect(() => { - const unsubscribeStatus = desktopClient.subscribe( + const unsubscribeStatus = subscribeToEnvironment( "chat_session_status", (payload) => { if (!payload || typeof payload !== "object") { @@ -1939,7 +1982,7 @@ export function useChatSession() { setStatus(nextStatus as ChatSessionStatus); }, ); - const unsubscribeEnded = desktopClient.subscribe( + const unsubscribeEnded = subscribeToEnvironment( "chat_session_ended", (payload) => { if (!payload || typeof payload !== "object") { @@ -1969,7 +2012,7 @@ export function useChatSession() { unsubscribeStatus(); unsubscribeEnded(); }; - }, [clearLiveToolRefs, finalizeSettledTurn]); + }, [clearLiveToolRefs, finalizeSettledTurn, subscribeToEnvironment]); // ---- Stale-stream fallback for attached sessions ---- // Scheduled/automation runs execute on a session host whose events are @@ -2022,12 +2065,14 @@ export function useChatSession() { const [historyMessages, record] = await Promise.all([ desktopClient .invoke("read_session_messages", { + environmentId, sessionId, maxMessages: MAX_MESSAGES, }) .catch(() => null), desktopClient .invoke<{ status?: string } | null>("get_discovered_session", { + environmentId, sessionId, }) .catch(() => null), @@ -2089,7 +2134,7 @@ export function useChatSession() { cancelled = true; window.clearInterval(interval); }; - }, [hydratedHistorySessionId, sessionId, status]); + }, [hydratedHistorySessionId, sessionId, status, environmentId]); // ---- Shared: start a new session via RPC ---- @@ -2098,16 +2143,25 @@ export function useChatSession() { validatedConfig: ChatSessionConfig, options: { preserveStatus?: boolean } = {}, ): Promise => { + const boundConfig = { ...validatedConfig, environmentId }; const payload = await postSession({ action: "start", - config: validatedConfig, + config: boundConfig, }); + if ( + payload.environmentId !== undefined && + payload.environmentId !== environmentId + ) { + throw new Error( + `Session started in environment ${payload.environmentId}, not ${environmentId}.`, + ); + } const id = payload.sessionId; if (!id) throw new Error("Missing session id from server"); const workspaceRoot = - payload.workspaceRoot?.trim() || validatedConfig.workspaceRoot.trim(); + payload.workspaceRoot?.trim() || boundConfig.workspaceRoot.trim(); const cwd = - payload.cwd?.trim() || validatedConfig.cwd?.trim() || workspaceRoot; + payload.cwd?.trim() || boundConfig.cwd?.trim() || workspaceRoot; if (!workspaceRoot || !cwd) { throw new Error("Missing resolved workspace from server"); } @@ -2120,21 +2174,21 @@ export function useChatSession() { } workspaceSelectionRequestRef.current += 1; setConfig({ - ...validatedConfig, + ...boundConfig, cwd, workspaceRoot, }); setHydratedHistorySessionId(null); return id; }, - [postSession], + [environmentId, postSession], ); // ---- Actions ---- const start = useCallback( async (nextConfig: ChatSessionConfig) => { - const validation = validateConfig(nextConfig); + const validation = validateConfig({ ...nextConfig, environmentId }); if (!validation.parsed) { setErrorState(validation.error); return; @@ -2171,6 +2225,7 @@ export function useChatSession() { addMessage, clearAbortFallbackTimeout, discardPendingStream, + environmentId, resetCounters, setErrorState, startSession, @@ -2193,7 +2248,7 @@ export function useChatSession() { const pendingSessionStart = sessionStartPromiseRef.current; let activeSessionId = sessionId ?? activeSessionIdRef.current; - const validation = validateConfig(config); + const validation = validateConfig({ ...config, environmentId }); if (!validation.parsed) { setErrorState(validation.error, activeSessionId); return false; @@ -2652,7 +2707,11 @@ export function useChatSession() { try { const historyMessages = await desktopClient.invoke( "read_session_messages", - { sessionId: activeSessionId, maxMessages: MAX_MESSAGES }, + { + environmentId, + sessionId: activeSessionId, + maxMessages: MAX_MESSAGES, + }, ); if (historyMessages.length > 0 && !newerTurnOwnsTranscript()) { applyCanonicalHistory(activeSessionId, historyMessages); @@ -2676,7 +2735,11 @@ export function useChatSession() { try { const historyMessages = await desktopClient.invoke( "read_session_messages", - { sessionId: activeSessionId, maxMessages: MAX_MESSAGES }, + { + environmentId, + sessionId: activeSessionId, + maxMessages: MAX_MESSAGES, + }, ); const hasCanonicalAssistantTurn = historyMessages.some( (message) => message.role === "assistant", @@ -2879,6 +2942,7 @@ export function useChatSession() { clearLiveToolRefs, config, finalizeSettledTurn, + environmentId, hydratedHistorySessionId, materializeToolMessagesFromResult, refreshSessionDiffSummary, @@ -2897,6 +2961,7 @@ export function useChatSession() { const activeSessionId = activeSessionIdRef.current; if (!activeSessionId) return; await desktopClient.invoke("respond_tool_approval", { + environmentId, sessionId: activeSessionId, requestId, approved, @@ -2908,7 +2973,7 @@ export function useChatSession() { prev.filter((item) => item.requestId !== requestId), ); }, - [], + [environmentId], ); const approveToolApproval = useCallback( @@ -2924,6 +2989,7 @@ export function useChatSession() { const answerAskQuestion = useCallback( async (requestId: string, answer: string) => { await desktopClient.invoke("respond_ask_question", { + environmentId, requestId, answer, }); @@ -2931,7 +2997,7 @@ export function useChatSession() { prev.filter((item) => item.requestId !== requestId), ); }, - [], + [environmentId], ); const restoreCheckpoint = useCallback( @@ -2968,13 +3034,13 @@ export function useChatSession() { throw new Error("Checkpoint restore did not return a new session id"); } - const nextMessages = await desktopClient.invoke( - "read_session_messages", - { - sessionId: nextSessionId, - maxMessages: MAX_MESSAGES, - }, - ); + const nextMessages = Array.isArray(payload.messages) + ? (payload.messages as ChatMessage[]) + : await desktopClient.invoke("read_session_messages", { + environmentId, + sessionId: nextSessionId, + maxMessages: MAX_MESSAGES, + }); setSessionId(nextSessionId); activeSessionIdRef.current = nextSessionId; @@ -2989,6 +3055,7 @@ export function useChatSession() { clearAbortFallbackTimeout, clearLiveToolRefs, config, + environmentId, postSession, refreshPromptsInQueue, refreshSessionDiffSummary, @@ -3046,6 +3113,7 @@ export function useChatSession() { const response = await desktopClient.invoke<{ detachedCount?: number }>( "proceed_while_running", { + environmentId, sessionId: normalizedSessionId, ...(toolCallId ? { toolCallId } : {}), }, @@ -3054,7 +3122,7 @@ export function useChatSession() { throw new Error("The command finished before it could be detached."); } }, - [], + [environmentId], ); const reset = useCallback(async () => { @@ -3117,6 +3185,14 @@ export function useChatSession() { const hydrateSession = useCallback( async (session: SessionHistoryItem) => { + if ( + (session.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID) !== + environmentId + ) { + throw new Error( + `Session ${session.sessionId} belongs to environment ${session.environmentId}, not ${environmentId}.`, + ); + } const requestId = hydrationRequestIdRef.current + 1; const hydrationStartedAt = Date.now(); hydrationRequestIdRef.current = requestId; @@ -3129,6 +3205,7 @@ export function useChatSession() { setSessionId(session.sessionId); setConfig((prev) => ({ ...prev, + environmentId, sessionId: session.sessionId, provider: session.provider || prev.provider, model: session.model || prev.model, @@ -3180,7 +3257,11 @@ export function useChatSession() { try { const historyMessages = await desktopClient.invoke( "read_session_messages", - { sessionId: session.sessionId, maxMessages: MAX_MESSAGES }, + { + environmentId, + sessionId: session.sessionId, + maxMessages: MAX_MESSAGES, + }, ); if (hydrationRequestIdRef.current !== requestId) return; if (historyMessages.length > 0) { @@ -3198,11 +3279,13 @@ export function useChatSession() { cwd?: string; workspaceRoot?: string; prompt?: string; + environmentId?: string; }>("chat_session_command", { request: { action: "attach", sessionId: session.sessionId, config: { + environmentId, provider: session.provider, model: session.model, cwd: session.cwd, @@ -3218,8 +3301,17 @@ export function useChatSession() { return undefined; }); if (hydrationRequestIdRef.current !== requestId) return; + if ( + attached?.environmentId !== undefined && + attached.environmentId !== environmentId + ) { + throw new Error( + `Session ${session.sessionId} attached to environment ${attached.environmentId}, not ${environmentId}.`, + ); + } setConfig((prev) => ({ ...prev, + environmentId, sessionId: session.sessionId, provider: attached?.provider || session.provider || prev.provider, model: attached?.model || session.model || prev.model, @@ -3276,6 +3368,7 @@ export function useChatSession() { clearAbortFallbackTimeout, clearLiveToolRefs, discardPendingStream, + environmentId, refreshPromptsInQueue, refreshSessionDiffSummary, resetStreamDedupe, @@ -3317,16 +3410,16 @@ export function useChatSession() { typeof payload.forkedFromSessionId === "string" ? payload.forkedFromSessionId : activeSessionId; - const nextMessages = await desktopClient.invoke( - "read_session_messages", - { - sessionId: newSessionId, - maxMessages: MAX_MESSAGES, - }, - ); + const nextMessages = Array.isArray(payload.messages) + ? (payload.messages as ChatMessage[]) + : await desktopClient.invoke("read_session_messages", { + environmentId, + sessionId: newSessionId, + maxMessages: MAX_MESSAGES, + }); return { newSessionId, forkedFromSessionId, messages: nextMessages }; }, - [config, postSession, status], + [config, environmentId, postSession, status], ); const steerPromptInQueue = useCallback( diff --git a/apps/examples/desktop-app/webview/hooks/use-session-agents.test.tsx b/apps/examples/desktop-app/webview/hooks/use-session-agents.test.tsx index a3455b7a89..327935d157 100644 --- a/apps/examples/desktop-app/webview/hooks/use-session-agents.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-session-agents.test.tsx @@ -18,15 +18,22 @@ let root: Root; let current: SessionAgentsHook; function HookHarness({ + environmentId = "local", sessionId, panelOpen = false, sessionActive = false, }: { + environmentId?: string; sessionId: string | null; panelOpen?: boolean; sessionActive?: boolean; }) { - current = useSessionAgents({ sessionId, panelOpen, sessionActive }); + current = useSessionAgents({ + environmentId, + sessionId, + panelOpen, + sessionActive, + }); return null; } @@ -76,6 +83,7 @@ describe("useSessionAgents", () => { invokeMock.mockResolvedValue([agentRow("a", "one")]); await render({ sessionId: "a" }); expect(invokeMock).toHaveBeenCalledWith("list_session_agents", { + environmentId: "local", sessionId: "a", }); expect(current.agents.map((agent) => agent.agentId)).toEqual(["one"]); @@ -90,6 +98,7 @@ describe("useSessionAgents", () => { invokeMock.mockResolvedValue([agentRow("a", "aged-out")]); await render({ sessionId: "a", panelOpen: false, sessionActive: false }); expect(invokeMock).toHaveBeenCalledWith("list_session_agents", { + environmentId: "local", sessionId: "a", }); expect(current.agents.map((agent) => agent.agentId)).toEqual(["aged-out"]); diff --git a/apps/examples/desktop-app/webview/hooks/use-session-agents.ts b/apps/examples/desktop-app/webview/hooks/use-session-agents.ts index c2291d81a1..52419f145b 100644 --- a/apps/examples/desktop-app/webview/hooks/use-session-agents.ts +++ b/apps/examples/desktop-app/webview/hooks/use-session-agents.ts @@ -12,6 +12,7 @@ const ACTIVE_POLL_INTERVAL_MS = 2500; * structurally unreadable rather than something a reset has to remember to clear. */ type RosterState = { + environmentId: string | null; sessionId: string | null; entries: SessionAgentEntry[]; loading: boolean; @@ -19,6 +20,7 @@ type RosterState = { }; const EMPTY_ROSTER: RosterState = { + environmentId: null, sessionId: null, entries: [], loading: false, @@ -81,10 +83,12 @@ function parseAgentEntries(value: unknown): SessionAgentEntry[] { * part worth gating, since it is the only part that costs anything repeatedly. */ export function useSessionAgents({ + environmentId, sessionId, sessionActive, panelOpen = false, }: { + environmentId: string; sessionId: string | null; sessionActive: boolean; /** Re-reads when the roster is put on screen; never gates the first read. */ @@ -106,9 +110,11 @@ export function useSessionAgents({ const seq = requestSeqRef.current; if (!options?.quiet) { setRoster((prev) => + prev.environmentId === environmentId && prev.sessionId === targetSessionId ? { ...prev, loading: true } : { + environmentId, sessionId: targetSessionId, entries: [], loading: true, @@ -119,12 +125,13 @@ export function useSessionAgents({ try { const result = await desktopClient.invoke( "list_session_agents", - { sessionId: targetSessionId }, + { environmentId, sessionId: targetSessionId }, ); if (requestSeqRef.current !== seq) { return; } setRoster({ + environmentId, sessionId: targetSessionId, entries: parseAgentEntries(result), loading: false, @@ -137,6 +144,7 @@ export function useSessionAgents({ const message = err instanceof Error ? err.message : "Could not load agents."; setRoster((prev) => ({ + environmentId, sessionId: targetSessionId, // A failed read means this attempt learned nothing — not that the // agents are gone. Discarding them would blank a list that had @@ -148,13 +156,17 @@ export function useSessionAgents({ // // Entries from a *different* session are still dropped, so a failure // cannot make the previous session's agents surface under this one. - entries: prev.sessionId === targetSessionId ? prev.entries : [], + entries: + prev.environmentId === environmentId && + prev.sessionId === targetSessionId + ? prev.entries + : [], loading: false, error: message, })); } }, - [], + [environmentId], ); // A roster is only ever read back for the session it was fetched for, so @@ -163,7 +175,10 @@ export function useSessionAgents({ // because mergeAgentActivity prefers a non-empty roster over the // message-derived tally, so a leaked one would render as phantom agents // belonging to the new session. - const isCurrent = sessionId !== null && roster.sessionId === sessionId; + const isCurrent = + sessionId !== null && + roster.environmentId === environmentId && + roster.sessionId === sessionId; const agents = isCurrent ? roster.entries : NO_AGENTS; const loading = isCurrent && roster.loading; const error = isCurrent ? roster.error : null; diff --git a/apps/examples/desktop-app/webview/hooks/use-session-history.test.tsx b/apps/examples/desktop-app/webview/hooks/use-session-history.test.tsx index 9683da2e67..81eb5da810 100644 --- a/apps/examples/desktop-app/webview/hooks/use-session-history.test.tsx +++ b/apps/examples/desktop-app/webview/hooks/use-session-history.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionKey } from "../lib/session-identity"; import { useSessionHistory } from "./use-session-history"; const { invokeMock, subscribeMock } = vi.hoisted(() => ({ @@ -84,6 +85,44 @@ afterEach(async () => { }); describe("useSessionHistory session mapping", () => { + it("keeps duplicate IDs visible and renames only the selected environment", async () => { + await act(async () => { + root.render(); + }); + await flush(); + await act(async () => { + pendingLists[0].resolve( + ["local", "remote"].map((environmentId) => ({ + ...sessionRow("same-id"), + environmentId, + metadata: { title: environmentId }, + })), + ); + await Promise.resolve(); + }); + expect(current.threads).toHaveLength(2); + const remoteKey = sessionKey({ + sessionId: "same-id", + environmentId: "remote", + }); + await act(async () => { + await current.renameThread(remoteKey, "Renamed remote"); + }); + expect(invokeMock).toHaveBeenCalledWith("update_chat_session_title", { + sessionId: "same-id", + environmentId: "remote", + title: "Renamed remote", + }); + expect( + current.threads.find( + (thread) => thread.id === sessionKey({ sessionId: "same-id" }), + )?.title, + ).toBe("local"); + expect( + current.threads.find((thread) => thread.id === remoteKey)?.title, + ).toBe("Renamed remote"); + }); + it("maps nested Core schedule provenance onto sidebar threads", async () => { await act(async () => { root.render(); @@ -112,10 +151,15 @@ describe("useSessionHistory session mapping", () => { }); expect( - current.threads.find((thread) => thread.id === "scheduled-session"), + current.threads.find( + (thread) => + thread.id === sessionKey({ sessionId: "scheduled-session" }), + ), ).toMatchObject({ source: "core", isScheduled: true }); expect( - current.threads.find((thread) => thread.id === "regular-session"), + current.threads.find( + (thread) => thread.id === sessionKey({ sessionId: "regular-session" }), + ), ).toMatchObject({ source: "core", isScheduled: false }); }); @@ -168,14 +212,18 @@ describe("useSessionHistory session mapping", () => { // The executions list also supplies the schedule identity the session // record itself lacks, so the sidebar can group it with its siblings. expect( - current.threads.find((thread) => thread.id === "cron-session"), + current.threads.find( + (thread) => thread.id === sessionKey({ sessionId: "cron-session" }), + ), ).toMatchObject({ isScheduled: true, scheduleId: "sched_daily", scheduleName: "Daily report", }); expect( - current.threads.find((thread) => thread.id === "regular-session"), + current.threads.find( + (thread) => thread.id === sessionKey({ sessionId: "regular-session" }), + ), ).toMatchObject({ isScheduled: false }); }); @@ -206,7 +254,9 @@ describe("useSessionHistory session mapping", () => { }); expect( - current.threads.find((thread) => thread.id === "run-session"), + current.threads.find( + (thread) => thread.id === sessionKey({ sessionId: "run-session" }), + ), ).toMatchObject({ isScheduled: true, startedAt: "2026-07-20T10:00:00.000Z", @@ -630,7 +680,9 @@ describe("useSessionHistory usage hydration", () => { await renderWithRows(12); expect(current.threads.map((thread) => thread.id)).toEqual( - Array.from({ length: 12 }, (_, index) => `session-${index}`), + Array.from({ length: 12 }, (_, index) => + sessionKey({ sessionId: `session-${index}` }), + ), ); await flush(800); @@ -663,7 +715,11 @@ describe("useSessionHistory usage hydration", () => { // The second page comes into view: only the rows it asks for are read. await act(async () => { - current.requestUsage(["session-11", " ", "not-a-session"]); + current.requestUsage( + ["session-11", " ", "not-a-session"].map((sessionId) => + sessionKey({ sessionId }), + ), + ); }); await flush(800); await settle(); @@ -676,7 +732,11 @@ describe("useSessionHistory usage hydration", () => { // Asking again for rows that already have usage is a no-op. await act(async () => { - current.requestUsage(["session-0", "session-11"]); + current.requestUsage( + ["session-0", "session-11"].map((sessionId) => + sessionKey({ sessionId }), + ), + ); }); await flush(800); await settle(); @@ -744,7 +804,9 @@ describe("useSessionHistory usage hydration", () => { // A page request restarts the effect while four reads are pending. The // restarted run must not add four reads of its own on top of them. await act(async () => { - current.requestUsage(["session-11"]); + current.requestUsage( + ["session-11"].map((sessionId) => sessionKey({ sessionId })), + ); }); await flush(800); await settle(); @@ -883,7 +945,9 @@ describe("useSessionHistory usage hydration", () => { expect(readsOfRunning()).toBe(0); await act(async () => { - current.requestUsage(["session-11"]); + current.requestUsage( + ["session-11"].map((sessionId) => sessionKey({ sessionId })), + ); }); await flush(800); await settle(); @@ -903,7 +967,7 @@ describe("useSessionHistory usage hydration", () => { // The view pages away or unmounts: the next refresh leaves it alone, // and the completed rows it already hydrated are not read again either. await act(async () => { - current.requestUsage([]); + current.requestUsage([].map((sessionId) => sessionKey({ sessionId }))); }); await flush(12_000); await flush(); diff --git a/apps/examples/desktop-app/webview/hooks/use-session-history.ts b/apps/examples/desktop-app/webview/hooks/use-session-history.ts index 67b43e84c1..684e4bd3c8 100644 --- a/apps/examples/desktop-app/webview/hooks/use-session-history.ts +++ b/apps/examples/desktop-app/webview/hooks/use-session-history.ts @@ -19,6 +19,8 @@ import { getSessionSource, PINNED_METADATA_KEY, } from "@/lib/session-history"; +import { eventEnvironmentId, sessionKey } from "@/lib/session-identity"; +import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths"; type CliDiscoveredSession = Omit & { status: string; @@ -82,20 +84,24 @@ type SessionUsage = { }; type SessionTitleUpdatedEvent = CustomEvent<{ + environmentId?: string; sessionId: string; title: string; }>; type SessionDeletedEvent = CustomEvent<{ + environmentId?: string; sessionId: string; }>; type SidecarSessionStateEvent = { + environmentId?: string; sessionId?: string; status?: string; }; type SidecarChatEvent = { + environmentId?: string; sessionId?: string; stream?: string; }; @@ -108,10 +114,11 @@ export type SessionPendingAction = { export type UseSessionHistoryOptions = { activeSessionId?: string | null; onOpenSession?: (session: SessionHistoryItem) => void; - onDeleteSession?: (sessionId: string) => void; + onDeleteSession?: (sessionId: string, environmentId: string) => void; onUpdateSessionMetadata?: ( sessionId: string, metadata: SessionMetadata, + environmentId?: string, ) => void; }; @@ -281,7 +288,7 @@ function toThread(session: SessionHistoryItem): SessionThread { const workspacePath = (session.workspaceRoot || session.cwd).trim(); const schedule = getSessionMetadataSchedule(session.metadata); return { - id: session.sessionId, + id: sessionKey(session), title: toTitle(session), source: getSessionSource(session) || undefined, codebase: basenamePath(workspacePath), @@ -417,6 +424,7 @@ function areSessionsEquivalent( getSessionMetadataSchedule(a.metadata), getSessionMetadataSchedule(b.metadata), ) || + a.environmentId !== b.environmentId || a.workspaceRoot !== b.workspaceRoot || a.cwd !== b.cwd || a.provider !== b.provider || @@ -491,7 +499,7 @@ function updateSessionById( ): SessionHistoryItem[] { let changed = false; const next = current.map((session) => { - if (session.sessionId !== sessionId) { + if (sessionKey(session) !== sessionId) { return session; } const updated = updater(session); @@ -511,10 +519,10 @@ function mergeDiscoveredSessions( return discovered; } const currentById = new Map( - current.map((session) => [session.sessionId, session]), + current.map((session) => [sessionKey(session), session]), ); return discovered.map((session) => { - const existing = currentById.get(session.sessionId); + const existing = currentById.get(sessionKey(session)); if (!existing) { return session; } @@ -682,7 +690,7 @@ export function useSessionHistory({ typeof execution?.scheduleId === "string" ? execution.scheduleId.trim() : ""; - links.set(sessionId, { + links.set(sessionKey({ sessionId }), { ...(scheduleId ? { scheduleId } : {}), ...(scheduleId && scheduleNames.has(scheduleId) ? { scheduleName: scheduleNames.get(scheduleId) } @@ -789,7 +797,7 @@ export function useSessionHistory({ const mapped = mergedSessions.map(toThread); const metadataTitleById = new Map( mergedSessions.map((session) => [ - session.sessionId, + sessionKey(session), getSessionMetadataTitle(session.metadata), ]), ); @@ -933,18 +941,18 @@ export function useSessionHistory({ // The active session is skipped: its transcript is still being written // and the chat tracks its usage live. const inactiveSessions = sessions.filter( - (session) => session.sessionId !== activeSessionId, + (session) => sessionKey(session) !== activeSessionId, ); const targets = inactiveSessions.slice(0, USAGE_HYDRATION_WINDOW); if (requestedUsageIds.size > 0) { - const queued = new Set(targets.map((session) => session.sessionId)); + const queued = new Set(targets.map(sessionKey)); for (const session of inactiveSessions) { if ( - requestedUsageIds.has(session.sessionId) && - !queued.has(session.sessionId) + requestedUsageIds.has(sessionKey(session)) && + !queued.has(sessionKey(session)) ) { targets.push(session); - queued.add(session.sessionId); + queued.add(sessionKey(session)); } } } @@ -959,7 +967,7 @@ export function useSessionHistory({ const usageFetchVerdict = ( session: SessionHistoryItem, ): "fetch" | "defer" | "skip" => { - const sessionId = session.sessionId; + const sessionId = sessionKey(session); if (!sessionId) { return "skip"; } @@ -975,11 +983,12 @@ export function useSessionHistory({ }; const startUsageFetch = (session: SessionHistoryItem): void => { - const sessionId = session.sessionId; + const sessionId = sessionKey(session); usageLoadingRef.current.set(sessionId, session.status); void desktopClient .invoke("read_session_messages", { - sessionId, + environmentId: session.environmentId, + sessionId: session.sessionId, maxMessages: 1200, }) .then(async (sessionMessages): Promise => { @@ -988,7 +997,8 @@ export function useSessionHistory({ const events = await desktopClient.invoke( "read_session_hooks", { - sessionId, + environmentId: session.environmentId, + sessionId: session.sessionId, limit: 1200, }, ); @@ -1108,7 +1118,9 @@ export function useSessionHistory({ useEffect(() => { const handleTitleUpdated = (event: Event) => { const detail = (event as SessionTitleUpdatedEvent).detail; - const sessionId = detail?.sessionId?.trim(); + const sessionId = detail?.sessionId?.trim() + ? sessionKey(detail) + : undefined; if (!sessionId) { return; } @@ -1132,7 +1144,9 @@ export function useSessionHistory({ const handleSessionDeleted = (event: Event) => { const detail = (event as SessionDeletedEvent).detail; - const sessionId = detail?.sessionId?.trim(); + const sessionId = detail?.sessionId?.trim() + ? sessionKey(detail) + : undefined; if (!sessionId) { return; } @@ -1144,7 +1158,7 @@ export function useSessionHistory({ usageByIdRef.current.delete(sessionId); messageHydratedStatusRef.current.delete(sessionId); setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), + current.filter((session) => sessionKey(session) !== sessionId), ); setThreads((current) => current.filter((thread) => thread.id !== sessionId), @@ -1175,7 +1189,7 @@ export function useSessionHistory({ } handleSessionDeleted( new CustomEvent("cline:session-deleted", { - detail: { sessionId }, + detail: { sessionId, environmentId: eventEnvironmentId(payload) }, }), ); }, @@ -1187,12 +1201,17 @@ export function useSessionHistory({ return; } const record = payload as SidecarSessionStateEvent; - const sessionId = record.sessionId?.trim(); + const sessionId = record.sessionId?.trim() + ? sessionKey({ + sessionId: record.sessionId, + environmentId: record.environmentId, + }) + : undefined; if (!sessionId) { return; } const known = sessionsRef.current.some( - (session) => session.sessionId === sessionId, + (session) => sessionKey(session) === sessionId, ); const status = normalizeDiscoveredStatus(record.status); if (!known) { @@ -1222,7 +1241,10 @@ export function useSessionHistory({ scheduleRefresh(HISTORY_TERMINAL_REFRESH_DELAY_MS, { force: true, }); - const sessionId = record.sessionId.trim(); + const sessionId = sessionKey({ + sessionId: record.sessionId.trim(), + environmentId: record.environmentId, + }); if (sessionId !== activeSessionId) { setUnreadSessionIds((current) => { const next = new Set(current); @@ -1254,12 +1276,17 @@ export function useSessionHistory({ return; } const record = payload as SidecarChatEvent; - const sessionId = record.sessionId?.trim(); + const sessionId = record.sessionId?.trim() + ? sessionKey({ + sessionId: record.sessionId, + environmentId: record.environmentId, + }) + : undefined; if (!sessionId) { return; } const known = sessionsRef.current.some( - (session) => session.sessionId === sessionId, + (session) => sessionKey(session) === sessionId, ); if (!known) { scheduleRefresh(HISTORY_EVENT_REFRESH_DELAY_MS); @@ -1292,7 +1319,7 @@ export function useSessionHistory({ useEffect(() => { const recent = sessions - .filter((session) => session.sessionId !== activeSessionId) + .filter((session) => sessionKey(session) !== activeSessionId) .slice(0, 4); let cancelled = false; const timer = window.setTimeout(() => { @@ -1300,7 +1327,7 @@ export function useSessionHistory({ if (cancelled) { return; } - const sessionId = session.sessionId; + const sessionId = sessionKey(session); if (!sessionId) { continue; } @@ -1330,7 +1357,8 @@ export function useSessionHistory({ titleLoadingRef.current.add(sessionId); void desktopClient .invoke("read_session_messages", { - sessionId, + environmentId: session.environmentId, + sessionId: session.sessionId, maxMessages: 80, }) .then((messages) => { @@ -1380,7 +1408,7 @@ export function useSessionHistory({ const getSessionByThreadId = useCallback( (threadId: string) => - sessionsRef.current.find((session) => session.sessionId === threadId), + sessionsRef.current.find((session) => sessionKey(session) === threadId), [], ); @@ -1415,20 +1443,28 @@ export function useSessionHistory({ } setPendingAction({ sessionId: threadId, action: "rename" }); try { + const sourceSession = getSessionByThreadId(threadId); + if (!sourceSession) return false; await desktopClient.invoke("update_chat_session_title", { - sessionId: threadId, + environmentId: + sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, + sessionId: sourceSession?.sessionId, title: normalizedTitle, }); - const sourceSession = getSessionByThreadId(threadId); const metadata = { ...(sourceSession?.metadata ?? {}), title: normalizedTitle || undefined, }; - onUpdateSessionMetadata?.(threadId, metadata); + onUpdateSessionMetadata?.( + sourceSession.sessionId, + metadata, + sourceSession.environmentId, + ); window.dispatchEvent( new CustomEvent("cline:session-title-updated", { detail: { - sessionId: threadId, + sessionId: sourceSession.sessionId, + environmentId: sourceSession.environmentId, title: normalizedTitle, }, }), @@ -1474,15 +1510,22 @@ export function useSessionHistory({ // if the write fails rather than blocking the row on a round trip. applyPinned(pinned); try { + const sourceSession = getSessionByThreadId(threadId); + if (!sourceSession) return false; await desktopClient.invoke("update_chat_session_metadata", { - sessionId: threadId, + environmentId: + sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, + sessionId: sourceSession?.sessionId, metadata: { [PINNED_METADATA_KEY]: pinned ? true : null }, }); - const sourceSession = getSessionByThreadId(threadId); - onUpdateSessionMetadata?.(threadId, { - ...(sourceSession?.metadata ?? {}), - [PINNED_METADATA_KEY]: pinned || undefined, - }); + onUpdateSessionMetadata?.( + sourceSession.sessionId, + { + ...(sourceSession?.metadata ?? {}), + [PINNED_METADATA_KEY]: pinned || undefined, + }, + sourceSession.environmentId, + ); scheduleRefresh(HISTORY_FAST_REFRESH_DELAY_MS); return true; } catch (error) { @@ -1508,6 +1551,7 @@ export function useSessionHistory({ return false; } const sourceSession = getSessionByThreadId(threadId); + if (!sourceSession) return false; setPendingAction({ sessionId: threadId, action: "fork" }); try { const payload = await desktopClient.invoke<{ @@ -1516,8 +1560,10 @@ export function useSessionHistory({ }>("chat_session_command", { request: { action: "fork", - sessionId: threadId, + sessionId: sourceSession?.sessionId, config: { + environmentId: + sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, provider: sourceSession?.provider || thread.provider, model: sourceSession?.model || thread.model, cwd: sourceSession?.cwd || sourceSession?.workspaceRoot || "", @@ -1532,6 +1578,8 @@ export function useSessionHistory({ } const forkedSession: SessionHistoryItem = { sessionId: newSessionId, + environmentId: + sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, status: "completed", provider: sourceSession?.provider || thread.provider, model: sourceSession?.model || thread.model, @@ -1568,12 +1616,16 @@ export function useSessionHistory({ const deleteThread = useCallback( async (threadId: string) => { + const sourceSession = getSessionByThreadId(threadId); + if (!sourceSession) return false; setPendingAction({ sessionId: threadId, action: "delete" }); try { const deleteResult = await desktopClient.invoke< boolean | { deleted?: boolean } >("delete_chat_session", { - sessionId: threadId, + environmentId: + sourceSession?.environmentId ?? LOCAL_WORKSPACE_ENVIRONMENT_ID, + sessionId: sourceSession?.sessionId, }); const deleted = typeof deleteResult === "boolean" @@ -1584,11 +1636,12 @@ export function useSessionHistory({ "The session could not be removed from local history.", ); } - onDeleteSession?.(threadId); + onDeleteSession?.(sourceSession.sessionId, sourceSession.environmentId); window.dispatchEvent( new CustomEvent("cline:session-deleted", { detail: { - sessionId: threadId, + sessionId: sourceSession.sessionId, + environmentId: sourceSession.environmentId, }, }), ); @@ -1607,7 +1660,7 @@ export function useSessionHistory({ setPendingAction(null); } }, - [onDeleteSession], + [getSessionByThreadId, onDeleteSession], ); const loadMoreSessions = useCallback( @@ -1686,7 +1739,7 @@ export function useSessionHistory({ }, [loadMoreSessions, refreshSessions]); const sessionById = useMemo( - () => new Map(sessions.map((session) => [session.sessionId, session])), + () => new Map(sessions.map((session) => [sessionKey(session), session])), [sessions], ); diff --git a/apps/examples/desktop-app/webview/lib/chat-schema.ts b/apps/examples/desktop-app/webview/lib/chat-schema.ts index addceeaedc..043ee258d3 100644 --- a/apps/examples/desktop-app/webview/lib/chat-schema.ts +++ b/apps/examples/desktop-app/webview/lib/chat-schema.ts @@ -5,6 +5,7 @@ export const ChatSessionConfigSchema = z.object({ sessionId: z.string().min(1).optional(), workspaceRoot: z.string(), cwd: z.string().optional(), + environmentId: z.string().trim().min(1), provider: z.string().min(1), model: z.string().min(1), mode: z.enum(["act", "plan"]).default("act"), diff --git a/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts b/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts index 2aedb52433..bdf26a25f5 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-app-state.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import { createDesktopAppState, desktopAppReducer } from "./desktop-app-state"; import type { SessionHistoryItem } from "./session-history"; +import { sessionKey } from "./session-identity"; const settingsSection = "General" as const; function createSession(sessionId: string): SessionHistoryItem { return { sessionId, + environmentId: "local", status: "completed", provider: "test-provider", model: "test-model", @@ -17,50 +19,100 @@ function createSession(sessionId: string): SessionHistoryItem { } describe("desktopAppReducer", () => { + it("keeps identical session IDs separate across environments", () => { + let state = createDesktopAppState("welcome", settingsSection, "local"); + for (const environmentId of ["local", "remote"]) { + state = desktopAppReducer(state, { + type: "open-session", + session: { ...createSession("same-id"), environmentId }, + environmentId, + }); + } + expect( + state.threads.filter( + (thread) => thread.historySession?.sessionId === "same-id", + ), + ).toHaveLength(2); + state = desktopAppReducer(state, { + type: "update-session-metadata", + sessionId: "same-id", + environmentId: "remote", + metadata: { title: "Remote title" }, + }); + expect( + state.threads.find( + (thread) => thread.environmentId === "local" && thread.historySession, + )?.historySession?.metadata?.title, + ).toBeUndefined(); + state = desktopAppReducer(state, { + type: "delete-session", + deletedSessionId: "same-id", + environmentId: "remote", + fallbackThreadId: "fallback", + fallbackEnvironmentId: "local", + }); + expect( + state.threads + .filter((thread) => thread.historySession?.sessionId === "same-id") + .map((thread) => thread.environmentId), + ).toEqual(["local"]); + }); + it("hands an edited prompt to a fork exactly once", () => { - let state = createDesktopAppState("welcome", settingsSection); + let state = createDesktopAppState("welcome", settingsSection, "local"); state = desktopAppReducer(state, { type: "open-session", session: createSession("forked-session"), + environmentId: "local", initialPromptDraft: "Revise this prompt", }); expect( - state.threads.find((thread) => thread.id === "session_forked-session") - ?.initialPromptDraft, + state.threads.find( + (thread) => + thread.id === + `session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`, + )?.initialPromptDraft, ).toBe("Revise this prompt"); state = desktopAppReducer(state, { type: "consume-initial-prompt-draft", - threadId: "session_forked-session", + threadId: `session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`, }); expect( - state.threads.find((thread) => thread.id === "session_forked-session") - ?.initialPromptDraft, + state.threads.find( + (thread) => + thread.id === + `session_${sessionKey({ sessionId: "forked-session", environmentId: "local" })}`, + )?.initialPromptDraft, ).toBeUndefined(); }); it("keeps both sessions deleted when deletion actions are queued together", () => { - let state = createDesktopAppState("welcome", settingsSection); + let state = createDesktopAppState("welcome", settingsSection, "local"); state = desktopAppReducer(state, { type: "open-session", session: createSession("session-a"), + environmentId: "local", }); state = desktopAppReducer(state, { type: "open-session", session: createSession("session-b"), + environmentId: "local", }); state = desktopAppReducer(state, { type: "delete-session", deletedSessionId: "session-a", fallbackThreadId: "fallback-a", + fallbackEnvironmentId: "local", }); state = desktopAppReducer(state, { type: "delete-session", deletedSessionId: "session-b", fallbackThreadId: "fallback-b", + fallbackEnvironmentId: "local", }); expect(state.threads.map((thread) => thread.id)).toEqual([ @@ -73,30 +125,145 @@ describe("desktopAppReducer", () => { state.navigation.current, ...state.navigation.forward, ]).not.toContainEqual( - expect.objectContaining({ activeThreadId: "session_session-a" }), + expect.objectContaining({ + activeThreadId: `session_${sessionKey({ sessionId: "session-a", environmentId: "local" })}`, + }), ); expect([ ...state.navigation.back, state.navigation.current, ...state.navigation.forward, ]).not.toContainEqual( - expect.objectContaining({ activeThreadId: "session_session-b" }), + expect.objectContaining({ + activeThreadId: `session_${sessionKey({ sessionId: "session-b", environmentId: "local" })}`, + }), ); }); it("ignores a duplicate deletion after its thread and history are removed", () => { - let state = createDesktopAppState("welcome", settingsSection); + let state = createDesktopAppState("welcome", settingsSection, "local"); state = desktopAppReducer(state, { type: "open-session", session: createSession("session-a"), + environmentId: "local", }); const deletion = { type: "delete-session" as const, deletedSessionId: "session-a", fallbackThreadId: "fallback-a", + fallbackEnvironmentId: "local", }; state = desktopAppReducer(state, deletion); expect(desktopAppReducer(state, deletion)).toBe(state); }); + + it("binds drafts to an environment without rebinding started threads", () => { + let state = createDesktopAppState("welcome", settingsSection, "local"); + state = desktopAppReducer(state, { + type: "bind-unstarted-thread", + threadId: "welcome", + environmentId: "pi-host", + }); + expect(state.threads[0]?.environmentId).toBe("pi-host"); + + state = desktopAppReducer(state, { + type: "thread-started", + threadId: "welcome", + }); + state = desktopAppReducer(state, { + type: "bind-unstarted-thread", + threadId: "welcome", + environmentId: "other-host", + }); + expect(state.threads[0]?.environmentId).toBe("pi-host"); + }); + + it("carries environment identity through new and restored threads", () => { + let state = createDesktopAppState("welcome", settingsSection, "local"); + state = desktopAppReducer(state, { + type: "new-thread", + threadId: "remote-draft", + environmentId: "pi-host", + }); + expect(state.threads.at(-1)).toMatchObject({ + id: "remote-draft", + environmentId: "pi-host", + }); + + state = desktopAppReducer(state, { + type: "open-session", + session: { + ...createSession("remote-session"), + environmentId: "pi-host", + workspaceRoot: "/home/pi/project", + cwd: "/home/pi/project", + }, + environmentId: "pi-host", + }); + expect(state.threads.at(-1)).toMatchObject({ + id: `session_${sessionKey({ sessionId: "remote-session", environmentId: "pi-host" })}`, + environmentId: "pi-host", + historySession: { environmentId: "pi-host" }, + }); + }); + + it("creates one draft per selected environment and reuses it", () => { + let state = createDesktopAppState("local-draft", settingsSection, "local"); + state = desktopAppReducer(state, { + type: "select-environment-draft", + environmentId: "pi-host", + threadId: "remote-draft", + }); + + expect(state.navigation.current).toMatchObject({ + activeThreadId: "remote-draft", + view: "chat", + }); + expect(state.threads).toContainEqual({ + id: "remote-draft", + environmentId: "pi-host", + }); + + const selectedAgain = desktopAppReducer(state, { + type: "select-environment-draft", + environmentId: "pi-host", + threadId: "duplicate-remote-draft", + }); + expect(selectedAgain).toBe(state); + expect( + selectedAgain.threads.filter( + (thread) => thread.environmentId === "pi-host" && !thread.hasStarted, + ), + ).toHaveLength(1); + + state = desktopAppReducer(selectedAgain, { + type: "select-environment-draft", + environmentId: "local", + threadId: "duplicate-local-draft", + }); + expect(state.navigation.current.activeThreadId).toBe("local-draft"); + expect( + state.threads.some((thread) => thread.id === "duplicate-local-draft"), + ).toBe(false); + }); + + it("does not reuse a started thread as an environment draft", () => { + let state = createDesktopAppState("local-draft", settingsSection, "local"); + state = desktopAppReducer(state, { + type: "thread-started", + threadId: "local-draft", + }); + state = desktopAppReducer(state, { + type: "select-environment-draft", + environmentId: "local", + threadId: "fresh-local-draft", + }); + + expect(state.navigation.current.activeThreadId).toBe("fresh-local-draft"); + expect(state.threads.at(-1)).toEqual({ + id: "fresh-local-draft", + environmentId: "local", + }); + }); }); diff --git a/apps/examples/desktop-app/webview/lib/desktop-app-state.ts b/apps/examples/desktop-app/webview/lib/desktop-app-state.ts index 28164df3d2..277ee6a041 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-app-state.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-app-state.ts @@ -4,11 +4,13 @@ import { navigationHistoryReducer, } from "./navigation-history"; import type { SessionHistoryItem, SessionMetadata } from "./session-history"; +import { sessionKey } from "./session-identity"; export type DesktopAppView = "chat" | "sessions" | "settings"; export type DesktopThread = { id: string; + environmentId: string; historySession?: SessionHistoryItem; hasStarted?: boolean; initialPromptDraft?: string; @@ -29,21 +31,31 @@ export type DesktopAppAction = | { type: "navigate"; destination: DesktopAppLocation } | { type: "back" } | { type: "forward" } - | { type: "new-thread"; threadId: string } + | { type: "new-thread"; threadId: string; environmentId: string } + | { type: "bind-unstarted-thread"; threadId: string; environmentId: string } + | { + type: "select-environment-draft"; + environmentId: string; + threadId: string; + } | { type: "open-session"; session: SessionHistoryItem; + environmentId: string; initialPromptDraft?: string; } | { type: "consume-initial-prompt-draft"; threadId: string } | { type: "delete-session"; deletedSessionId: string; + environmentId?: string; deletedThreadId?: string; fallbackThreadId: string; + fallbackEnvironmentId: string; } | { type: "update-session-metadata"; + environmentId?: string; sessionId: string; metadata: SessionMetadata; } @@ -63,9 +75,10 @@ function areLocationsEqual( export function createDesktopAppState( initialThreadId: string, initialSettingsSection: SettingsSection, + initialEnvironmentId: string, ): DesktopAppState { return { - threads: [{ id: initialThreadId }], + threads: [{ id: initialThreadId, environmentId: initialEnvironmentId }], navigation: createNavigationHistory({ activeThreadId: initialThreadId, settingsSection: initialSettingsSection, @@ -98,7 +111,10 @@ export function desktopAppReducer( }; case "new-thread": return { - threads: [...state.threads, { id: action.threadId }], + threads: [ + ...state.threads, + { id: action.threadId, environmentId: action.environmentId }, + ], navigation: navigationHistoryReducer(state.navigation, { type: "navigate", destination: { @@ -108,8 +124,54 @@ export function desktopAppReducer( }, }), }; + case "bind-unstarted-thread": + return { + ...state, + threads: state.threads.map((thread) => + thread.id === action.threadId && + !thread.hasStarted && + !thread.historySession + ? { ...thread, environmentId: action.environmentId } + : thread, + ), + }; + case "select-environment-draft": { + const existingDraft = [...state.threads] + .reverse() + .find( + (thread) => + thread.environmentId === action.environmentId && + !thread.hasStarted && + !thread.historySession, + ); + const targetThreadId = existingDraft?.id ?? action.threadId; + const threads = existingDraft + ? state.threads + : [ + ...state.threads, + { id: targetThreadId, environmentId: action.environmentId }, + ]; + const destination = { + ...state.navigation.current, + activeThreadId: targetThreadId, + view: "chat" as const, + }; + if ( + threads === state.threads && + areLocationsEqual(state.navigation.current, destination) + ) { + return state; + } + return { + threads, + navigation: navigationHistoryReducer(state.navigation, { + type: "navigate", + destination, + }), + }; + } case "open-session": { - const threadId = `session_${action.session.sessionId}`; + const threadId = `session_${sessionKey({ ...action.session, environmentId: action.environmentId })}`; const existingIdx = state.threads.findIndex( (thread) => thread.id === threadId, ); @@ -119,8 +181,12 @@ export function desktopAppReducer( index === existingIdx ? { ...thread, + environmentId: action.environmentId, hasStarted: true, - historySession: action.session, + historySession: { + ...action.session, + environmentId: action.environmentId, + }, initialPromptDraft: action.initialPromptDraft, } : thread, @@ -129,8 +195,12 @@ export function desktopAppReducer( ...state.threads, { id: threadId, + environmentId: action.environmentId, hasStarted: true, - historySession: action.session, + historySession: { + ...action.session, + environmentId: action.environmentId, + }, initialPromptDraft: action.initialPromptDraft, }, ]; @@ -157,14 +227,15 @@ export function desktopAppReducer( ), }; case "delete-session": { - const historyThreadId = `session_${action.deletedSessionId}`; + const historyThreadId = `session_${sessionKey({ sessionId: action.deletedSessionId, environmentId: action.environmentId })}`; const deletedThreadIds = new Set( state.threads .filter( (thread) => thread.id === action.deletedThreadId || thread.id === historyThreadId || - thread.historySession?.sessionId === action.deletedSessionId, + (thread.historySession?.sessionId === action.deletedSessionId && + thread.environmentId === (action.environmentId ?? "local")), ) .map((thread) => thread.id), ); @@ -193,7 +264,13 @@ export function desktopAppReducer( let replacementThreadId = threads[0]?.id; if (deletedWasActive || !replacementThreadId) { replacementThreadId = action.fallbackThreadId; - threads = [...threads, { id: replacementThreadId }]; + threads = [ + ...threads, + { + id: replacementThreadId, + environmentId: action.fallbackEnvironmentId, + }, + ]; } const fallback: DesktopAppLocation = { ...state.navigation.current, @@ -224,7 +301,8 @@ export function desktopAppReducer( return { ...state, threads: state.threads.map((thread) => - thread.historySession?.sessionId === action.sessionId + thread.historySession?.sessionId === action.sessionId && + thread.environmentId === (action.environmentId ?? "local") ? { ...thread, historySession: { diff --git a/apps/examples/desktop-app/webview/lib/desktop-notifications.test.ts b/apps/examples/desktop-app/webview/lib/desktop-notifications.test.ts index 65189496e9..dd4fe7a02d 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-notifications.test.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-notifications.test.ts @@ -62,6 +62,25 @@ afterEach(() => { }); describe("desktop notifications", () => { + it("does not deduplicate completion notifications across hosts", async () => { + const { watchDesktopNotifications } = await importFresh(); + const stop = watchDesktopNotifications(); + for (const environmentId of ["local", "remote"]) { + emit("chat_session_ended", { + sessionId: "same-id", + environmentId, + reason: "completed", + }); + emit("chat_session_ended", { + sessionId: "same-id", + environmentId, + reason: "completed", + }); + } + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); + stop(); + }); + it("notifies once when a background approval remains in state snapshots", async () => { const { watchDesktopNotifications } = await importFresh(); const stop = watchDesktopNotifications(); diff --git a/apps/examples/desktop-app/webview/lib/desktop-notifications.ts b/apps/examples/desktop-app/webview/lib/desktop-notifications.ts index a8d0be9435..e219428a30 100644 --- a/apps/examples/desktop-app/webview/lib/desktop-notifications.ts +++ b/apps/examples/desktop-app/webview/lib/desktop-notifications.ts @@ -5,6 +5,7 @@ import { requestPermission, } from "@tauri-apps/plugin-notification"; import { desktopClient, isTauriAvailable } from "@/lib/desktop-client"; +import { eventEnvironmentId, sessionKey } from "./session-identity"; const DESKTOP_NOTIFICATION_SETTINGS_STORAGE_KEY = "cline:desktop-notification-settings:v1"; @@ -309,19 +310,21 @@ export function watchDesktopNotifications(): () => void { }; const handleTerminal = ( + environmentId: string, sessionId: string, kind: TerminalKind, detail = "", ) => { - if (!sessionId || terminalBySession.get(sessionId) === kind) { + const key = sessionKey({ sessionId, environmentId }); + if (!sessionId || terminalBySession.get(key) === kind) { return; } - terminalBySession.set(sessionId, kind); + terminalBySession.set(key, kind); if (kind === "cancelled") { return; } if (kind === "completed") { - if ((queuedPromptsBySession.get(sessionId) ?? 0) > 0) { + if ((queuedPromptsBySession.get(key) ?? 0) > 0) { return; } void notify({ @@ -347,7 +350,7 @@ export function watchDesktopNotifications(): () => void { const sessionId = asNonEmptyString(record.sessionId); if (!sessionId) return; queuedPromptsBySession.set( - sessionId, + sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }), Array.isArray(record.items) ? record.items.length : 0, ); }), @@ -362,13 +365,16 @@ export function watchDesktopNotifications(): () => void { stream === "chat_tool_call_start" || stream === "chat_text" ) { - terminalBySession.delete(sessionId); + terminalBySession.delete( + sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }), + ); return; } if (stream !== "chat_done") return; const done = parseDoneChunk(event.chunk); const kind = terminalKind(done.reason || "completed"); - if (kind) handleTerminal(sessionId, kind, done.text); + if (kind) + handleTerminal(eventEnvironmentId(payload), sessionId, kind, done.text); }), desktopClient.subscribe("chat_session_status", (payload) => { if (!payload || typeof payload !== "object") return; @@ -377,11 +383,14 @@ export function watchDesktopNotifications(): () => void { const status = asNonEmptyString(record.status).toLowerCase(); if (!sessionId || !status) return; if (status === "running" || status === "starting") { - terminalBySession.delete(sessionId); + terminalBySession.delete( + sessionKey({ sessionId, environmentId: eventEnvironmentId(payload) }), + ); return; } const kind = terminalKind(status); - if (kind && status !== "idle") handleTerminal(sessionId, kind); + if (kind && status !== "idle") + handleTerminal(eventEnvironmentId(payload), sessionId, kind); }), desktopClient.subscribe("chat_session_ended", (payload) => { if (!payload || typeof payload !== "object") return; @@ -389,7 +398,8 @@ export function watchDesktopNotifications(): () => void { const sessionId = asNonEmptyString(record.sessionId); const reason = asNonEmptyString(record.reason); const kind = terminalKind(reason); - if (sessionId && kind) handleTerminal(sessionId, kind); + if (sessionId && kind) + handleTerminal(eventEnvironmentId(payload), sessionId, kind); }), desktopClient.subscribe("tool_approval_state", (payload) => { if (!payload || typeof payload !== "object") return; @@ -398,7 +408,16 @@ export function watchDesktopNotifications(): () => void { if (!sessionId || !Array.isArray(record.items)) return; for (const item of record.items as ToolApprovalItem[]) { const requestId = asNonEmptyString(item.requestId); - if (!requestId || !addSeenRequest(seenApprovalRequests, requestId)) { + if ( + !requestId || + !addSeenRequest( + seenApprovalRequests, + sessionKey({ + sessionId: requestId, + environmentId: eventEnvironmentId(payload), + }), + ) + ) { continue; } const toolName = asNonEmptyString(item.toolName) || "A tool"; @@ -418,7 +437,13 @@ export function watchDesktopNotifications(): () => void { if ( !requestId || !sessionId || - !addSeenRequest(seenQuestionRequests, requestId) + !addSeenRequest( + seenQuestionRequests, + sessionKey({ + sessionId: requestId, + environmentId: eventEnvironmentId(payload), + }), + ) ) { return; } diff --git a/apps/examples/desktop-app/webview/lib/remote-environments.test.ts b/apps/examples/desktop-app/webview/lib/remote-environments.test.ts new file mode 100644 index 0000000000..df069d6f12 --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/remote-environments.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + createRemoteEnvironmentDraft, + formatRemoteEnvironmentDestination, + normalizeRemoteEnvironmentProfile, + validateRemoteEnvironmentProfile, +} from "./remote-environments"; + +describe("remote environment models", () => { + it("leaves a new profile port blank so SSH config can supply it", () => { + expect(createRemoteEnvironmentDraft()).toEqual({ + id: undefined, + name: "", + host: "", + user: undefined, + port: undefined, + identityFile: undefined, + }); + }); + + it("normalizes SSH connection fields", () => { + expect( + normalizeRemoteEnvironmentProfile({ + id: " remote-1 ", + name: " Build box ", + host: " builder.example.com ", + user: " ubuntu ", + port: 2222, + identityFile: " ~/.ssh/build ", + }), + ).toEqual({ + id: "remote-1", + name: "Build box", + host: "builder.example.com", + user: "ubuntu", + port: 2222, + identityFile: "~/.ssh/build", + }); + }); + + it("validates required connection fields and formats destinations", () => { + const profile = { + name: "Build box", + host: "builder.example.com", + user: "ubuntu", + port: 2222, + }; + expect(validateRemoteEnvironmentProfile(profile)).toBeUndefined(); + expect(formatRemoteEnvironmentDestination(profile)).toBe( + "ubuntu@builder.example.com:2222", + ); + expect( + formatRemoteEnvironmentDestination({ + host: "build-alias", + port: undefined, + }), + ).toBe("build-alias"); + }); +}); diff --git a/apps/examples/desktop-app/webview/lib/remote-environments.ts b/apps/examples/desktop-app/webview/lib/remote-environments.ts new file mode 100644 index 0000000000..c7ee7e50c3 --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/remote-environments.ts @@ -0,0 +1,149 @@ +export const DEFAULT_REMOTE_ENVIRONMENT_PORT = 22; + +export type { + RemoteEnvironmentInput as RemoteEnvironmentProfile, + RemoteEnvironmentStatus, +} from "@cline/core"; + +import type { + RemoteEnvironmentInput as RemoteEnvironmentProfile, + RemoteEnvironmentStatus, +} from "@cline/core"; + +export type RemoteEnvironmentListResult = { + profiles: RemoteEnvironmentProfile[]; + activeEnvironmentId: string; + activeProfileId: string | null; + statuses: RemoteEnvironmentStatus[]; +}; + +export type RemoteEnvironmentUpsertResult = { + profile: RemoteEnvironmentProfile; +}; + +export type RemoteEnvironmentTestResult = { + profile?: RemoteEnvironmentProfile; + status: "passed" | "failed"; + message?: string; + remotePlatform?: string; + remoteArch?: string; +}; + +export type RemoteEnvironmentConnectResult = { + profile: RemoteEnvironmentProfile; + status: "connected"; + environmentId: string; + activeEnvironmentId: string; + activeProfileId: string; + homeDir: string; + workspaceRoot: string; + remotePlatform?: string; + remoteArch?: string; +}; + +export type RemoteEnvironmentDisconnectResult = { + status: "disconnected"; + disconnectedProfileId: string | null; + activeEnvironmentId: string; + activeProfileId: string | null; +}; + +export type RemoteEnvironmentDeleteResult = { + deleted: boolean; + activeEnvironmentId: string; + activeProfileId: string | null; +}; + +export type RemoteEnvironmentTestState = + | "untested" + | "testing" + | "passed" + | "failed"; + +export type RemoteEnvironmentBootstrapState = + | "unknown" + | "installing" + | "ready" + | "failed"; + +export type RemoteEnvironmentConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "disconnecting" + | "error"; + +export type RemoteEnvironmentRuntimeState = { + test: RemoteEnvironmentTestState; + bootstrap: RemoteEnvironmentBootstrapState; + connection: RemoteEnvironmentConnectionState; + message?: string; + remotePlatform?: string; + remoteArch?: string; +}; + +export const DEFAULT_REMOTE_ENVIRONMENT_RUNTIME_STATE: RemoteEnvironmentRuntimeState = + { + test: "untested", + bootstrap: "unknown", + connection: "disconnected", + }; + +export function createRemoteEnvironmentDraft( + profile?: RemoteEnvironmentProfile, +): RemoteEnvironmentProfile { + return { + id: profile?.id, + name: profile?.name ?? "", + host: profile?.host ?? "", + user: profile?.user, + port: profile?.port, + identityFile: profile?.identityFile, + }; +} + +function trimmedOptional(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function normalizeRemoteEnvironmentProfile( + profile: RemoteEnvironmentProfile, +): RemoteEnvironmentProfile { + return { + id: trimmedOptional(profile.id), + name: profile.name.trim(), + host: profile.host.trim(), + user: trimmedOptional(profile.user), + port: profile.port, + identityFile: trimmedOptional(profile.identityFile), + }; +} + +export function validateRemoteEnvironmentProfile( + profile: RemoteEnvironmentProfile, +): string | undefined { + if (!profile.name.trim()) return "Name is required."; + if (!profile.host.trim()) return "SSH host is required."; + if ( + profile.port !== undefined && + (!Number.isInteger(profile.port) || + profile.port < 1 || + profile.port > 65_535) + ) { + return "Port must be a whole number between 1 and 65535."; + } + return undefined; +} + +export function formatRemoteEnvironmentDestination( + profile: Pick, +): string { + const host = profile.host.trim(); + const user = profile.user?.trim(); + const destination = user ? `${user}@${host}` : host; + return profile.port === undefined || + profile.port === DEFAULT_REMOTE_ENVIRONMENT_PORT + ? destination + : `${destination}:${profile.port}`; +} diff --git a/apps/examples/desktop-app/webview/lib/session-history.ts b/apps/examples/desktop-app/webview/lib/session-history.ts index cc627307d5..a7e133ade8 100644 --- a/apps/examples/desktop-app/webview/lib/session-history.ts +++ b/apps/examples/desktop-app/webview/lib/session-history.ts @@ -49,6 +49,12 @@ export interface SessionHistoryItem { model: string; cwd: string; workspaceRoot: string; + environmentId: string; + remoteEnvironment?: { + id: string; + name?: string; + host?: string; + }; parentSessionId?: string; isSubagent?: boolean; prompt?: string; diff --git a/apps/examples/desktop-app/webview/lib/session-identity.ts b/apps/examples/desktop-app/webview/lib/session-identity.ts new file mode 100644 index 0000000000..f1afaf2f98 --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/session-identity.ts @@ -0,0 +1,16 @@ +/** Stable UI identity; the runtime session ID remains unchanged on the wire. */ +export function sessionKey(session: { + sessionId: string; + environmentId?: string; +}): string { + return JSON.stringify([session.environmentId ?? "local", session.sessionId]); +} + +export function eventEnvironmentId(payload: unknown): string { + return payload && + typeof payload === "object" && + "environmentId" in payload && + typeof payload.environmentId === "string" + ? payload.environmentId + : "local"; +} diff --git a/apps/examples/desktop-app/webview/lib/workspace-environment.test.ts b/apps/examples/desktop-app/webview/lib/workspace-environment.test.ts new file mode 100644 index 0000000000..db0b13500d --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/workspace-environment.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { remoteWorkspaceEnvironmentFromContext } from "./workspace-environment"; + +describe("remoteWorkspaceEnvironmentFromContext", () => { + it("returns the active SSH environment and its reported home", () => { + expect( + remoteWorkspaceEnvironmentFromContext({ + environmentId: "pi-host", + workspaceRoot: "/home/pi", + cwd: "/home/pi", + homeDir: "/home/pi", + activeEnvironmentId: "pi-host", + remoteEnvironment: { id: "pi-host", host: "pi.local" }, + }), + ).toEqual({ id: "pi-host", homeDir: "/home/pi" }); + }); + + it("keeps local contexts local", () => { + expect( + remoteWorkspaceEnvironmentFromContext({ + environmentId: "local", + workspaceRoot: "/Users/dev/project", + cwd: "/Users/dev/project", + homeDir: "/Users/dev", + activeEnvironmentId: "local", + remoteEnvironment: null, + }), + ).toBeNull(); + }); +}); diff --git a/apps/examples/desktop-app/webview/lib/workspace-environment.ts b/apps/examples/desktop-app/webview/lib/workspace-environment.ts new file mode 100644 index 0000000000..6f629507b0 --- /dev/null +++ b/apps/examples/desktop-app/webview/lib/workspace-environment.ts @@ -0,0 +1,16 @@ +import type { ProcessContext } from "@/hooks/chat-session/types"; + +export type RemoteWorkspaceEnvironment = { + id: string; + homeDir: string; +}; + +export function remoteWorkspaceEnvironmentFromContext( + context: ProcessContext, +): RemoteWorkspaceEnvironment | null { + const id = context.remoteEnvironment?.id?.trim(); + if (!id) return null; + const homeDir = context.homeDir?.trim(); + if (!homeDir) return null; + return { id, homeDir }; +} diff --git a/apps/examples/desktop-app/webview/lib/workspace-paths.test.ts b/apps/examples/desktop-app/webview/lib/workspace-paths.test.ts index ba2f19e965..79c42c2107 100644 --- a/apps/examples/desktop-app/webview/lib/workspace-paths.test.ts +++ b/apps/examples/desktop-app/webview/lib/workspace-paths.test.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { afterEach, describe, expect, it } from "vitest"; import { filterWorkspacePaths, @@ -7,9 +9,12 @@ import { mergeWorkspacePaths, normalizeWorkspacePath, parseWorkspaceSelectionStorage, + readWorkspaceSelectionFromWindow, registerHostHomeDirectory, resolveWorkspaceFilePath, + WORKSPACE_SELECTION_STORAGE_KEY, workspacePathsFromSessions, + writeWorkspaceSelectionToWindow, } from "./workspace-paths"; describe("workspace paths", () => { @@ -25,6 +30,10 @@ describe("workspace paths", () => { expect(looksLikeFolderPath("")).toBe(false); }); + afterEach(() => { + window.localStorage.clear(); + }); + it("normalizes trailing separators and Windows path casing", () => { expect(normalizeWorkspacePath(" /workspace/cline/ ")).toBe( "/workspace/cline", @@ -100,17 +109,33 @@ describe("workspace paths", () => { }); it("orders the catalog by the most recent session in each workspace", () => { - const paths = workspacePathsFromSessions([ - { workspaceRoot: "/projects/old", startedAt: "2026-01-05T00:00:00Z" }, - { - workspaceRoot: "/projects/active", - startedAt: "2026-02-01T00:00:00Z", - endedAt: "2026-02-01T01:00:00Z", - }, - { workspaceRoot: "/projects/old", startedAt: "2026-03-01T00:00:00Z" }, - { workspaceRoot: "/projects/mid", startedAt: "2026-02-15T00:00:00Z" }, - { workspaceRoot: "/projects/undated" }, - ]); + const paths = workspacePathsFromSessions( + [ + { + workspaceRoot: "/projects/old", + startedAt: "2026-01-05T00:00:00Z", + environmentId: "local", + }, + { + workspaceRoot: "/projects/active", + startedAt: "2026-02-01T00:00:00Z", + endedAt: "2026-02-01T01:00:00Z", + environmentId: "local", + }, + { + workspaceRoot: "/projects/old", + startedAt: "2026-03-01T00:00:00Z", + environmentId: "local", + }, + { + workspaceRoot: "/projects/mid", + startedAt: "2026-02-15T00:00:00Z", + environmentId: "local", + }, + { workspaceRoot: "/projects/undated", environmentId: "local" }, + ], + "local", + ); expect(paths).toEqual([ "/projects/old", @@ -123,10 +148,14 @@ describe("workspace paths", () => { it("builds the project catalog from every loaded history workspace", () => { const sessions = Array.from({ length: 25 }, (_, index) => ({ workspaceRoot: `/projects/project-${String(index + 1).padStart(2, "0")}`, + environmentId: "local", })); - sessions.push({ workspaceRoot: "/projects/project-01/" }); + sessions.push({ + workspaceRoot: "/projects/project-01/", + environmentId: "local", + }); - const paths = workspacePathsFromSessions(sessions); + const paths = workspacePathsFromSessions(sessions, "local"); expect(paths).toHaveLength(25); expect(paths).toContain("/projects/project-25"); @@ -136,20 +165,73 @@ describe("workspace paths", () => { expect( parseWorkspaceSelectionStorage( JSON.stringify({ - lastWorkspace: "/projects/selected/", - workspaces: ["/projects/one", "/projects/selected"], + environments: { + local: { + lastWorkspace: "/projects/selected/", + workspaces: ["/projects/one", "/projects/selected"], + }, + }, }), + "local", ), ).toEqual({ lastWorkspace: "/projects/selected/", workspaces: ["/projects/one", "/projects/selected"], }); - expect(parseWorkspaceSelectionStorage("not json")).toEqual({ + expect(parseWorkspaceSelectionStorage("not json", "local")).toEqual({ lastWorkspace: "", workspaces: [], }); }); + it("does not interpret path-only v1 data as an environment selection", () => { + expect( + parseWorkspaceSelectionStorage( + JSON.stringify({ + lastWorkspace: "/projects/legacy", + workspaces: ["/projects/legacy"], + }), + "local", + ), + ).toEqual({ lastWorkspace: "", workspaces: [] }); + }); + + it("reads and writes each environment without replacing the others", () => { + writeWorkspaceSelectionToWindow("local", { + lastWorkspace: "/Users/dev/local-app", + workspaces: ["/Users/dev/local-app"], + }); + writeWorkspaceSelectionToWindow("pi-host", { + lastWorkspace: "/home/pi/remote-app", + workspaces: ["/home/pi/other-app", "/home/pi/remote-app"], + }); + + expect(readWorkspaceSelectionFromWindow("local")).toEqual({ + lastWorkspace: "/Users/dev/local-app", + workspaces: ["/Users/dev/local-app"], + }); + expect(readWorkspaceSelectionFromWindow("pi-host")).toEqual({ + lastWorkspace: "/home/pi/remote-app", + workspaces: ["/home/pi/other-app", "/home/pi/remote-app"], + }); + expect( + JSON.parse( + window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY) ?? "{}", + ), + ).toEqual({ + environments: { + local: { + lastWorkspace: "/Users/dev/local-app", + workspaces: ["/Users/dev/local-app"], + }, + "pi-host": { + lastWorkspace: "/home/pi/remote-app", + workspaces: ["/home/pi/other-app", "/home/pi/remote-app"], + }, + }, + }); + }); + it("excludes .cline-internal paths from the workspace catalog", () => { expect( isExcludedWorkspacePath("/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip"), @@ -169,17 +251,25 @@ describe("workspace paths", () => { expect(isExcludedWorkspacePath(temporaryWorkspace)).toBe(true); expect( - workspacePathsFromSessions([ - { workspaceRoot: temporaryWorkspace }, - { workspaceRoot: "/projects/app" }, - ]), + workspacePathsFromSessions( + [ + { workspaceRoot: temporaryWorkspace, environmentId: "local" }, + { workspaceRoot: "/projects/app", environmentId: "local" }, + ], + "local", + ), ).toEqual(["/projects/app"]); expect( parseWorkspaceSelectionStorage( JSON.stringify({ - lastWorkspace: temporaryWorkspace, - workspaces: [temporaryWorkspace, "/projects/app"], + environments: { + local: { + lastWorkspace: temporaryWorkspace, + workspaces: [temporaryWorkspace, "/projects/app"], + }, + }, }), + "local", ), ).toEqual({ lastWorkspace: "", @@ -230,13 +320,19 @@ describe("workspace paths", () => { }); it("filters excluded paths out of session-derived workspaces", () => { - const paths = workspacePathsFromSessions([ - { workspaceRoot: "/projects/app" }, - { workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip" }, - { cwd: "/Users/beatrix/Desktop" }, - { cwd: "/Users/beatrix" }, - { cwd: "/projects/tool" }, - ]); + const paths = workspacePathsFromSessions( + [ + { workspaceRoot: "/projects/app", environmentId: "local" }, + { + workspaceRoot: "/Users/beatrix/.cline/worktrees/97815/sdk-wip", + environmentId: "local", + }, + { cwd: "/Users/beatrix/Desktop", environmentId: "local" }, + { cwd: "/Users/beatrix", environmentId: "local" }, + { cwd: "/projects/tool", environmentId: "local" }, + ], + "local", + ); expect(paths).toEqual(["/projects/app", "/projects/tool"]); }); @@ -245,13 +341,18 @@ describe("workspace paths", () => { expect( parseWorkspaceSelectionStorage( JSON.stringify({ - lastWorkspace: "/Users/beatrix/Desktop", - workspaces: [ - "/projects/one", - "/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip", - "/Users/beatrix", - ], + environments: { + local: { + lastWorkspace: "/Users/beatrix/Desktop", + workspaces: [ + "/projects/one", + "/Users/beatrix/.cline/worktrees/5e0b3/sdk-wip", + "/Users/beatrix", + ], + }, + }, }), + "local", ), ).toEqual({ lastWorkspace: "/Users/beatrix/Desktop", @@ -261,4 +362,42 @@ describe("workspace paths", () => { filterWorkspacePaths(["/projects/one", "/Users/beatrix/Desktop"]), ).toEqual(["/projects/one"]); }); + + it("scopes stored and session-derived workspaces by environment", () => { + const raw = JSON.stringify({ + environments: { + local: { + lastWorkspace: "/Users/dev/local-app", + workspaces: ["/Users/dev/local-app"], + }, + "pi-host": { + lastWorkspace: "/home/pi/remote-app", + workspaces: ["/home/pi/remote-app"], + }, + }, + }); + + expect(parseWorkspaceSelectionStorage(raw, "local").workspaces).toEqual([ + "/Users/dev/local-app", + ]); + expect(parseWorkspaceSelectionStorage(raw, "pi-host").workspaces).toEqual([ + "/home/pi/remote-app", + ]); + expect( + workspacePathsFromSessions( + [ + { workspaceRoot: "/Users/dev/local-app", environmentId: "local" }, + { + workspaceRoot: "/home/pi/remote-app", + environmentId: "pi-host", + }, + { + workspaceRoot: "/home/other/app", + environmentId: "other-host", + }, + ], + "pi-host", + ), + ).toEqual(["/home/pi/remote-app"]); + }); }); diff --git a/apps/examples/desktop-app/webview/lib/workspace-paths.ts b/apps/examples/desktop-app/webview/lib/workspace-paths.ts index 9423a3b7c0..5d86dfd51a 100644 --- a/apps/examples/desktop-app/webview/lib/workspace-paths.ts +++ b/apps/examples/desktop-app/webview/lib/workspace-paths.ts @@ -1,18 +1,25 @@ import { isChatWorkspacePath } from "@cline/shared/browser"; export const WORKSPACE_SELECTION_STORAGE_KEY = - "cline.code.workspace-selection.v1"; + "cline.code.workspace-selection.v2"; + +export const LOCAL_WORKSPACE_ENVIRONMENT_ID = "local"; export type WorkspaceSelectionStorage = { lastWorkspace: string; workspaces: string[]; }; +type WorkspaceSelectionStore = { + environments: Record; +}; + export type WorkspacePathSource = { cwd?: string; workspaceRoot?: string; startedAt?: string; endedAt?: string; + environmentId: string; }; /** Typed/pasted folder paths in search boxes double as manual path entry. */ @@ -144,9 +151,13 @@ export function filterWorkspacePaths(paths: readonly string[]): string[] { */ export function workspacePathsFromSessions( sessions: readonly WorkspacePathSource[], + environmentId: string, ): string[] { + const scopedSessions = sessions.filter( + (session) => session.environmentId === environmentId, + ); const lastActivityByPath = new Map(); - for (const session of sessions) { + for (const session of scopedSessions) { const normalized = normalizeWorkspacePath( session.workspaceRoot || session.cwd || "", ); @@ -164,7 +175,9 @@ export function workspacePathsFromSessions( } return filterWorkspacePaths( mergeWorkspacePaths( - sessions.map((session) => session.workspaceRoot || session.cwd || ""), + scopedSessions.map( + (session) => session.workspaceRoot || session.cwd || "", + ), ), ).sort((a, b) => { const aTime = lastActivityByPath.get(normalizeWorkspacePath(a)) ?? 0; @@ -175,24 +188,27 @@ export function workspacePathsFromSessions( export function parseWorkspaceSelectionStorage( raw: string | null, + environmentId: string, ): WorkspaceSelectionStorage { if (!raw) { return { lastWorkspace: "", workspaces: [] }; } try { const parsed = JSON.parse(raw) as { - lastWorkspace?: unknown; - workspaces?: unknown; + environments?: Record; }; + const selected = parsed.environments?.[environmentId] as + | { lastWorkspace?: unknown; workspaces?: unknown } + | undefined; const parsedLastWorkspace = - typeof parsed?.lastWorkspace === "string" - ? parsed.lastWorkspace.trim() + typeof selected?.lastWorkspace === "string" + ? selected.lastWorkspace.trim() : ""; const lastWorkspace = isChatWorkspacePath(parsedLastWorkspace) ? "" : parsedLastWorkspace; - const workspaces = Array.isArray(parsed?.workspaces) - ? parsed.workspaces.filter( + const workspaces = Array.isArray(selected?.workspaces) + ? selected.workspaces.filter( (workspace): workspace is string => typeof workspace === "string", ) : []; @@ -207,13 +223,16 @@ export function parseWorkspaceSelectionStorage( } } -export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage { +export function readWorkspaceSelectionFromWindow( + environmentId: string, +): WorkspaceSelectionStorage { if (typeof window === "undefined") { return { lastWorkspace: "", workspaces: [] }; } try { return parseWorkspaceSelectionStorage( window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY), + environmentId, ); } catch { return { lastWorkspace: "", workspaces: [] }; @@ -221,22 +240,40 @@ export function readWorkspaceSelectionFromWindow(): WorkspaceSelectionStorage { } export function writeWorkspaceSelectionToWindow( + environmentId: string, value: WorkspaceSelectionStorage, ): void { if (typeof window === "undefined") { return; } try { + const current = (() => { + try { + const parsed = JSON.parse( + window.localStorage.getItem(WORKSPACE_SELECTION_STORAGE_KEY) ?? "{}", + ) as Partial; + return parsed.environments && typeof parsed.environments === "object" + ? parsed.environments + : {}; + } catch { + return {}; + } + })(); const lastWorkspace = isChatWorkspacePath(value.lastWorkspace) ? "" : value.lastWorkspace.trim(); window.localStorage.setItem( WORKSPACE_SELECTION_STORAGE_KEY, JSON.stringify({ - lastWorkspace, - workspaces: filterWorkspacePaths( - mergeWorkspacePaths(value.workspaces, [lastWorkspace]), - ), + environments: { + ...current, + [environmentId]: { + lastWorkspace, + workspaces: filterWorkspacePaths( + mergeWorkspacePaths(value.workspaces, [lastWorkspace]), + ), + }, + }, }), ); } catch {