feat(desktop): integrate SSH remote environments (#14117)

* feat(core): add SSH remote environments

* feat(desktop): integrate SSH remote environments

* fix(core): harden SSH lifecycle and helper package exports

* fix(desktop): correct SSH credentials, history and packaging

* fix(core): use current SSH identity for pending cleanup

* docs(core): clarify SSH destination invariants during cleanup

* fix(desktop): restore remote history before reopening SSH sessions

* fix(core): recover SSH cleanup after remote Hub crashes

* test(core): make SSH regression coverage portable on Windows

* fix(core): leave account connectors untouched by SSH Hubs

* fix(core): restore missing SSH helpers for pending cleanup

* fix(desktop): route remote detach and refresh SSH profiles

* UI clean up

* fix(desktop): open remote settings from environment selector

* fix(desktop): find bundled SSH helpers in Tauri's Linux resource directory

Tauri's deb, rpm, and AppImage bundles install binaries under usr/bin and
resources under usr/lib/<productName>. The sidecar only probed the macOS
and Windows layouts, so packaged Linux builds could not locate the remote
helper and every SSH connect failed with "no compatible remote helper".

* fix(desktop): drive the environment selector from the thread's environment

The selector showed the globally connected environment while prompts were
routed to the active thread's environment, so navigating Back from an SSH
draft to a local draft displayed SSH while sending to the local machine.
Re-selecting the already-connected host now just opens its draft instead
of tearing down and rebuilding the remote runtime.

* build(desktop): bundle only Linux remote helpers

The macOS helpers land in Contents/Resources as Mach-O files. Tauri only
codesigns frameworks, externalBin, and the main binaries, and an unsigned
Mach-O anywhere in the bundle fails notarization, so shipping them would
break the next desktop-publish run. Linux x64 and arm64 cover common SSH
hosts; macOS targets can still use CLINE_REMOTE_HELPER_BINARY.

* style(desktop): format Linux remote helper test

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Bee
2026-09-17 10:16:11 -07:00
committed by GitHub
co-authored by Saoud Rizwan Saoud Rizwan
parent 9e12953bed
commit 419829e9d0
63 changed files with 7034 additions and 1249 deletions
+60 -1
View File
@@ -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.
+1 -1
View File
@@ -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) => {
@@ -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<string> => {
const outfile = sidecarOutfile(targetTriple);
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
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 `<binary> --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<void> => {
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
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
@@ -67,13 +103,18 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
};
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) => {
@@ -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<void> {
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;
});
@@ -84,6 +84,42 @@ describe("rewriteDesktopTeamPrompt", () => {
}
});
});
function localRuntimeContext(
sessionManager: Record<string, unknown>,
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<string, unknown> {
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<unknown[]>;
@@ -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",
+346 -76
View File
@@ -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>,
): 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<JsonRecord> {
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<unknown> {
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<void> {
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<unknown> {
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<unknown> {
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<unknown> {
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,
);
}
@@ -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",
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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;
}
+304 -73
View File
@@ -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<SidecarContext, Promise<void>>();
// Helpers — WebSocket broadcast
// ---------------------------------------------------------------------------
// Session state belongs to a runtime environment, not to a globally unique ID.
const environmentContexts = new WeakMap<
SidecarContext,
Map<string, SidecarContext>
>();
const contextOwners = new WeakMap<SidecarContext, SidecarContext>();
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<void> {
const cleanup: Array<Promise<unknown>> = [];
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<SessionRuntimeBinding | undefined> {
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<void> {
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<SessionRuntimeBinding["remote"]>,
): Promise<SessionRuntimeBinding> {
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<unknown>[] = [
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<void> {
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<NodeHubClient> {
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);
+3 -6
View File
@@ -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<void> {
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();
+2 -14
View File
@@ -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", () => {
@@ -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<typeof import("@cline/core")>("@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<void> {}
public constructor(options: unknown) {
hubClientConstructorMock(options);
}
public connect(): Promise<void> {
return hubConnectMock();
}
public subscribe(listener: unknown): () => void {
return hubSubscribeMock(listener);
}
public dispose(): Promise<void> {
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<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
test: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
};
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<string, RemoteEnvironmentConnection>();
let activeProfileId: string | undefined;
const list = vi.fn(async () => profiles);
const upsert = vi.fn(async () => profile);
const test = vi.fn(
async (): Promise<RemoteEnvironmentStatus> => ({
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<typeof vi.fn> {
const send = vi.fn();
ctx.wsClients.add({ send });
return send;
}
function readEvent(send: ReturnType<typeof vi.fn>, 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<typeof vi.fn>;
};
const hubClient = {
dispose: vi.fn(async () => undefined),
} as unknown as SessionRuntimeBinding["hubClient"] & {
dispose: ReturnType<typeof vi.fn>;
};
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();
});
});
@@ -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 });
}
});
@@ -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/<productName>`. 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);
}
@@ -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<string, Record<string, unknown>>(),
refresh: vi.fn(),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@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 });
}
});
});
@@ -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, {
@@ -333,17 +333,22 @@ export async function readSessionMessages(
ctx: Pick<SidecarContext, "liveSessions">,
sessionId: string,
maxMessages = 800,
remoteMessages?: MessageWithMetadata[],
): Promise<unknown[]> {
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<number, StoredCheckpointEntry>()
: readCheckpointEntriesByRunCount(sessionId);
const pendingToolMessages = new Map<string, [number, string, unknown]>();
let userRunCount = 0;
for (let idx = 0; idx < start; idx += 1) {
@@ -2,13 +2,13 @@ import { getFileIndex } from "@cline/core";
import type { SidecarContext } from "../types";
export function searchWorkspaceFiles(
ctx: Pick<SidecarContext, "workspaceRoot">,
ctx: Pick<SidecarContext, "localWorkspaceRoot">,
args?: Record<string, unknown>,
): Promise<string[]> {
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 =
@@ -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 <cmd>`,
* 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");
});
});
@@ -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<string | undefined> {
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 };
}
+20 -4
View File
@@ -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<string, unknown>;
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<string, string[]>;
};
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<SidecarWebSocketClient>;
pendingApprovals: Map<string, PendingToolApproval>;
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
workspaceRoot: string;
runtimeBindings: Map<string, SessionRuntimeBinding>;
sessionEnvironmentIds: Map<string, string>;
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.
@@ -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",
@@ -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;
+464 -55
View File
@@ -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<SettingsSection>,
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<OnboardingStep>("welcome");
const environmentSelectionRevision = useRef(0);
const [activeRemoteEnvironment, setActiveRemoteEnvironment] =
useState<RemoteWorkspaceEnvironment | null>(null);
const [remoteEnvironmentProfiles, setRemoteEnvironmentProfiles] = useState<
RemoteEnvironmentProfile[]
>([]);
const [
remoteEnvironmentProfilesLoading,
setRemoteEnvironmentProfilesLoading,
] = useState(true);
const [remoteDirectoryPicker, setRemoteDirectoryPicker] =
useState<RemoteWorkspaceEnvironment | null>(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<ProcessContext>("get_process_context")
.then((context) => {
if (!cancelled && revision === environmentSelectionRevision.current) {
const remoteEnvironment =
remoteWorkspaceEnvironmentFromContext(context);
setActiveRemoteEnvironment(remoteEnvironment);
if (remoteEnvironment) {
dispatchApp({
type: "select-environment-draft",
threadId: makeThreadId(),
environmentId: remoteEnvironment.id,
});
}
}
})
.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<RemoteEnvironmentListResult>("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<RemoteEnvironmentConnectResult>(
"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<string | null> => {
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<SessionHistoryItem | null>(
"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 (
<AccountProvider>
@@ -490,16 +788,44 @@ export default function Home() {
inert={view === "settings" ? true : undefined}
>
<ChatThreadPane
key={activeThread.id}
key={`${activeThread.id}:${activeThread.environmentId}`}
environmentId={activeThread.environmentId}
environmentProfiles={remoteEnvironmentProfiles}
environmentProfilesLoading={
remoteEnvironmentProfilesLoading
}
onAddSshHost={() => 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 ? (
<RemoteDirectoryPicker
environmentId={remoteDirectoryPicker.id}
homeDir={remoteDirectoryPicker.homeDir}
onCancel={() => completeRemoteDirectoryPicker(null)}
onSelect={completeRemoteDirectoryPicker}
open
/>
) : null}
</AccountProvider>
);
}
@@ -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<void>;
onOpenSessionById?: (
sessionId: string,
environmentId?: string,
) => void | Promise<void>;
onPickRemoteWorkspaceDirectory: (
environment: RemoteWorkspaceEnvironment,
) => Promise<string | null>;
onSelectEnvironment: (environmentId: string) => Promise<void>;
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<boolean> => {
@@ -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<boolean> => {
@@ -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<string | null>(
"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 = (
<ChatInputBar
attachments={attachmentList}
environmentId={environmentId}
hasRunningAgents={agentActivity.running > 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 ? (
<DiffView
cwd={config.cwd || config.workspaceRoot}
environmentId={environmentId}
fileDiffs={fileDiffs}
onClose={() => setShowDiffView(false)}
/>
@@ -1656,6 +2056,15 @@ function ChatThreadPane({
)
}
composer={composer}
environmentSelector={
<EnvironmentSelector
activeEnvironmentId={environmentId}
loading={environmentProfilesLoading}
onAddSshHost={onAddSshHost}
onSelectEnvironment={onSelectEnvironment}
profiles={environmentProfiles}
/>
}
gitBranch={gitBranch}
notice={
providersLoaded &&
@@ -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,
@@ -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", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -1105,6 +1125,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -1192,6 +1213,7 @@ describe("ChatInputBar", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -2307,6 +2329,7 @@ describe("ChatInputBar token ring", () => {
>
<ChatInputBar
attachments={[]}
environmentId="local"
gitBranch="main"
mode="act"
model="test-model"
@@ -286,7 +286,16 @@ export type PromptDraft = {
value: string;
};
export function buildWorkspaceFileSearchKey(
environmentId: string,
workspaceRoot: string,
query: string,
): string {
return JSON.stringify([environmentId, workspaceRoot, query]);
}
type ChatInputBarProps = {
environmentId: string;
variant?: "conversation" | "welcome";
status: ChatSessionStatus;
hasRunningAgents?: boolean;
@@ -331,6 +340,7 @@ type ChatInputBarProps = {
};
function ChatInputBarImpl({
environmentId,
variant = "conversation",
status,
hasRunningAgents = false,
@@ -927,7 +937,11 @@ function ChatInputBarImpl({
return;
}
const requestKey = `${workspaceRoot}::${activeMention.query}`;
const requestKey = buildWorkspaceFileSearchKey(
environmentId,
workspaceRoot,
activeMention.query,
);
if (mentionLastRequestKeyRef.current === requestKey) {
return;
}
@@ -949,6 +963,7 @@ function ChatInputBarImpl({
const results = await desktopClient.invoke<string[]>(
"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) => {
@@ -143,6 +143,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -159,6 +160,7 @@ describe("DiffView file actions", () => {
root.render(
<DiffView
cwd="/Users/renee/cline"
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
@@ -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(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
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(<DiffView fileDiffs={[FILE_DIFF]} onClose={vi.fn()} />);
root.render(
<DiffView
environmentId="local"
fileDiffs={[FILE_DIFF]}
onClose={vi.fn()}
/>,
);
});
await click(buttonWithLabel("Copy file path for docs/a.mdx"));
@@ -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<Set<string>>(new Set());
const [editors, setEditors] = useState<EditorOption[]>([]);
@@ -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 (
@@ -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<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
await Promise.resolve();
});
}
async function pointerDown(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
button: 0,
}),
);
await Promise.resolve();
});
}
function trigger(): HTMLButtonElement {
const element = container.querySelector<HTMLButtonElement>(
"#environment-selector-btn",
);
expect(element).not.toBeNull();
return element as HTMLButtonElement;
}
function menuItemContaining(text: string): HTMLElement {
const item = Array.from(
document.querySelectorAll<HTMLElement>('[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(
<EnvironmentSelector
activeEnvironmentId="pi-server"
onAddSshHost={onAddSshHost}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
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(
<EnvironmentSelector
activeEnvironmentId="local"
onAddSshHost={vi.fn()}
onSelectEnvironment={onSelectEnvironment}
profiles={profiles}
/>,
);
});
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);
});
});
@@ -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<void>;
onAddSshHost: () => void;
};
export function buildEnvironmentSelectorModel(
activeEnvironmentId: string,
profiles: RemoteEnvironmentProfile[],
): EnvironmentSelectorModel {
const remoteById = new Map<string, EnvironmentSelectorOption>();
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<string | null>(
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 (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Connecting
</span>
);
}
return null;
};
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Environment: ${model.activeLabel}`}
className="size-9 shrink-0 rounded-md border border-border/70 bg-background/80 p-0 text-foreground shadow-none transition-colors hover:bg-accent hover:text-foreground"
disabled={busy}
id="environment-selector-btn"
title={`Environment: ${model.activeLabel}`}
variant="ghost"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ActiveIcon className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72" side="bottom">
<DropdownMenuItem
aria-current={model.local.selected ? "true" : undefined}
className="aria-current:bg-purple-500/20 aria-current:focus:bg-purple-500/25"
disabled={busy}
onSelect={() => void selectEnvironment(model.local.id)}
>
<Laptop />
<span className="uppercase">{model.local.label}</span>
{optionStatus(model.local)}
{model.local.selected ? <Check className="ml-auto" /> : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Cloud className="size-4" />
<span>Cloud</span>
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] font-normal normal-case tracking-normal text-muted-foreground">
Coming soon
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex items-center justify-between">
<DropdownMenuLabel className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Server className="size-4" />
Remote
</DropdownMenuLabel>
<DropdownMenuItem
aria-label="Add SSH Host"
title="Add SSH Host"
className="mr-1 size-6 justify-center p-0"
disabled={busy}
onSelect={onAddSshHost}
>
<Settings className="size-3.5" />
</DropdownMenuItem>
</div>
{model.remotes.length > 0 ? (
model.remotes.map((option) => (
<DropdownMenuItem
aria-current={option.selected ? "true" : undefined}
className="aria-current:bg-purple-500/20 aria-current:focus:bg-purple-500/25"
disabled={busy}
key={option.id}
onSelect={() => void selectEnvironment(option.id)}
>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{optionStatus(option)}
{option.selected ? <Check className="ml-auto" /> : null}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<span className="text-muted-foreground">No SSH hosts saved</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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<void> {
const button = [
...document.querySelectorAll<HTMLButtonElement>("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<string, unknown>) => {
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(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={onSelect}
open
/>,
);
});
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(
<RemoteDirectoryPicker
environmentId="pi-host"
homeDir="/home/pi"
onCancel={vi.fn()}
onSelect={vi.fn()}
open
/>,
);
});
await vi.waitFor(() => {
expect(document.body.textContent).toContain(
"Directory response belongs to other-host, not pi-host.",
);
});
});
});
@@ -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<string | null>(null);
const [directories, setDirectories] = useState<
Array<{ name: string; path: string }>
>([]);
const [truncated, setTruncated] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<WorkspaceDirectoryListResult>("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 (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
<DialogContent className="gap-4 sm:max-w-xl">
<DialogHeader>
<DialogTitle>Choose remote workspace</DialogTitle>
<DialogDescription>
Browse directories on the connected SSH host. No local folders are
shown here.
</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2">
<Button
aria-label="Remote home directory"
disabled={loading || currentPath === normalizedHome}
onClick={() => setRequestedPath(normalizedHome)}
size="icon"
variant="outline"
>
<Home />
</Button>
<Button
aria-label="Parent remote directory"
disabled={loading || !canGoUp}
onClick={() => parentPath && setRequestedPath(parentPath)}
size="icon"
variant="outline"
>
<ArrowUp />
</Button>
<p
className="min-w-0 flex-1 truncate rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs"
title={currentPath}
>
{currentPath}
</p>
<Button
aria-label="Refresh remote directories"
disabled={loading}
onClick={() => setReloadVersion((version) => version + 1)}
size="icon"
variant="outline"
>
<RefreshCw className={loading ? "animate-spin" : undefined} />
</Button>
</div>
<div className="min-h-56 rounded-md border p-1.5">
{loading ? (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading remote directories
</div>
) : error ? (
<div className="flex h-52 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-destructive">
<CircleAlert className="size-5" />
{error}
</div>
) : directories.length === 0 ? (
<div className="flex h-52 items-center justify-center text-sm text-muted-foreground">
No subdirectories
</div>
) : (
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
{directories.map((entry) => {
return (
<Button
className="h-auto w-full justify-start gap-2 px-2 py-2 text-left"
key={entry.path}
onClick={() => setRequestedPath(entry.path)}
variant="ghost"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-sm">
{entry.name}
</span>
</Button>
);
})}
</div>
)}
</div>
{truncated && !loading && !error ? (
<p className="text-xs text-muted-foreground">
Only the first directories are shown. Open a folder to continue
browsing.
</p>
) : null}
<DialogFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
disabled={loading || Boolean(error)}
onClick={() => onSelect(currentPath)}
>
Use this folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<boolean>;
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: (
<button data-testid="environment-selector" type="button">
Local
</button>
),
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",
@@ -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<boolean>;
onOpenSession?: (sessionId: string) => void | Promise<void>;
@@ -136,7 +138,8 @@ export function WelcomeScreen({
<h1 className="sr-only">What would you like to build?</h1>
<AgentWelcomeHero />
<div className="mt-11 flex min-w-0 items-center">
<div className="mt-11 flex min-w-0 items-center gap-2">
{environmentSelector}
<WelcomeWorkspaceControls
currentBranch={gitBranch}
onListGitBranches={onListGitBranches}
@@ -30,6 +30,7 @@ const thread: SessionThread = {
const session: SessionHistoryItem = {
sessionId: thread.id,
environmentId: "local",
status: "completed",
provider: thread.provider,
model: thread.model,
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, type HTMLAttributes } 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 { RemoteEnvironmentsContent } from "./remote-environments-view";
const { invokeMock } = vi.hoisted(() => ({
invokeMock: vi.fn(),
}));
vi.mock("@/lib/desktop-client", () => ({
desktopClient: { invoke: invokeMock },
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
}));
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<HTMLButtonElement>("button"),
].find((candidate) => candidate.textContent?.includes(text));
expect(button).toBeDefined();
return button as HTMLButtonElement;
}
function inputById(id: string): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>(`#${id}`);
expect(input).not.toBeNull();
return input as HTMLInputElement;
}
async function click(element: Element): Promise<void> {
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(<RemoteEnvironmentsContent />);
});
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(<RemoteEnvironmentsContent />);
});
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(<RemoteEnvironmentsContent />);
});
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,
});
});
});
@@ -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 (
<div className="flex min-w-0 items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{label}</span>
<Badge
className={cn(
isPositive &&
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
isPending &&
"border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
isError && "border-destructive/30 bg-destructive/10 text-destructive",
)}
variant="outline"
>
{isPending ? <Loader2 className="animate-spin" /> : null}
{isPositive ? <CheckCircle2 /> : null}
{isError ? <CircleAlert /> : null}
{statusLabel(value)}
</Badge>
</div>
);
}
function runtimeStateFor(
states: Record<string, RemoteEnvironmentRuntimeState>,
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<RemoteEnvironmentProfile[]>([]);
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
null,
);
const [draft, setDraft] = useState<RemoteEnvironmentProfile>(() =>
createRemoteEnvironmentDraft(),
);
const [runtimeStates, setRuntimeStates] = useState<
Record<string, RemoteEnvironmentRuntimeState>
>({});
const [isLoading, setIsLoading] = useState(true);
const [busyAction, setBusyAction] = useState<BusyAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] =
useState<RemoteEnvironmentProfile | null>(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<RemoteEnvironmentRuntimeState>
| ((
current: RemoteEnvironmentRuntimeState,
) => Partial<RemoteEnvironmentRuntimeState>),
) => {
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<RemoteEnvironmentListResult>(
"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 extends keyof RemoteEnvironmentProfile>(
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<RemoteEnvironmentProfile> => {
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<RemoteEnvironmentUpsertResult>(
"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<RemoteEnvironmentTestResult>(
"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<RemoteEnvironmentDeleteResult>(
"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 (
<PageFrame>
<PageHeader
actions={
<Button
aria-label="Refresh remote environments"
disabled={isLoading || isBusy}
onClick={() => void loadProfiles()}
variant="ghost"
size="icon-sm"
>
<RefreshCw className={cn(isLoading && "animate-spin")} />
</Button>
}
title="Remote Environments"
description="Manage your remote SSH hosts and their configurations."
/>
{error ? (
<Alert className="mb-5" variant="destructive">
<CircleAlert />
<AlertTitle>Remote environment error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-[minmax(16rem,0.75fr)_minmax(24rem,1.25fr)] gap-5 max-[960px]:grid-cols-1">
<Card className="h-112 gap-4 overflow-hidden py-5">
<CardHeader className="flex shrink-0 flex-row items-center justify-between gap-3 px-5">
<CardTitle className="flex items-center gap-2">
SSH Hosts
<Badge variant="secondary">{profiles.length}</Badge>
</CardTitle>
<Button
disabled={isBusy}
onClick={startNewProfile}
variant="default"
size="xs"
>
<Plus />
New Host
</Button>
</CardHeader>
<CardContent className="min-h-0 flex-1 space-y-2 overflow-y-auto px-3">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading SSH hosts
</div>
) : profiles.length === 0 ? (
<PageEmptyState>
No SSH hosts yet. Add the address for your first remote
environment.
</PageEmptyState>
) : (
profiles.map((profile) => {
const runtime = runtimeStateFor(
runtimeStates,
profile.id,
activeProfileId,
);
const isSelected = profile.id === selectedProfileId;
const isActive = profile.id === activeProfileId;
return (
<button
aria-current={isSelected ? "true" : undefined}
className={cn(
"w-full rounded-lg border border-transparent px-3 py-3 text-left transition-colors hover:bg-accent/60",
isSelected && "border-border bg-accent",
)}
key={profile.id ?? `${profile.host}:${profile.port}`}
onClick={() => selectProfile(profile)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{profile.name}
</p>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
{formatRemoteEnvironmentDestination(profile)}
</p>
</div>
{isActive ? (
<Badge
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
variant="outline"
>
Active
</Badge>
) : runtime.connection === "connecting" ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="gap-5 py-5">
<CardHeader className="px-5">
<div className="flex items-center justify-between gap-3">
<CardTitle>
{selectedProfile?.name ?? "Adding New Host..."}
</CardTitle>
{draft.id ? (
<Button
disabled={isBusy}
onClick={() => setDeleteTarget(draft)}
variant="ghost"
size="icon-lg"
>
<Trash2 />
</Button>
) : null}
</div>
{hasSavedDestination ? (
<CardDescription>
Create a new host to change the SSH host, user, or port.
</CardDescription>
) : null}
</CardHeader>
<CardContent className="space-y-5 px-5">
<div className="grid grid-cols-2 gap-4 max-[620px]:grid-cols-1">
<div className="space-y-2">
<Label htmlFor="remote-name">Name</Label>
<Input
disabled={isBusy}
id="remote-name"
onChange={(event) => updateDraft("name", event.target.value)}
placeholder="Build server"
value={draft.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-host">SSH host</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-host"
onChange={(event) => updateDraft("host", event.target.value)}
placeholder="dev.example.com or ssh-config-alias"
spellCheck={false}
value={draft.host}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-user">User (optional)</Label>
<Input
autoCapitalize="none"
disabled={isBusy || hasSavedDestination}
id="remote-user"
onChange={(event) => updateDraft("user", event.target.value)}
placeholder="ubuntu"
spellCheck={false}
value={draft.user ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remote-port">Port</Label>
<Input
disabled={isBusy || hasSavedDestination}
id="remote-port"
max={65_535}
min={1}
onChange={(event) =>
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
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="remote-identity">Identity file (optional)</Label>
<Input
disabled={isBusy}
id="remote-identity"
onChange={(event) =>
updateDraft("identityFile", event.target.value)
}
placeholder="~/.ssh/id_ed25519"
spellCheck={false}
value={draft.identityFile ?? ""}
/>
<p className="text-xs text-muted-foreground">
Password sign-in is not supported. The host key must already be
trusted in your SSH known_hosts file.
</p>
</div>
{formError ? (
<p className="text-sm text-destructive">{formError}</p>
) : null}
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Environment status</p>
</div>
<Button
disabled={isBusy}
onClick={() => void testProfile()}
variant="secondary"
className="text-muted-foreground"
size="sm"
>
{busyAction?.action === "test" ? (
<Loader2 className="animate-spin" />
) : (
<Plug />
)}
Test Connection
</Button>
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3 max-[620px]:grid-cols-1">
<StatusBadge
label="Environment"
value={draft.id === activeProfileId ? "active" : "inactive"}
/>
<StatusBadge
label="Connection"
value={selectedRuntime.connection}
/>
<StatusBadge
label="Connection test"
value={selectedRuntime.test}
/>
<StatusBadge
label="Cline setup"
value={selectedRuntime.bootstrap}
/>
</div>
{selectedRuntime.remotePlatform || selectedRuntime.remoteArch ? (
<p className="mt-3 text-xs text-muted-foreground">
Remote:{" "}
{[selectedRuntime.remotePlatform, selectedRuntime.remoteArch]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{selectedRuntime.message ? (
<p
className={cn(
"mt-3 text-xs leading-5 text-muted-foreground",
(selectedRuntime.connection === "error" ||
selectedRuntime.test === "failed" ||
selectedRuntime.bootstrap === "failed") &&
"text-destructive",
)}
>
{selectedRuntime.message}
</p>
) : null}
</div>
<div className="flex justify-end border-t pt-5">
<div className="flex flex-wrap justify-end gap-2">
<Button
disabled={isBusy}
onClick={() => void saveProfile()}
variant="default"
>
{busyAction?.action === "save" ? (
<Loader2 className="animate-spin" />
) : null}
Save
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<AlertDialog
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
open={deleteTarget !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete SSH host?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? `Delete “${deleteTarget.name}” from remote environments? Projects and Cline session data remain on the remote host.`
: "Delete this SSH host from remote environments?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
disabled={isBusy || !deleteTarget}
onClick={() => {
if (deleteTarget) void deleteProfile(deleteTarget);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageFrame>
);
}
@@ -12,6 +12,7 @@ const ALL_SETTINGS_SECTIONS = [
"Channels",
"Schedules",
"Import",
"Remote",
"Account",
] as const;
@@ -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({
<RoutineSchedulesContent onOpenSession={onOpenSession} />
) : activeNav === "Import" ? (
<ImportContent />
) : activeNav === "Remote" ? (
<RemoteEnvironmentsContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
@@ -640,7 +643,7 @@ export function SettingsView({
);
return (
<div className="h-full overflow-hidden bg-background">
<div className="cline-settings-content h-full overflow-hidden bg-background">
<div className="h-full min-h-0 overflow-hidden">{content}</div>
</div>
);
@@ -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,
@@ -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;
@@ -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<string, unknown>[] = [];
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(<HookHarness environmentId={environmentId} />),
);
// 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<string, unknown>) => {
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(<HookHarness environmentId="pi-server" />),
);
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(<HookHarness />));
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",
});
@@ -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<string | null>(null);
const [status, setStatus] = useState<ChatSessionStatus>("idle");
const [isHydratingSession, setIsHydratingSession] = useState(false);
const [config, setConfig] = useState<ChatSessionConfig>(getInitialChatConfig);
const [config, setConfig] = useState<ChatSessionConfig>(() =>
getInitialChatConfig(environmentId),
);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [rawTranscript, setRawTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -750,6 +761,7 @@ export function useChatSession() {
turnEndReconcileTimerRef.current = null;
void desktopClient
.invoke<ChatMessage[]>("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<string, unknown>) => {
const request = { request: body };
if (body.action === "send") {
const postSession = useCallback(
async (body: Record<string, unknown>) => {
const bodyConfig =
body.config &&
typeof body.config === "object" &&
!Array.isArray(body.config)
? (body.config as Record<string, unknown>)
: {};
const request = {
request: {
...body,
config: { ...bodyConfig, environmentId },
},
};
if (body.action === "send") {
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"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<ChatSessionHookEvent[]>(
"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<ProcessContext>(
"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<ToolApprovalRequestItem[]>("poll_tool_approvals", {
environmentId,
sessionId: activeSessionId,
limit: 20,
})
@@ -1254,6 +1296,7 @@ export function useChatSession() {
void desktopClient
.invoke<AskQuestionRequestItem[]>("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<ChatMessage[]>("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<string> => {
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<ChatMessage[]>(
"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<ChatMessage[]>(
"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<ChatMessage[]>(
"read_session_messages",
{
sessionId: nextSessionId,
maxMessages: MAX_MESSAGES,
},
);
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("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<ChatMessage[]>(
"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<ChatMessage[]>(
"read_session_messages",
{
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
},
);
const nextMessages = Array.isArray(payload.messages)
? (payload.messages as ChatMessage[])
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
environmentId,
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
});
return { newSessionId, forkedFromSessionId, messages: nextMessages };
},
[config, postSession, status],
[config, environmentId, postSession, status],
);
const steerPromptInQueue = useCallback(
@@ -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"]);
@@ -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<unknown>(
"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;
@@ -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(<HookHarness />);
});
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(<HookHarness />);
@@ -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();
@@ -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<SessionHistoryItem, "status"> & {
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<SessionMessage[]>("read_session_messages", {
sessionId,
environmentId: session.environmentId,
sessionId: session.sessionId,
maxMessages: 1200,
})
.then(async (sessionMessages): Promise<SessionUsage> => {
@@ -988,7 +997,8 @@ export function useSessionHistory({
const events = await desktopClient.invoke<SessionHookEvent[]>(
"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<SessionMessage[]>("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],
);
@@ -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"),
@@ -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",
});
});
});
@@ -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<SettingsSection extends string> =
| { type: "navigate"; destination: DesktopAppLocation<SettingsSection> }
| { 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<SettingsSection extends string>(
export function createDesktopAppState<SettingsSection extends string>(
initialThreadId: string,
initialSettingsSection: SettingsSection,
initialEnvironmentId: string,
): DesktopAppState<SettingsSection> {
return {
threads: [{ id: initialThreadId }],
threads: [{ id: initialThreadId, environmentId: initialEnvironmentId }],
navigation: createNavigationHistory({
activeThreadId: initialThreadId,
settingsSection: initialSettingsSection,
@@ -98,7 +111,10 @@ export function desktopAppReducer<SettingsSection extends string>(
};
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<SettingsSection extends string>(
},
}),
};
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<SettingsSection extends string>(
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<SettingsSection extends string>(
...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<SettingsSection extends string>(
),
};
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<SettingsSection extends string>(
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<SettingsSection> = {
...state.navigation.current,
@@ -224,7 +301,8 @@ export function desktopAppReducer<SettingsSection extends string>(
return {
...state,
threads: state.threads.map((thread) =>
thread.historySession?.sessionId === action.sessionId
thread.historySession?.sessionId === action.sessionId &&
thread.environmentId === (action.environmentId ?? "local")
? {
...thread,
historySession: {
@@ -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();
@@ -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;
}
@@ -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");
});
});
@@ -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<RemoteEnvironmentProfile, "host" | "user" | "port">,
): 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}`;
}
@@ -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;
@@ -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";
}
@@ -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();
});
});
@@ -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 };
}
@@ -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"]);
});
});
@@ -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<string, WorkspaceSelectionStorage>;
};
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<string, number>();
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<string, unknown>;
};
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<WorkspaceSelectionStore>;
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 {