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 {