mirror of
https://github.com/cline/cline.git
synced 2026-09-13 18:10:14 +08:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b43fb7777c | ||
|
|
8ea71e3278 | ||
|
|
f4abb7e14b | ||
|
|
1e08d8f277 | ||
|
|
8bebcd1f37 | ||
|
|
14f9def7ed | ||
|
|
2e6c25785e | ||
|
|
b565e5b9ca | ||
|
|
0313c34b1a | ||
|
|
d0f49330a3 | ||
|
|
db31380268 | ||
|
|
b123f2eb54 | ||
|
|
303722a559 | ||
|
|
5fff9cbb4c | ||
|
|
70e0a3731d | ||
|
|
d52af3112d | ||
|
|
eccb0db669 | ||
|
|
a9af97a83f | ||
|
|
53388f5f9c | ||
|
|
093e9dd3d5 | ||
|
|
d35f4a043b | ||
|
|
bd9ee23f6c | ||
|
|
10ff456e93 | ||
|
|
cb34ba5cf9 | ||
|
|
2ed4b811be | ||
|
|
7bf542ddab | ||
|
|
1cb61ab925 | ||
|
|
7bf495878e | ||
|
|
677a5cd915 | ||
|
|
4524e884a7 | ||
|
|
75d1d6654b | ||
|
|
45027ee4e3 | ||
|
|
7f8fd0b62d | ||
|
|
6df53ddf64 | ||
|
|
243b6fe9d9 | ||
|
|
edd6e2c29b | ||
|
|
1eb28e553e | ||
|
|
42225f9219 | ||
|
|
da759853ea | ||
|
|
6fed0f0d38 | ||
|
|
b456ce73dd | ||
|
|
2b9d1c3e6a | ||
|
|
82277128aa | ||
|
|
ee4edfeaf6 | ||
|
|
17b0ad7a76 | ||
|
|
be8bb302a8 | ||
|
|
f4f573c395 | ||
|
|
e03b4577b4 | ||
|
|
16d36e53f2 | ||
|
|
74a1910505 | ||
|
|
f3db7a0a98 | ||
|
|
2b62f7b447 | ||
|
|
6b8b5726c3 | ||
|
|
f6d8e54089 | ||
|
|
ef09caa3e6 | ||
|
|
bdb5f30f5e | ||
|
|
2fa0be8940 | ||
|
|
2e5a565090 | ||
|
|
eb95908737 | ||
|
|
b091c72e40 | ||
|
|
ebf5b52e9b | ||
|
|
13d801b578 | ||
|
|
642c2f643b | ||
|
|
528c6b8ff2 | ||
|
|
2f1c94505a | ||
|
|
5b7005b003 | ||
|
|
e48a7c9c91 | ||
|
|
71e1c59ab8 | ||
|
|
d7b61de54f | ||
|
|
7350294f7d | ||
|
|
fe90cbfdf1 | ||
|
|
44cf50cee7 | ||
|
|
b39c7e0319 | ||
|
|
378fdf147b | ||
|
|
9c62406da9 | ||
|
|
a988d6a6ee | ||
|
|
f1ec8c897c | ||
|
|
0b1f083531 | ||
|
|
d79ad49de3 | ||
|
|
ce78c667cf | ||
|
|
2140996303 | ||
|
|
0d1a72f009 | ||
|
|
a6c0cfd5c3 | ||
|
|
17e8c0cbc9 | ||
|
|
e2b97792b1 | ||
|
|
e4a7c19432 | ||
|
|
589f32d13e | ||
|
|
879c085fdb |
@@ -80,6 +80,7 @@ describe("applyInteractiveModelChange", () => {
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
modes: {},
|
||||
providers: {},
|
||||
modes: {},
|
||||
}));
|
||||
|
||||
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -160,6 +162,7 @@ function EditOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
|
||||
const diffPalette = props.theme.diff;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
: displayText;
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
|
||||
const showDetail =
|
||||
errorExpanded && presentation.detail !== presentation.summary.trim();
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
|
||||
import type { BrowserConfig } from "./types";
|
||||
|
||||
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
|
||||
);
|
||||
|
||||
export const providerSettingsManager = new ProviderSettingsManager();
|
||||
export const desktopClientSettingsManager = new ClientSettingsManager({
|
||||
clientId: "desktop",
|
||||
});
|
||||
desktopClientSettingsManager.initializeModesIfMissing(
|
||||
providerSettingsManager.read().modes,
|
||||
);
|
||||
|
||||
export const browserConfig: BrowserConfig = {
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
|
||||
@@ -17,19 +17,25 @@ import {
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
parseProviderModeSettings,
|
||||
readGlobalSettings,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
saveModeSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setTelemetryOptOutGlobally,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
desktopClientSettingsManager,
|
||||
providerSettingsManager,
|
||||
workspaceRoot,
|
||||
} from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
@@ -103,6 +109,7 @@ export async function handleDesktopCommand(
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager, {
|
||||
isClinePassEnabled: true,
|
||||
modeSettings: desktopClientSettingsManager.read().modes,
|
||||
});
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
@@ -112,6 +119,18 @@ export async function handleDesktopCommand(
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
}
|
||||
if (command === "save_mode_settings") {
|
||||
const mode = ProviderModeSchema.parse(args?.mode);
|
||||
const settings =
|
||||
args?.settings == null
|
||||
? undefined
|
||||
: parseProviderModeSettings(mode, args.settings);
|
||||
return await saveModeSettings(
|
||||
providerSettingsManager,
|
||||
{ mode, settings },
|
||||
desktopClientSettingsManager,
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
return saveLocalProviderSettings(providerSettingsManager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from "node:process";
|
||||
import {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
isChatProviderModel,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
@@ -86,12 +87,16 @@ export async function loadModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
}));
|
||||
const models: WebviewProviderModel[] = payload.models
|
||||
.filter(isChatProviderModel)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
inputModalities: model.inputModalities,
|
||||
outputModalities: model.outputModalities,
|
||||
}));
|
||||
ctx.send(peer, { type: "models", providerId: provider, models });
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export type WebviewUsage = {
|
||||
|
||||
export type WebviewProviderModel = Pick<
|
||||
ProviderModel,
|
||||
"id" | "name" | "supportsReasoning"
|
||||
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
|
||||
> & {
|
||||
supportsThinking?: boolean;
|
||||
};
|
||||
|
||||
@@ -16,12 +16,27 @@ export type ProviderModelCatalog = {
|
||||
};
|
||||
|
||||
function toModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? []).map((model) => model.id);
|
||||
return (models ?? [])
|
||||
.filter(
|
||||
(model) =>
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
|
||||
return (models ?? [])
|
||||
.filter((model) => model.supportsReasoning)
|
||||
.filter(
|
||||
(model) =>
|
||||
(model.inputModalities === undefined ||
|
||||
model.inputModalities.includes("text")) &&
|
||||
(model.outputModalities === undefined ||
|
||||
model.outputModalities.includes("text")) &&
|
||||
model.supportsReasoning,
|
||||
)
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
@@ -31,7 +46,10 @@ export function buildProviderModelCatalog(
|
||||
return {
|
||||
providers,
|
||||
enabledProviderIds: providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.filter(
|
||||
(provider) =>
|
||||
provider.enabled && toModelIds(provider.modelList).length > 0,
|
||||
)
|
||||
.map((provider) => provider.id),
|
||||
providerModels: Object.fromEntries(
|
||||
providers.map((provider) => [
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { ProviderModesSettings } from "@cline/shared/browser";
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
name: string;
|
||||
supportsAttachments?: boolean;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
inputModalities?: ModelModality[];
|
||||
outputModalities?: ModelModality[];
|
||||
}
|
||||
|
||||
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
|
||||
|
||||
export type ProviderConfigFieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
@@ -63,6 +69,7 @@ export interface ProviderSettingsUpdate {
|
||||
export interface ProviderCatalogResponse {
|
||||
providers: Provider[];
|
||||
settingsPath: string;
|
||||
modes: ProviderModesSettings;
|
||||
}
|
||||
|
||||
export interface ProviderModelsResponse {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Cline Code Desktop Changelog
|
||||
|
||||
## 0.0.14-beta.1
|
||||
|
||||
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
|
||||
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
|
||||
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
|
||||
- Onboarding now includes a GitHub integration step.
|
||||
- Early proof of concept for running sessions in SSH remote environments.
|
||||
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
|
||||
|
||||
## 0.0.13
|
||||
|
||||
- Added an app font size setting. A slider in Settings scales the interface, and your size is applied before the window paints, so launching no longer flashes at the old size first.
|
||||
|
||||
@@ -30,6 +30,59 @@ 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).
|
||||
|
||||
## SSH Remote Environments (v0)
|
||||
|
||||
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 desktop 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/code/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 in v0. The current helper is roughly 110 MB because it 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.
|
||||
|
||||
The desktop 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 is intentional for the proof of
|
||||
concept so a branch-matched desktop helper cannot disrupt another Cline build.
|
||||
|
||||
v0 intentionally 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
|
||||
|
||||
The framework-neutral color, typography, radius, and navigation contract lives
|
||||
@@ -141,6 +194,16 @@ Desktop transport envelope:
|
||||
## Data + Storage
|
||||
|
||||
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
|
||||
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
|
||||
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
|
||||
`pet.json` manifest with
|
||||
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
|
||||
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
|
||||
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
|
||||
available as a bundled option.
|
||||
Visibility and the selected installed avatar are configured independently under
|
||||
**Settings → General → Desktop avatar**; both values are stored in
|
||||
`~/.cline/avatars/selected.json`.
|
||||
- Canonical replay/export artifact: `<sessionId>.messages.json`.
|
||||
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
|
||||
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14-beta.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
@@ -26,6 +26,10 @@
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "4.0.31",
|
||||
"@ai-sdk/google": "4.0.44",
|
||||
"@ai-sdk/openai": "4.0.41",
|
||||
"@ai-sdk/react": "4.0.44",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -66,6 +70,7 @@
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"ai": "^7.0.58",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -87,7 +92,7 @@
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
|
||||
@@ -38,22 +38,54 @@ 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.
|
||||
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/code-sidecar-${targetTriple}`,
|
||||
"./sidecar/remote-helper.ts",
|
||||
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.
|
||||
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
|
||||
|
||||
const main = async () => {
|
||||
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) => {
|
||||
|
||||
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
|
||||
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
|
||||
});
|
||||
|
||||
it("inlines the cloud-agents toggle only when set at build time", () => {
|
||||
const withoutToggle = defineMap(telemetryDefineArgs({}));
|
||||
expect(withoutToggle).not.toHaveProperty(
|
||||
"process.env.CLINE_CODE_CLOUD_AGENTS",
|
||||
);
|
||||
|
||||
const withToggle = defineMap(
|
||||
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
|
||||
);
|
||||
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
|
||||
});
|
||||
|
||||
it("JSON-escapes values so headers with quotes survive the define", () => {
|
||||
const defines = defineMap(
|
||||
telemetryDefineArgs({
|
||||
|
||||
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
] as const;
|
||||
|
||||
/** Optional build-time overrides for packaged dogfood builds. */
|
||||
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
|
||||
|
||||
/**
|
||||
* Every env var `getTelemetryBuildTimeConfig` reads
|
||||
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
|
||||
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
|
||||
const define = (name: string, value: string) => {
|
||||
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
|
||||
};
|
||||
for (const name of OPTIONAL_SECRET_ENV_VARS) {
|
||||
for (const name of [
|
||||
...OPTIONAL_SECRET_ENV_VARS,
|
||||
...OPTIONAL_FEATURE_ENV_VARS,
|
||||
]) {
|
||||
const value = env[name];
|
||||
if (value) {
|
||||
define(name, value);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ClineCore,
|
||||
ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
SessionSource,
|
||||
toProviderConfig,
|
||||
} from "@cline/core";
|
||||
import { RemoteEnvironmentService } from "../sidecar/remote-environments";
|
||||
|
||||
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: join(temporaryDirectory, "known_hosts"),
|
||||
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 providerConfig = {
|
||||
...toProviderConfig(
|
||||
{ ...settings, model: modelId },
|
||||
{ includeKnownModels: false },
|
||||
),
|
||||
};
|
||||
delete providerConfig.refreshToken;
|
||||
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}`);
|
||||
|
||||
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;
|
||||
});
|
||||
@@ -17,7 +17,11 @@ sidecar/
|
||||
├── server.ts # Bun HTTP server + WebSocket handlers
|
||||
├── context.ts # SidecarContext type and factory
|
||||
├── commands.ts # Command router
|
||||
├── chat-session.ts # Shared-Hub chat session adapter
|
||||
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
|
||||
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
|
||||
├── cline-auth.ts # Refresh-aware Cline auth token resolution
|
||||
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
|
||||
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
|
||||
├── session-data/ # Shared discovery, messages, artifacts, search helpers
|
||||
├── paths.ts # Path resolution
|
||||
├── types.ts # Shared types
|
||||
@@ -89,7 +93,7 @@ online:
|
||||
|
||||
```typescript
|
||||
const pendingApprovals = new Map<string, {
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
resolve: (result: ToolApprovalResult) => void | Promise<void>;
|
||||
request: ToolApprovalRequest;
|
||||
}>();
|
||||
|
||||
@@ -97,6 +101,10 @@ const pendingApprovals = new Map<string, {
|
||||
// When frontend responds → resolve promise
|
||||
```
|
||||
|
||||
Cloud sessions route approvals the same way, but the resolver forwards the
|
||||
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
|
||||
be async.
|
||||
|
||||
### 3. Provider Management — Direct ProviderSettingsManager
|
||||
|
||||
```typescript
|
||||
@@ -138,7 +146,7 @@ Supported commands:
|
||||
|
||||
| Command | Implementation |
|
||||
|---------|---------------|
|
||||
| `chat_session_command` | shared Hub through `ClineCore` |
|
||||
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
|
||||
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
|
||||
| `list_provider_models` | `getLocalProviderModels` |
|
||||
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
|
||||
@@ -147,18 +155,30 @@ Supported commands:
|
||||
| `save_provider_settings` | `saveLocalProviderSettings` |
|
||||
| `add_provider` | `addLocalProvider` |
|
||||
| `run_provider_oauth_login` | `loginLocalProvider` |
|
||||
| `list_chat_sessions` | `SqliteSessionStore` + file discovery |
|
||||
| `list_discovered_sessions` | Merged discovery |
|
||||
| `read_session_messages` | Session data readers |
|
||||
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions (2s budget) |
|
||||
| `list_discovered_sessions` | Merged discovery (local + cloud) |
|
||||
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
|
||||
| `read_session_hooks` | Session data readers |
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
|
||||
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
|
||||
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
|
||||
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
|
||||
| `get_desktop_settings` | `readDesktopSettings()` |
|
||||
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
|
||||
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
|
||||
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
|
||||
| `list_mcp_servers` | Direct file I/O |
|
||||
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
|
||||
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
|
||||
| `upsert_mcp_server` | Direct file I/O |
|
||||
| `delete_mcp_server` | Direct file I/O |
|
||||
| `get_git_branch` | async `execFile("git", ...)` |
|
||||
|
||||
Realtime mode sessions expose only one browser-callable tool, `run_cline`, when
|
||||
the selected realtime model supports tool calling. The webview implements that
|
||||
tool by sending the request through the active Cline chat session and returning
|
||||
its persisted result to the realtime provider for playback. Cline remains the
|
||||
owner of workspace context, agent tools, MCP, approvals, and session history;
|
||||
provider credentials remain in the sidecar.
|
||||
| `list_git_branches` | async `execFile("git", ...)` |
|
||||
| `checkout_git_branch` | async `execFile("git", ...)` |
|
||||
| `search_workspace_files` | `getFileIndex` |
|
||||
|
||||
@@ -13,6 +13,7 @@ import { materializeUserFiles } from "./attachments";
|
||||
import {
|
||||
buildSessionConnectionUpdate,
|
||||
consumeWorkspaceMetadata,
|
||||
copySessionGeneratedArtifacts,
|
||||
handleChatSessionCommand,
|
||||
hasProviderChanged,
|
||||
mergeSessionConfig,
|
||||
@@ -23,6 +24,43 @@ import {
|
||||
} from "./chat-session";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
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("rewriteDesktopTeamPrompt", () => {
|
||||
it("rewrites /team for the core runtime", () => {
|
||||
expect(
|
||||
@@ -197,7 +235,7 @@ describe("pathless session starts", () => {
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { start },
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
const result = (await handleChatSessionCommand(ctx, {
|
||||
@@ -221,15 +259,139 @@ 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",
|
||||
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks sessions initiated by realtime voice with the realtime source", async () => {
|
||||
const start = vi.fn(async () => ({
|
||||
sessionId: "session-realtime",
|
||||
manifest: {
|
||||
cwd: "/workspace/project",
|
||||
workspace_root: "/workspace/project",
|
||||
},
|
||||
manifestPath: "/tmp/session-realtime.json",
|
||||
messagesPath: "/tmp/session-realtime.messages.json",
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
source: "realtime",
|
||||
config: {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/workspace/project",
|
||||
workspaceRoot: "/workspace/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "realtime" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("copies generated artifacts into the forked session", () => {
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-fork-artifacts-"));
|
||||
const sourceSessionId = "source-session";
|
||||
const targetSessionId = "forked-session";
|
||||
const sourceArtifactsDir = join(sessionsDir, sourceSessionId, "artifacts");
|
||||
|
||||
try {
|
||||
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
|
||||
mkdirSync(sourceArtifactsDir, { recursive: true });
|
||||
writeFileSync(join(sourceArtifactsDir, "generated.mp4"), "video");
|
||||
writeFileSync(join(sourceArtifactsDir, "generated.mp3"), "audio");
|
||||
|
||||
copySessionGeneratedArtifacts(sourceSessionId, targetSessionId);
|
||||
|
||||
expect(
|
||||
readFileSync(
|
||||
join(sessionsDir, targetSessionId, "artifacts", "generated.mp4"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("video");
|
||||
expect(
|
||||
readFileSync(
|
||||
join(sessionsDir, targetSessionId, "artifacts", "generated.mp3"),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("audio");
|
||||
} finally {
|
||||
if (previousSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
|
||||
}
|
||||
rmSync(sessionsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the selected workspace checkpoint before forking for message editing", async () => {
|
||||
const sourceSessionId = `source-fork-${Date.now()}`;
|
||||
const sourceMessages = [
|
||||
@@ -268,29 +430,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;
|
||||
@@ -392,26 +557,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;
|
||||
@@ -473,20 +641,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(),
|
||||
} as unknown as SidecarContext;
|
||||
@@ -524,7 +695,7 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
sessionManager: { restore },
|
||||
...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -555,13 +726,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(
|
||||
@@ -605,15 +779,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(
|
||||
@@ -645,7 +822,7 @@ describe("session forks", () => {
|
||||
],
|
||||
]),
|
||||
restoringWorkspacePaths: new Set(["/workspace/project"]),
|
||||
sessionManager: { send },
|
||||
...localRuntimeContext({ send }, { sessionIds: [sessionId] }),
|
||||
} as unknown as SidecarContext;
|
||||
|
||||
await expect(
|
||||
@@ -705,17 +882,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,
|
||||
@@ -839,7 +1019,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[]>;
|
||||
@@ -971,15 +1151,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",
|
||||
@@ -1295,6 +1473,97 @@ describe("first-send connection updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("system prompt mode resolution", () => {
|
||||
function createStartContext() {
|
||||
const start = vi.fn(async (input: { config: Record<string, unknown> }) => ({
|
||||
sessionId: "session-mode-test",
|
||||
manifest: {
|
||||
cwd: String(input.config.cwd ?? ""),
|
||||
workspace_root: String(input.config.workspaceRoot ?? ""),
|
||||
},
|
||||
manifestPath: "/tmp/session-mode-test.json",
|
||||
messagesPath: "/tmp/session-mode-test.messages.json",
|
||||
}));
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
...localRuntimeContext({ start }),
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, start };
|
||||
}
|
||||
|
||||
async function startAndCaptureSystemPrompt(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const { ctx, start } = createStartContext();
|
||||
const cwd = String(config.cwd ?? "");
|
||||
// Seed the metadata cache so resolveSystemPrompt does not scan a real
|
||||
// workspace during the test.
|
||||
prewarmWorkspaceMetadata(cwd, async () => "test metadata");
|
||||
await handleChatSessionCommand(ctx, { action: "start", config });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
const input = start.mock.calls[0][0] as {
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
return String(input.config.systemPrompt ?? "");
|
||||
}
|
||||
|
||||
it("keeps the interactive act persona when autoApproveTools is enabled", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-act-auto-approve",
|
||||
mode: "act",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).not.toContain(
|
||||
"user who you cannot communicate with directly",
|
||||
);
|
||||
expect(systemPrompt).toContain("assist users with various coding tasks");
|
||||
});
|
||||
|
||||
it("defaults to act mode when mode is omitted", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-default",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain("assist users with various coding tasks");
|
||||
});
|
||||
|
||||
it("appends plan-mode instructions when mode is plan", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-plan",
|
||||
mode: "plan",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).not.toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain("You are in Plan mode");
|
||||
});
|
||||
|
||||
it("only uses the yolo persona when mode is explicitly yolo", async () => {
|
||||
const systemPrompt = await startAndCaptureSystemPrompt({
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
cwd: "/tmp/cline-desktop-mode-yolo",
|
||||
mode: "yolo",
|
||||
autoApproveTools: true,
|
||||
});
|
||||
|
||||
expect(systemPrompt).toContain("submit_and_exit");
|
||||
expect(systemPrompt).toContain(
|
||||
"user who you cannot communicate with directly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace metadata prewarming", () => {
|
||||
it("reuses one in-flight scan and consumes it only once", async () => {
|
||||
let resolveFirst: ((value: string) => void) | undefined;
|
||||
@@ -1413,18 +1682,20 @@ Follow the desktop send skill 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 };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import {
|
||||
@@ -9,13 +15,17 @@ import {
|
||||
createSessionCompactionState,
|
||||
createUserInstructionConfigService,
|
||||
getCoreBuiltinToolCatalog,
|
||||
ProviderSettingsManager,
|
||||
projectSessionCompactionState,
|
||||
readGlobalSettings,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
RuntimeOAuthTokenManager,
|
||||
type SessionCompactionState,
|
||||
type SessionPendingPrompt,
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
splitCoreSessionConfig,
|
||||
toProviderConfig,
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
@@ -26,7 +36,15 @@ import {
|
||||
materializeUserFiles,
|
||||
trackQueuedAttachments,
|
||||
} from "./attachments";
|
||||
import { emitChunk, nowMs, sendEvent } from "./context";
|
||||
import { getCloudSessionManager } from "./cloud-sessions";
|
||||
import {
|
||||
emitChunk,
|
||||
findSessionRuntimeBinding,
|
||||
getSessionRuntimeBinding,
|
||||
nowMs,
|
||||
sendEvent,
|
||||
} from "./context";
|
||||
import { isCloudAgentsEnabled } from "./feature-flags";
|
||||
import { readSessionManifest, sharedSessionDataDir } from "./paths";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
@@ -34,6 +52,7 @@ import type {
|
||||
JsonRecord,
|
||||
LiveSession,
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
|
||||
@@ -303,6 +322,22 @@ function readPersistedChatMessages(
|
||||
}
|
||||
}
|
||||
|
||||
export function copySessionGeneratedArtifacts(
|
||||
sourceSessionId: string,
|
||||
targetSessionId: string,
|
||||
): void {
|
||||
if (sourceSessionId === targetSessionId) return;
|
||||
const sourceDir = join(sharedSessionDataDir(), sourceSessionId, "artifacts");
|
||||
if (!existsSync(sourceDir)) return;
|
||||
|
||||
const targetDir = join(sharedSessionDataDir(), targetSessionId, "artifacts");
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionMetadataTitle(sessionId: string): string | undefined {
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
const metadata =
|
||||
@@ -342,6 +377,7 @@ function createLiveSession(
|
||||
overrides?: Partial<LiveSession>,
|
||||
): LiveSession {
|
||||
return {
|
||||
environmentId: overrides?.environmentId,
|
||||
config,
|
||||
messages: overrides?.messages ?? [],
|
||||
promptsInQueue: overrides?.promptsInQueue ?? [],
|
||||
@@ -541,11 +577,12 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
|
||||
return String(config.systemPrompt ?? config.system_prompt ?? "").trim();
|
||||
}
|
||||
const providerId = String(config.provider ?? config.providerId ?? "").trim();
|
||||
const mode = config.autoApproveTools
|
||||
? "yolo"
|
||||
: config.mode === "plan"
|
||||
? "plan"
|
||||
: "act";
|
||||
// Mode comes from config.mode only. `autoApproveTools` is a tool-approval
|
||||
// policy (see resolveToolPolicies) and must NOT switch the session to the
|
||||
// non-interactive "yolo" persona, whose prompt demands a `submit_and_exit`
|
||||
// tool that interactive desktop sessions do not expose.
|
||||
const mode =
|
||||
config.mode === "plan" ? "plan" : config.mode === "yolo" ? "yolo" : "act";
|
||||
const metadata = await consumeWorkspaceMetadata(cwd);
|
||||
const inlineRules =
|
||||
typeof config.rules === "string" && config.rules.trim().length > 0
|
||||
@@ -627,9 +664,86 @@ 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 storedConfig = {
|
||||
...toProviderConfig(
|
||||
{
|
||||
...settings,
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
},
|
||||
{ includeKnownModels: false },
|
||||
),
|
||||
};
|
||||
delete storedConfig.refreshToken;
|
||||
const explicitProviderConfig =
|
||||
config.providerConfig && typeof config.providerConfig === "object"
|
||||
? (config.providerConfig as JsonRecord)
|
||||
: undefined;
|
||||
const explicitApiKey = String(config.apiKey ?? config.api_key ?? "").trim();
|
||||
const oauth = explicitApiKey
|
||||
? null
|
||||
: await new RuntimeOAuthTokenManager({
|
||||
providerSettingsManager: manager,
|
||||
}).resolveProviderApiKey({ providerId });
|
||||
const apiKey =
|
||||
explicitApiKey ||
|
||||
oauth?.apiKey ||
|
||||
resolveProviderApiKeyFromSettings(manager, providerId) ||
|
||||
String(storedConfig.apiKey ?? "").trim();
|
||||
|
||||
return {
|
||||
...config,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(!config.baseUrl && storedConfig.baseUrl
|
||||
? { baseUrl: storedConfig.baseUrl }
|
||||
: {}),
|
||||
...(!config.headers && storedConfig.headers
|
||||
? { headers: storedConfig.headers }
|
||||
: {}),
|
||||
providerConfig: {
|
||||
...storedConfig,
|
||||
...(explicitProviderConfig ?? {}),
|
||||
providerId,
|
||||
...(modelId ? { modelId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionManager(
|
||||
ctx: SidecarContext,
|
||||
sessionId?: string,
|
||||
config?: JsonRecord,
|
||||
): ClineCore {
|
||||
return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config))
|
||||
.sessionManager;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -641,20 +755,34 @@ 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)
|
||||
: undefined;
|
||||
const coreConfig: JsonRecord = {
|
||||
...buildCoreSessionConfig(request.config),
|
||||
...buildCoreSessionConfig(config),
|
||||
systemPrompt,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
};
|
||||
@@ -670,7 +798,10 @@ async function handleStart(
|
||||
});
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
source:
|
||||
request.source === SessionSource.REALTIME
|
||||
? SessionSource.REALTIME
|
||||
: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
@@ -680,8 +811,14 @@ async function handleStart(
|
||||
const cwd = startResult.manifest.cwd;
|
||||
ctx.logger?.log("Desktop chat session started", { sessionId });
|
||||
const session = createLiveSession(
|
||||
{ ...request.config, cwd, workspaceRoot },
|
||||
{
|
||||
...config,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
environmentId: binding.environmentId,
|
||||
},
|
||||
{
|
||||
environmentId: binding.environmentId,
|
||||
messages: initialMessages,
|
||||
prompt: initialMessages
|
||||
? derivePromptFromMessages(initialMessages)
|
||||
@@ -693,7 +830,13 @@ async function handleStart(
|
||||
},
|
||||
);
|
||||
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(
|
||||
@@ -705,7 +848,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`);
|
||||
@@ -716,13 +870,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:
|
||||
@@ -736,9 +889,14 @@ async function handleAttach(
|
||||
String(request.config?.workspaceRoot ?? "").trim() ||
|
||||
String(existing?.config.workspaceRoot ?? "").trim(),
|
||||
};
|
||||
const attachedConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(baseAttachedConfig)
|
||||
: baseAttachedConfig;
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(attachedConfig, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: existing?.messages ?? [],
|
||||
promptsInQueue: existing?.promptsInQueue ?? [],
|
||||
status: session.status,
|
||||
@@ -758,9 +916,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,
|
||||
@@ -813,11 +973,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),
|
||||
@@ -829,8 +998,12 @@ async function rebuildSessionForProviderChange(
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
resolveSystemPrompt(previousConfig),
|
||||
resolveSystemPrompt(nextConfig),
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(effectivePreviousConfig)
|
||||
: resolveSystemPrompt(effectivePreviousConfig),
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(effectiveNextConfig)
|
||||
: resolveSystemPrompt(effectiveNextConfig),
|
||||
]);
|
||||
|
||||
await manager.stop(sessionId);
|
||||
@@ -839,7 +1012,7 @@ async function rebuildSessionForProviderChange(
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
nextConfig,
|
||||
effectiveNextConfig,
|
||||
nextSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
@@ -850,7 +1023,7 @@ async function rebuildSessionForProviderChange(
|
||||
// persistence failure cannot leave runtime and cached state diverged.
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(nextConfig),
|
||||
buildSessionConnectionUpdate(effectiveNextConfig),
|
||||
);
|
||||
} catch (replacementError) {
|
||||
try {
|
||||
@@ -860,14 +1033,14 @@ async function rebuildSessionForProviderChange(
|
||||
await startRebuiltSession(
|
||||
manager,
|
||||
sessionId,
|
||||
previousConfig,
|
||||
effectivePreviousConfig,
|
||||
previousSystemPrompt,
|
||||
messages,
|
||||
compactionState,
|
||||
);
|
||||
await manager.updateSessionConnection(
|
||||
sessionId,
|
||||
buildSessionConnectionUpdate(previousConfig),
|
||||
buildSessionConnectionUpdate(effectivePreviousConfig),
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
@@ -892,8 +1065,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,
|
||||
);
|
||||
@@ -910,7 +1088,8 @@ async function handleSend(
|
||||
// `/command` token as the session's display prompt.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
|
||||
readWorkspacePath(session?.config ?? request.config) ??
|
||||
ctx.localWorkspaceRoot,
|
||||
prompt,
|
||||
request.config?.mode ?? session?.config?.mode,
|
||||
);
|
||||
@@ -947,7 +1126,7 @@ async function handleSend(
|
||||
if (providerChanged && session) {
|
||||
await rebuildSessionForProviderChange(
|
||||
ctx,
|
||||
manager,
|
||||
binding,
|
||||
sessionId,
|
||||
session.config,
|
||||
nextConfig,
|
||||
@@ -970,6 +1149,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,
|
||||
@@ -1043,11 +1230,19 @@ async function handleSend(
|
||||
} else {
|
||||
deleteMaterializedAttachments(sessionId, userFiles);
|
||||
}
|
||||
ctx.logger?.log("Desktop chat prompt completed", {
|
||||
const completionMetadata = {
|
||||
sessionId,
|
||||
finishReason: result?.finishReason,
|
||||
textLength: result?.text?.length ?? 0,
|
||||
});
|
||||
};
|
||||
if (result?.finishReason === "error") {
|
||||
ctx.logger?.error?.("Desktop chat prompt returned an error result", {
|
||||
...completionMetadata,
|
||||
failure: result.text,
|
||||
});
|
||||
} else {
|
||||
ctx.logger?.log("Desktop chat prompt completed", completionMetadata);
|
||||
}
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages;
|
||||
@@ -1106,7 +1301,7 @@ async function handleStop(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
await getSessionManager(ctx).stop(sessionId);
|
||||
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.busy = false;
|
||||
@@ -1121,7 +1316,10 @@ async function handleAbort(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
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;
|
||||
@@ -1143,7 +1341,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 &&
|
||||
@@ -1195,10 +1393,18 @@ async function handleForkUnlocked(
|
||||
sourceSession: SessionRecord | undefined,
|
||||
restoreWorkspacePath?: string,
|
||||
): Promise<unknown> {
|
||||
const manager = getSessionManager(ctx);
|
||||
const sourceMessages =
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
let sourceMessages =
|
||||
readPersistedChatMessages(sourceSessionId) ??
|
||||
ctx.liveSessions.get(sourceSessionId)?.messages;
|
||||
if (!sourceMessages?.length && binding.kind === "ssh") {
|
||||
sourceMessages = await manager.readMessages(sourceSessionId);
|
||||
}
|
||||
if (!sourceMessages?.length) {
|
||||
throw new Error(`No messages found for session ${sourceSessionId}`);
|
||||
}
|
||||
@@ -1208,10 +1414,11 @@ async function handleForkUnlocked(
|
||||
? (sourceSession.metadata as JsonRecord)
|
||||
: undefined) ?? readSessionMetadata(sourceSessionId);
|
||||
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 ||
|
||||
@@ -1241,6 +1448,10 @@ async function handleForkUnlocked(
|
||||
request.config?.cwd ||
|
||||
"",
|
||||
};
|
||||
const forkConfig =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(baseForkConfig)
|
||||
: baseForkConfig;
|
||||
const checkpointMetadata =
|
||||
sourceMetadata?.checkpoint !== undefined
|
||||
? { checkpoints: sourceMetadata.checkpoint }
|
||||
@@ -1261,7 +1472,10 @@ async function handleForkUnlocked(
|
||||
...checkpointMetadata,
|
||||
},
|
||||
};
|
||||
const systemPrompt = await resolveSystemPrompt(forkConfig);
|
||||
const systemPrompt =
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(forkConfig)
|
||||
: await resolveSystemPrompt(forkConfig);
|
||||
const startInput = {
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
@@ -1307,6 +1521,7 @@ async function handleForkUnlocked(
|
||||
});
|
||||
newSessionId = started.sessionId;
|
||||
}
|
||||
copySessionGeneratedArtifacts(sourceSessionId, newSessionId);
|
||||
try {
|
||||
const read = await manager.readMessages(newSessionId);
|
||||
if (forkBeforeRunCount !== undefined || read.length > 0) {
|
||||
@@ -1321,12 +1536,14 @@ async function handleForkUnlocked(
|
||||
ctx.liveSessions.set(
|
||||
newSessionId,
|
||||
createLiveSession(forkConfig, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: forkMessages,
|
||||
prompt: derivePromptFromMessages(forkMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, newSessionId);
|
||||
return {
|
||||
@@ -1348,10 +1565,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 };
|
||||
@@ -1370,14 +1588,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 () => {
|
||||
const restored = await manager.restore({
|
||||
sessionId: sourceSessionId,
|
||||
@@ -1388,7 +1617,10 @@ async function handleRestoreCheckpoint(
|
||||
...splitCoreSessionConfig(
|
||||
buildCoreSessionConfig({
|
||||
...config,
|
||||
systemPrompt: await resolveSystemPrompt(config),
|
||||
systemPrompt:
|
||||
binding.kind === "ssh"
|
||||
? readExplicitSystemPrompt(config)
|
||||
: await resolveSystemPrompt(config),
|
||||
}) as unknown as ClineCoreStartConfig,
|
||||
),
|
||||
source: SessionSource.DESKTOP,
|
||||
@@ -1401,6 +1633,7 @@ async function handleRestoreCheckpoint(
|
||||
if (!sessionId || !restoredMessages) {
|
||||
throw new Error("Checkpoint restore did not return a new session");
|
||||
}
|
||||
copySessionGeneratedArtifacts(sourceSessionId, sessionId);
|
||||
discardAllTrackedAttachments(
|
||||
sourceSessionId,
|
||||
ctx.liveSessions.get(sourceSessionId),
|
||||
@@ -1409,12 +1642,14 @@ async function handleRestoreCheckpoint(
|
||||
ctx.liveSessions.set(
|
||||
sessionId,
|
||||
createLiveSession(config, {
|
||||
environmentId: binding.environmentId,
|
||||
messages: restoredMessages,
|
||||
prompt: derivePromptFromMessages(restoredMessages),
|
||||
title: readSessionMetadataTitle(sourceSessionId),
|
||||
status: "idle",
|
||||
}),
|
||||
);
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
// A restore that reuses the source session id leaves the persisted
|
||||
// transcript describing the discarded turns, and read_session_messages
|
||||
// prefers that file over the live session. Write the trimmed history so
|
||||
@@ -1435,9 +1670,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),
|
||||
@@ -1452,7 +1689,7 @@ async function handleSteerPrompt(
|
||||
const promptId = request.promptId?.trim();
|
||||
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.update({
|
||||
sessionId,
|
||||
promptId,
|
||||
@@ -1484,7 +1721,7 @@ async function handleUpdatePendingPrompt(
|
||||
// through handleSend, so resolve slash commands here too.
|
||||
const runtimePrompt = await resolveDesktopRuntimePrompt(
|
||||
ctx,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
|
||||
readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot,
|
||||
prompt,
|
||||
sessionConfig?.mode,
|
||||
);
|
||||
@@ -1510,7 +1747,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,
|
||||
@@ -1553,6 +1790,125 @@ export async function handleChatSessionCommand(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
const executionTarget = String(request.config?.executionTarget ?? "").trim();
|
||||
const sessionId = request.sessionId?.trim();
|
||||
const existingCloudSession =
|
||||
sessionId &&
|
||||
ctx.liveSessions.get(sessionId)?.config.executionTarget === "cloud";
|
||||
if (executionTarget === "cloud" || existingCloudSession) {
|
||||
const cloud = getCloudSessionManager(ctx);
|
||||
// The approval preference lives only client-side; keep the live session
|
||||
// current so a lazily created inner session inherits the user's choice.
|
||||
const requestedAutoApprove = request.config?.autoApproveTools;
|
||||
if (typeof requestedAutoApprove === "boolean" && sessionId) {
|
||||
const live = ctx.liveSessions.get(sessionId);
|
||||
if (live) {
|
||||
live.config.autoApproveTools = requestedAutoApprove;
|
||||
}
|
||||
}
|
||||
switch (request.action) {
|
||||
case "start": {
|
||||
const requestedSessionId = String(
|
||||
request.config?.sessionId ?? request.config?.session_id ?? "",
|
||||
).trim();
|
||||
// Server ids attach even with a cold registry; client-planned ids create.
|
||||
if (requestedSessionId && cloud.isCloudSession(requestedSessionId)) {
|
||||
return await cloud.attach(requestedSessionId);
|
||||
}
|
||||
// Gate new sessions only; existing cloud sessions must remain usable.
|
||||
if (!isCloudAgentsEnabled()) {
|
||||
throw new Error(
|
||||
"Cloud sessions are not enabled for this account yet.",
|
||||
);
|
||||
}
|
||||
const repoUrl = String(request.config?.repoUrl ?? "").trim();
|
||||
const modelId = String(
|
||||
request.config?.model ?? request.config?.modelId ?? "",
|
||||
).trim();
|
||||
if (!repoUrl || !modelId) {
|
||||
throw new Error("repoUrl and model are required for a cloud session");
|
||||
}
|
||||
const branch = String(request.config?.branch ?? "").trim();
|
||||
const initialPrompt = request.prompt?.trim();
|
||||
const reasoningEffort = readReasoningEffort(
|
||||
request.config?.reasoningEffort,
|
||||
);
|
||||
return await cloud.create({
|
||||
repoUrl,
|
||||
modelId,
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
...(branch ? { branch } : {}),
|
||||
...(typeof request.config?.thinking === "boolean"
|
||||
? { thinking: request.config.thinking }
|
||||
: {}),
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(typeof requestedAutoApprove === "boolean"
|
||||
? { autoApproveTools: requestedAutoApprove }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
case "attach":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.attach(sessionId);
|
||||
case "send": {
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
if (request.attachments?.userFiles?.length) {
|
||||
throw new Error(
|
||||
"File attachments are not supported in cloud sessions",
|
||||
);
|
||||
}
|
||||
const live = ctx.liveSessions.get(sessionId);
|
||||
const delivery = request.delivery ?? (live?.busy ? "queue" : undefined);
|
||||
const modelId = String(
|
||||
request.config?.model ?? request.config?.modelId ?? "",
|
||||
).trim();
|
||||
return await cloud.send(
|
||||
sessionId,
|
||||
prompt,
|
||||
delivery,
|
||||
modelId || undefined,
|
||||
request.attachments?.userImages,
|
||||
);
|
||||
}
|
||||
case "stop":
|
||||
case "abort":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.abort(sessionId);
|
||||
case "pending_prompts":
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
return await cloud.pendingPrompts(sessionId);
|
||||
case "steer_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
return await cloud.updatePendingPrompt(sessionId, promptId, {
|
||||
delivery: "steer",
|
||||
});
|
||||
}
|
||||
case "update_pending_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
const prompt = request.prompt?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
if (!prompt) throw new Error("prompt is required");
|
||||
return await cloud.updatePendingPrompt(sessionId, promptId, {
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
case "remove_pending_prompt": {
|
||||
const promptId = request.promptId?.trim();
|
||||
if (!sessionId || !promptId)
|
||||
throw new Error("sessionId and promptId are required");
|
||||
return await cloud.removePendingPrompt(sessionId, promptId);
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`${request.action} is not supported for cloud sessions yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const handler = ACTION_HANDLERS[request.action];
|
||||
if (!handler) throw new Error("unsupported action");
|
||||
return handler(ctx, request);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
captureAuthRefreshSoftFailure,
|
||||
getProviderAuthHandler,
|
||||
type ProviderSettingsManager,
|
||||
RuntimeOAuthTokenManager,
|
||||
} from "@cline/core";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
// Cline access tokens expire between app launches, so account requests must
|
||||
// resolve through the refresh-aware OAuth manager instead of reading the
|
||||
// persisted token directly. A single shared instance keeps concurrent account
|
||||
// requests single-flight; the refresh token is single-use, so parallel
|
||||
// refreshes would invalidate each other.
|
||||
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
|
||||
|
||||
export async function resolveFreshClineAuthToken(
|
||||
manager: ProviderSettingsManager,
|
||||
ctx?: SidecarContext,
|
||||
): Promise<string | undefined> {
|
||||
let refreshError: Error | undefined;
|
||||
try {
|
||||
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
|
||||
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
|
||||
providerId: "cline",
|
||||
});
|
||||
if (resolution?.apiKey) {
|
||||
return resolution.apiKey;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to the persisted token; when one exists the account request
|
||||
// surfaces the auth failure to the caller.
|
||||
refreshError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
// The canonical handler applies the same formatting the refresh path uses:
|
||||
// OAuth access tokens gain the `workos:` prefix core-platform expects,
|
||||
// while raw API keys pass through untouched.
|
||||
const persisted = getProviderAuthHandler("cline")?.getApiKey(
|
||||
manager.getProviderSettings("cline"),
|
||||
);
|
||||
// Never-signed-in resolves to undefined without a refresh attempt and is
|
||||
// silent. A refresh failure with no persisted fallback means credentials
|
||||
// existed but yielded nothing — that is the signal a real auth regression
|
||||
// would show up as, so report exactly one event for it.
|
||||
if (!persisted && refreshError && ctx) {
|
||||
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
|
||||
error: refreshError,
|
||||
});
|
||||
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
|
||||
errorName: refreshError.name,
|
||||
errorCode: "desktop_refresh_failed_no_fallback_token",
|
||||
});
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -110,8 +110,10 @@ describe("cline_account command auth states", () => {
|
||||
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
|
||||
getAuthToken: () => Promise<string | undefined>;
|
||||
};
|
||||
// Persisted OAuth tokens gain the `workos:` prefix required by
|
||||
// core-platform (see cline-auth.ts).
|
||||
await expect(serviceOptions.getAuthToken()).resolves.toBe(
|
||||
"persisted-token",
|
||||
"workos:persisted-token",
|
||||
);
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
|
||||
import {
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
resolveGitHubInstallUrl,
|
||||
} from "./commands-integrations";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
|
||||
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings = getProviderSettingsMock;
|
||||
},
|
||||
RuntimeOAuthTokenManager: class {
|
||||
resolveProviderApiKey = resolveProviderApiKeyMock;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createContext() {
|
||||
const capture = vi.fn();
|
||||
const ctx = {
|
||||
telemetry: { capture },
|
||||
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, capture };
|
||||
}
|
||||
|
||||
const REQUEST_OPTIONS = {
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
appBaseUrl: "https://app.example.com",
|
||||
authToken: "test-token",
|
||||
} as const;
|
||||
|
||||
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
...REQUEST_OPTIONS,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getProviderSettingsMock.mockReset();
|
||||
resolveProviderApiKeyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("listClineIntegrations", () => {
|
||||
it("lists integrations through the envelope with a bearer token", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineIntegrations(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer test-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listClineGitHubRepositories", () => {
|
||||
it("lists GitHub repositories from the repositories endpoint", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
|
||||
);
|
||||
|
||||
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual([{ full_name: "cline/cline" }]);
|
||||
expect(String(fetchImpl.mock.calls[0][0])).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/repositories",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the API envelope error message on failures", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ success: false, error: "failed to list integrations" },
|
||||
500,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
listClineIntegrations(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("failed to list integrations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGitHubInstallUrl", () => {
|
||||
it("resolves the GitHub install URL from the redirect location", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
});
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
|
||||
expect(url.origin + url.pathname).toBe(
|
||||
"https://api.example.com/api/v1/integrations/github/install",
|
||||
);
|
||||
// The post-install browser hop must land on the Cline dashboard.
|
||||
expect(url.searchParams.get("redirect")).toBe(
|
||||
"https://app.example.com/dashboard/integrations",
|
||||
);
|
||||
// The redirect must be read, not followed: the Location URL is the result.
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("resolves a relative redirect location against the request URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "//github.com/apps/cline/installations/new" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
|
||||
|
||||
// A bare relative Location would blow up later in the URL opener.
|
||||
expect(result).toEqual({
|
||||
url: "https://github.com/apps/cline/installations/new",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://evil.example/apps/cline", "evil.example"],
|
||||
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
|
||||
// Subdomains are not part of the install flow, so they are not allowed
|
||||
// either -- the host must be exactly github.com.
|
||||
["https://gist.github.com/apps/cline", "gist.github.com"],
|
||||
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 302, headers: { location } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow(`unexpected host: ${host}`);
|
||||
});
|
||||
|
||||
it("rejects a redirect that does not use https", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://github.com/apps/cline" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("must use https");
|
||||
});
|
||||
|
||||
it("rejects a redirect location that is not a usable URL", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("not a valid URL");
|
||||
});
|
||||
|
||||
it("throws when the install endpoint does not answer with a redirect", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ error: "authentication required" }, 401),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
|
||||
).rejects.toThrow("authentication required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cline_integrations command auth states", () => {
|
||||
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
|
||||
const { ctx, capture } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue(null);
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the Cline API with the resolved fresh token when signed in", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ success: true, data: [{ provider: "github" }] }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
const result = await handleCommand(ctx, "cline_integrations", {
|
||||
operation: "list",
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ provider: "github" }]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
expect(String(url)).toContain("/api/v1/integrations");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer fresh-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown operations", async () => {
|
||||
const { ctx } = createContext();
|
||||
resolveProviderApiKeyMock.mockResolvedValue({
|
||||
apiKey: "fresh-token",
|
||||
refreshed: true,
|
||||
});
|
||||
getProviderSettingsMock.mockReturnValue(undefined);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { handleCommand } = await import("./commands");
|
||||
await expect(
|
||||
handleCommand(ctx, "cline_integrations", {
|
||||
operation: "dropIntegrations",
|
||||
}),
|
||||
).rejects.toThrow("Unsupported Cline integrations operation");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
ClineGitHubRepository,
|
||||
ClineIntegration,
|
||||
} from "../webview/lib/cline-integrations-types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const GITHUB_INSTALL_HOST = "github.com";
|
||||
|
||||
function resolveInstallRedirect(location: string, requestUrl: URL): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
|
||||
}
|
||||
if (resolved.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`GitHub install redirect must use https, got: ${resolved.protocol}`,
|
||||
);
|
||||
}
|
||||
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
|
||||
throw new Error(
|
||||
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
|
||||
);
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
export interface ClineIntegrationsRequestOptions {
|
||||
apiBaseUrl: string;
|
||||
/** Frontend origin the browser install flow returns to when it finishes. */
|
||||
appBaseUrl: string;
|
||||
authToken: string;
|
||||
requestTimeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function listClineIntegrations(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineIntegration[]> {
|
||||
const data = await requestClineApiJson("/api/v1/integrations", options);
|
||||
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
|
||||
}
|
||||
|
||||
export async function listClineGitHubRepositories(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<ClineGitHubRepository[]> {
|
||||
const data = await requestClineApiJson(
|
||||
"/api/v1/integrations/github/repositories",
|
||||
options,
|
||||
);
|
||||
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
|
||||
}
|
||||
|
||||
export async function resolveGitHubInstallUrl(
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<{ url: string }> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const installUrl = new URL(
|
||||
"/api/v1/integrations/github/install",
|
||||
options.apiBaseUrl,
|
||||
);
|
||||
installUrl.searchParams.set(
|
||||
"redirect",
|
||||
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(installUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${options.authToken}` },
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const location = response.headers.get("location");
|
||||
if (response.status >= 300 && response.status < 400 && location?.trim()) {
|
||||
return { url: resolveInstallRedirect(location.trim(), installUrl) };
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = text.trim() ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvelopeError(parsed: unknown): string | undefined {
|
||||
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const error = (parsed as { error?: unknown }).error;
|
||||
return typeof error === "string" && error.trim() ? error : undefined;
|
||||
}
|
||||
|
||||
function formatRequestFailure(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
parsed: unknown,
|
||||
): string {
|
||||
const envelopeError = getEnvelopeError(parsed);
|
||||
if (envelopeError) {
|
||||
return envelopeError;
|
||||
}
|
||||
const body = bodyText.trim();
|
||||
if (body) {
|
||||
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
|
||||
return `Cline integrations request failed with status ${status}: ${preview}`;
|
||||
}
|
||||
return `Cline integrations request failed with status ${status}`;
|
||||
}
|
||||
|
||||
async function requestClineApiJson(
|
||||
endpoint: string,
|
||||
options: ClineIntegrationsRequestOptions,
|
||||
): Promise<unknown> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
formatRequestFailure(response.status, text, undefined),
|
||||
);
|
||||
}
|
||||
throw new Error("Cline integrations response was not valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(formatRequestFailure(response.status, text, parsed));
|
||||
}
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
|
||||
const envelope = parsed as {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
data?: unknown;
|
||||
};
|
||||
if (typeof envelope.success === "boolean") {
|
||||
if (!envelope.success) {
|
||||
throw new Error(
|
||||
getEnvelopeError(parsed) || "Cline integrations request failed",
|
||||
);
|
||||
}
|
||||
return envelope.data ?? null;
|
||||
}
|
||||
}
|
||||
return parsed ?? null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { handleCommand } from "./commands";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
function createContext(): {
|
||||
ctx: SidecarContext;
|
||||
events: Array<{ name: string; payload: Record<string, unknown> }>;
|
||||
} {
|
||||
const events: Array<{ name: string; payload: Record<string, unknown> }> = [];
|
||||
const ctx = {
|
||||
liveSessions: new Map(),
|
||||
restoringWorkspacePaths: new Set(),
|
||||
streamIndices: new Map(),
|
||||
wsClients: new Set([
|
||||
{
|
||||
send(message: string) {
|
||||
const parsed = JSON.parse(message) as {
|
||||
event: { name: string; payload: Record<string, unknown> };
|
||||
};
|
||||
events.push(parsed.event);
|
||||
},
|
||||
},
|
||||
]),
|
||||
pendingApprovals: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
sessionManager: null,
|
||||
hubClient: null,
|
||||
workspaceRoot: "/local/workspace",
|
||||
unsubscribeSessionEvents: null,
|
||||
cloudSessionManager: null,
|
||||
} as unknown as SidecarContext;
|
||||
return { ctx, events };
|
||||
}
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-commands-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings commands", () => {
|
||||
it("reads default desktop settings and an off feature gate", async () => {
|
||||
const { ctx } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "get_desktop_settings", {}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: false });
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-boolean cloud sessions toggle value", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: "yes",
|
||||
}),
|
||||
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists the toggle and broadcasts the new gate immediately", async () => {
|
||||
const { ctx, events } = createContext();
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: true,
|
||||
}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: true });
|
||||
// Open webviews re-evaluate without waiting for a restart or account
|
||||
// change.
|
||||
expect(events).toEqual([
|
||||
{ name: "feature_flags_changed", payload: { cloudAgents: true } },
|
||||
]);
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: true,
|
||||
});
|
||||
|
||||
await handleCommand(ctx, "set_cloud_sessions_enabled", {
|
||||
cloud_sessions_enabled: false,
|
||||
});
|
||||
expect(events.at(-1)).toEqual({
|
||||
name: "feature_flags_changed",
|
||||
payload: { cloudAgents: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the env override through the feature gate", async () => {
|
||||
const { ctx } = createContext();
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
|
||||
|
||||
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
|
||||
cloudAgents: true,
|
||||
});
|
||||
// The toggle's stored value is reported as-is; the override only
|
||||
// affects the effective gate.
|
||||
await expect(
|
||||
handleCommand(ctx, "get_desktop_settings", {}),
|
||||
).resolves.toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,11 @@ vi.mock("@cline/core", async () => {
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ClientSettingsManager: class {
|
||||
initializeModesIfMissing = vi.fn();
|
||||
read = vi.fn(() => ({ modes: {} }));
|
||||
getModeSettings = vi.fn(() => undefined);
|
||||
},
|
||||
ClineCore: {
|
||||
create: createCoreMock,
|
||||
},
|
||||
@@ -186,7 +191,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",
|
||||
@@ -222,6 +227,43 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves attached-session content projection to the Core event stream", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() });
|
||||
ctx.liveSessions.set("session-image", {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
attachedViaHub: true,
|
||||
});
|
||||
|
||||
for (const event of [
|
||||
"assistant.delta",
|
||||
"assistant.image",
|
||||
"reasoning.delta",
|
||||
"tool.started",
|
||||
"tool.finished",
|
||||
]) {
|
||||
handleHubLiveEvent(ctx, {
|
||||
event,
|
||||
sessionId: "session-image",
|
||||
payload: {
|
||||
text: "one canonical copy",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(readEvents(ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it("announces a queued prompt start once when drain emits both queue events", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -515,6 +557,37 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps an approval visible when its remote acknowledgement fails", async () => {
|
||||
const { createSidecarContext } = await import("./context");
|
||||
const { handleCommand } = await import("./commands");
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.pendingApprovals.set("cloud-approval", {
|
||||
item: {
|
||||
requestId: "cloud-approval",
|
||||
sessionId: "ses-cloud",
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
},
|
||||
resolve: async () => {
|
||||
throw new Error("hub disconnected");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleCommand(ctx, "respond_tool_approval", {
|
||||
sessionId: "ses-cloud",
|
||||
requestId: "cloud-approval",
|
||||
approved: true,
|
||||
}),
|
||||
).rejects.toThrow("hub disconnected");
|
||||
expect(
|
||||
await handleCommand(ctx, "poll_tool_approvals", {
|
||||
sessionId: "ses-cloud",
|
||||
}),
|
||||
).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]);
|
||||
});
|
||||
|
||||
it("routes routine commands through the connected shared Hub client", async () => {
|
||||
const { createSidecarContext, initializeSessionManager } = await import(
|
||||
"./context"
|
||||
@@ -589,4 +662,37 @@ describe("disposeSidecarContext attachment cleanup", () => {
|
||||
expect(existsSync(queuedFile)).toBe(false);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("waits for pending approval callbacks before shutdown completes", async () => {
|
||||
const { createSidecarContext, disposeSidecarContext } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
let release: (() => void) | undefined;
|
||||
ctx.pendingApprovals.set("approval-1", {
|
||||
item: {
|
||||
requestId: "approval-1",
|
||||
sessionId: "session-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
input: {},
|
||||
},
|
||||
resolve: async () =>
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
let disposed = false;
|
||||
const disposing = disposeSidecarContext(ctx, "test_shutdown").then(() => {
|
||||
disposed = true;
|
||||
});
|
||||
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
|
||||
expect(disposed).toBe(false);
|
||||
|
||||
release?.();
|
||||
await disposing;
|
||||
expect(disposed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,10 @@ import type {
|
||||
PendingAskQuestion,
|
||||
PendingToolApproval,
|
||||
PromptInQueue,
|
||||
SessionRuntimeBinding,
|
||||
SidecarContext,
|
||||
} from "./types";
|
||||
import { LOCAL_ENVIRONMENT_ID } from "./types";
|
||||
|
||||
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
|
||||
const hubClientInitialization = new WeakMap<
|
||||
@@ -165,7 +167,7 @@ export function serializeQueuedPromptStart(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function sendPromptsInQueueSnapshot(
|
||||
export function sendPromptsInQueueSnapshot(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
): void {
|
||||
@@ -501,12 +503,15 @@ 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,
|
||||
unsubscribeSessionEvents: null,
|
||||
cloudSessionManager: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
}
|
||||
@@ -516,9 +521,7 @@ export async function disposeSidecarContext(
|
||||
reason = "code_sidecar_shutdown",
|
||||
): Promise<void> {
|
||||
const cleanup: Array<Promise<unknown>> = [];
|
||||
|
||||
ctx.unsubscribeSessionEvents?.();
|
||||
ctx.unsubscribeSessionEvents = null;
|
||||
const approvalCleanup: Array<Promise<unknown>> = [];
|
||||
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
@@ -534,7 +537,21 @@ export async function disposeSidecarContext(
|
||||
}
|
||||
ctx.wsClients.clear();
|
||||
for (const pending of ctx.pendingApprovals.values()) {
|
||||
pending.resolve({ approved: false, reason });
|
||||
// Cloud sessions outlive this app: denying their approvals on local
|
||||
// shutdown would fail a tool call on a pod that keeps running and
|
||||
// could otherwise be answered later (from here or another surface).
|
||||
// Drop those entries locally and leave the remote approval pending.
|
||||
if (ctx.cloudSessionManager?.isCloudSession(pending.item.sessionId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
approvalCleanup.push(
|
||||
Promise.resolve(pending.resolve({ approved: false, reason })),
|
||||
);
|
||||
} catch (error) {
|
||||
// Keep disposing the remaining resources, then preserve the failure.
|
||||
approvalCleanup.push(Promise.reject(error));
|
||||
}
|
||||
}
|
||||
ctx.pendingApprovals.clear();
|
||||
for (const pending of ctx.pendingQuestions.values()) {
|
||||
@@ -542,20 +559,28 @@ export async function disposeSidecarContext(
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
ctx.pendingQuestions.clear();
|
||||
// Approval callbacks may need the Hub/cloud clients that are disposed below.
|
||||
const approvalResults = await Promise.allSettled(approvalCleanup);
|
||||
|
||||
const hubClient = ctx.hubClient;
|
||||
ctx.hubClient = null;
|
||||
if (hubClient) {
|
||||
cleanup.push(hubClient.dispose());
|
||||
const cloudSessionManager = ctx.cloudSessionManager;
|
||||
ctx.cloudSessionManager = null;
|
||||
if (cloudSessionManager) {
|
||||
cleanup.push(cloudSessionManager.dispose());
|
||||
}
|
||||
|
||||
const sessionManager = ctx.sessionManager;
|
||||
ctx.sessionManager = null;
|
||||
if (sessionManager) {
|
||||
cleanup.push(sessionManager.dispose(reason));
|
||||
for (const binding of ctx.runtimeBindings?.values() ?? []) {
|
||||
binding.unsubscribeSessionEvents();
|
||||
cleanup.push(binding.hubClient.dispose());
|
||||
cleanup.push(binding.sessionManager.dispose(reason));
|
||||
}
|
||||
ctx.runtimeBindings?.clear();
|
||||
ctx.sessionEnvironmentIds?.clear();
|
||||
if (ctx.remoteEnvironments) {
|
||||
cleanup.push(ctx.remoteEnvironments.dispose());
|
||||
ctx.remoteEnvironments = null;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(cleanup);
|
||||
const results = [...approvalResults, ...(await Promise.allSettled(cleanup))];
|
||||
const firstFailure = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
@@ -685,6 +710,7 @@ export function handleHubLiveEvent(
|
||||
sessionId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
},
|
||||
options: { relayRawAssistantText?: boolean } = {},
|
||||
): void {
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
@@ -697,13 +723,23 @@ export function handleHubLiveEvent(
|
||||
|
||||
switch (event.event) {
|
||||
case "assistant.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
if (text) {
|
||||
emitChunk(ctx, sessionId, "chat_text", text);
|
||||
if (options.relayRawAssistantText) {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
if (text) emitChunk(ctx, sessionId, "chat_text", text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "assistant.image":
|
||||
case "assistant.video":
|
||||
case "assistant.audio":
|
||||
case "reasoning.delta":
|
||||
case "tool.started":
|
||||
case "tool.finished":
|
||||
// HubRuntimeHost already projects these into the canonical Core event
|
||||
// stream consumed by handleCoreSessionEvent. Relaying the raw Hub copy
|
||||
// too duplicates assistant output and tool activity.
|
||||
return;
|
||||
case "assistant.media": {
|
||||
const media = event.payload?.media;
|
||||
if (isGeneratedMedia(media)) {
|
||||
@@ -711,61 +747,84 @@ export function handleHubLiveEvent(
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
const text =
|
||||
typeof event.payload?.text === "string" ? event.payload.text : "";
|
||||
const redacted = event.payload?.redacted === true;
|
||||
if (!text && !redacted) {
|
||||
case "usage.updated": {
|
||||
const delta =
|
||||
event.payload?.delta &&
|
||||
typeof event.payload.delta === "object" &&
|
||||
!Array.isArray(event.payload.delta)
|
||||
? (event.payload.delta as Record<string, unknown>)
|
||||
: {};
|
||||
const totals =
|
||||
event.payload?.totals &&
|
||||
typeof event.payload.totals === "object" &&
|
||||
!Array.isArray(event.payload.totals)
|
||||
? (event.payload.totals as Record<string, unknown>)
|
||||
: {};
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_usage",
|
||||
JSON.stringify({
|
||||
inputTokens: delta.inputTokens,
|
||||
outputTokens: delta.outputTokens,
|
||||
cacheReadTokens: delta.cacheReadTokens,
|
||||
cacheWriteTokens: delta.cacheWriteTokens,
|
||||
cost: delta.totalCost,
|
||||
totalInputTokens: totals.inputTokens,
|
||||
totalOutputTokens: totals.outputTokens,
|
||||
totalCost: totals.totalCost,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "session.pending_prompts": {
|
||||
const items = Array.isArray(event.payload?.prompts)
|
||||
? (event.payload.prompts as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const mapped: PromptInQueue[] = items
|
||||
.map((item) => ({
|
||||
id: typeof item.id === "string" ? item.id : "",
|
||||
prompt: typeof item.prompt === "string" ? item.prompt : "",
|
||||
steer: item.delivery === "steer",
|
||||
attachmentCount:
|
||||
typeof item.attachmentCount === "number" ? item.attachmentCount : 0,
|
||||
userImages: Array.isArray(item.userImages)
|
||||
? (item.userImages as string[])
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => item.id && (item.prompt || item.attachmentCount > 0));
|
||||
reconcileQueuedAttachments(
|
||||
session,
|
||||
mapped.map((item) => item.id),
|
||||
);
|
||||
// No "head submitted" inference here, unlike the local queue-drain
|
||||
// handler: the hub emits an explicit session.pending_prompt_submitted
|
||||
// for real submissions, and a snapshot can also shrink because a
|
||||
// prompt was REMOVED — inferring a start would render the deleted
|
||||
// prompt in the transcript as if it had been sent.
|
||||
session.promptsInQueue = mapped;
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
return;
|
||||
}
|
||||
case "session.pending_prompt_submitted": {
|
||||
const item =
|
||||
event.payload?.prompt && typeof event.payload.prompt === "object"
|
||||
? (event.payload.prompt as Record<string, unknown>)
|
||||
: undefined;
|
||||
const promptId = typeof item?.id === "string" ? item.id : "";
|
||||
if (!promptId) {
|
||||
return;
|
||||
}
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_reasoning",
|
||||
JSON.stringify({ text, redacted }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.started": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_start",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
input: event.payload?.input,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "tool.finished": {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_tool_call_end",
|
||||
JSON.stringify({
|
||||
toolCallId:
|
||||
typeof event.payload?.toolCallId === "string"
|
||||
? event.payload.toolCallId
|
||||
: undefined,
|
||||
toolName:
|
||||
typeof event.payload?.toolName === "string"
|
||||
? event.payload.toolName
|
||||
: "tool",
|
||||
output: event.payload?.output,
|
||||
error:
|
||||
typeof event.payload?.error === "string"
|
||||
? event.payload.error
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
markQueuedAttachmentsSubmitted(session, promptId);
|
||||
emitQueuedPromptStart(ctx, sessionId, session, {
|
||||
promptId,
|
||||
prompt: typeof item?.prompt === "string" ? item.prompt : "",
|
||||
attachmentCount:
|
||||
typeof item?.attachmentCount === "number" ? item.attachmentCount : 0,
|
||||
userImages: Array.isArray(item?.userImages)
|
||||
? (item.userImages as string[])
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "run.started":
|
||||
@@ -777,20 +836,43 @@ export function handleHubLiveEvent(
|
||||
!Array.isArray(event.payload.session)
|
||||
? (event.payload.session as Record<string, unknown>)
|
||||
: undefined;
|
||||
const status =
|
||||
const runtimeStatus =
|
||||
typeof payloadSession?.status === "string"
|
||||
? payloadSession.status
|
||||
: event.event === "run.started"
|
||||
? "running"
|
||||
: session.status;
|
||||
// Hub "pending" means the run is blocked on approval or otherwise
|
||||
// still active. Desktop has no pending status, so expose it as running
|
||||
// and keep later prompts on the queue path.
|
||||
const status = runtimeStatus === "pending" ? "running" : runtimeStatus;
|
||||
// Pods emit periodic session.updated snapshots; re-broadcasting an
|
||||
// unchanged status marks the session unread in the sidebar every time.
|
||||
const statusChanged = session.status !== status;
|
||||
session.status = status;
|
||||
session.busy = status === "running";
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
if (statusChanged) {
|
||||
sendEvent(ctx, "chat_session_status", { sessionId, status });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "run.completed":
|
||||
case "run.failed":
|
||||
case "run.aborted": {
|
||||
// A failed run carries its reason in payload.error — surface it, or
|
||||
// the user sees a silent no-op (e.g. "Insufficient balance").
|
||||
const errorMessage =
|
||||
event.event === "run.failed" && typeof event.payload?.error === "string"
|
||||
? event.payload.error.trim()
|
||||
: "";
|
||||
if (errorMessage) {
|
||||
emitChunk(
|
||||
ctx,
|
||||
sessionId,
|
||||
"chat_core_log",
|
||||
JSON.stringify({ level: "error", message: errorMessage }),
|
||||
);
|
||||
}
|
||||
const reason =
|
||||
typeof event.payload?.reason === "string"
|
||||
? event.payload.reason
|
||||
@@ -822,8 +904,8 @@ export async function initializeSessionManager(
|
||||
telemetry: ctx.telemetry,
|
||||
hub: {
|
||||
strategy: "require-hub",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
clientType: "code-sidecar",
|
||||
displayName: "Code App sidecar",
|
||||
},
|
||||
@@ -834,24 +916,186 @@ export async function initializeSessionManager(
|
||||
handleCoreSessionEvent(ctx, 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,
|
||||
});
|
||||
}
|
||||
|
||||
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> {
|
||||
const knownEnvironmentId =
|
||||
preferredEnvironmentId?.trim() ||
|
||||
ctx.liveSessions.get(sessionId)?.environmentId ||
|
||||
ctx.sessionEnvironmentIds.get(sessionId);
|
||||
const candidates = [
|
||||
...(knownEnvironmentId
|
||||
? [ctx.runtimeBindings.get(knownEnvironmentId)]
|
||||
: []),
|
||||
...ctx.runtimeBindings.values(),
|
||||
].filter(
|
||||
(binding, index, all): binding is SessionRuntimeBinding =>
|
||||
Boolean(binding) && all.indexOf(binding) === index,
|
||||
);
|
||||
for (const binding of candidates) {
|
||||
try {
|
||||
if (await binding.sessionManager.get(sessionId)) {
|
||||
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
|
||||
return binding;
|
||||
}
|
||||
} catch {
|
||||
// A disconnected environment must not prevent another runtime from
|
||||
// resolving the session.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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(ctx),
|
||||
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(ctx, 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(ctx, 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);
|
||||
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);
|
||||
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) {
|
||||
@@ -863,8 +1107,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.");
|
||||
@@ -874,15 +1118,14 @@ export async function ensureSharedHubClient(
|
||||
url,
|
||||
clientType: "code-sidecar-observer",
|
||||
displayName: "Code App observer",
|
||||
workspaceRoot: ctx.workspaceRoot,
|
||||
cwd: ctx.workspaceRoot,
|
||||
workspaceRoot: ctx.localWorkspaceRoot,
|
||||
cwd: ctx.localWorkspaceRoot,
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
client.subscribe((event) => {
|
||||
handleHubLiveEvent(ctx, event);
|
||||
});
|
||||
ctx.hubClient = client;
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.dispose().catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
readDesktopSettings,
|
||||
resolveDesktopSettingsPath,
|
||||
setCloudSessionsEnabled,
|
||||
} from "./desktop-settings";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("desktop settings", () => {
|
||||
it("defaults cloud sessions to off when no settings file exists", () => {
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("persists the cloud sessions opt-in and reads it back", () => {
|
||||
expect(setCloudSessionsEnabled(true)).toEqual({
|
||||
cloudSessionsEnabled: true,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
|
||||
expect(setCloudSessionsEnabled(false)).toEqual({
|
||||
cloudSessionsEnabled: false,
|
||||
});
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
|
||||
it("writes into the desktop-owned settings file, not global-settings", () => {
|
||||
setCloudSessionsEnabled(true);
|
||||
const raw = JSON.parse(
|
||||
readFileSync(resolveDesktopSettingsPath(), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(raw.cloudSessionsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("treats malformed files and non-boolean values as off", () => {
|
||||
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
|
||||
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
writeFileSync(
|
||||
resolveDesktopSettingsPath(),
|
||||
JSON.stringify({ cloudSessionsEnabled: "yes" }),
|
||||
"utf8",
|
||||
);
|
||||
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
/**
|
||||
* Desktop-app-only preferences.
|
||||
*
|
||||
* These are kept out of the shared `global-settings.json` on purpose: that
|
||||
* file is parsed with a strict schema by every Cline app, and an older CLI
|
||||
* writing settings would silently strip fields it does not know about.
|
||||
*/
|
||||
export type DesktopSettings = {
|
||||
/** Opt-in gate for cloud sessions while the feature is in preview. */
|
||||
cloudSessionsEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: DesktopSettings = {
|
||||
cloudSessionsEnabled: false,
|
||||
};
|
||||
|
||||
export function resolveDesktopSettingsPath(): string {
|
||||
return join(resolveClineDataDir(), "settings", "code-settings.json");
|
||||
}
|
||||
|
||||
export function readDesktopSettings(): DesktopSettings {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(resolveDesktopSettingsPath(), "utf8");
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
cloudSessionsEnabled: parsed.cloudSessionsEnabled === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDesktopSettings(settings: DesktopSettings): void {
|
||||
const filePath = resolveDesktopSettingsPath();
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
// Write-then-rename keeps the file whole if two app instances race or the
|
||||
// process dies mid-write; a torn JSON file would silently reset settings.
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
export function setCloudSessionsEnabled(enabled: boolean): DesktopSettings {
|
||||
const next = { ...readDesktopSettings(), cloudSessionsEnabled: enabled };
|
||||
writeDesktopSettings(next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setCloudSessionsEnabled } from "./desktop-settings";
|
||||
import { isCloudAgentsEnabled } from "./feature-flags";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cline-feature-flags-"));
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_CODE_CLOUD_AGENTS;
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("isCloudAgentsEnabled", () => {
|
||||
it("defaults to off with no setting and no override", () => {
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("follows the user's settings opt-in toggle", () => {
|
||||
setCloudSessionsEnabled(true);
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
setCloudSessionsEnabled(false);
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("honors the env override in both directions", () => {
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "true";
|
||||
expect(isCloudAgentsEnabled()).toBe(true);
|
||||
// The env override wins over the settings toggle in both directions.
|
||||
setCloudSessionsEnabled(true);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "0";
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
process.env.CLINE_CODE_CLOUD_AGENTS = "false";
|
||||
expect(isCloudAgentsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readDesktopSettings } from "./desktop-settings";
|
||||
|
||||
/**
|
||||
* Env override first; otherwise the user's explicit opt-in from Settings.
|
||||
*
|
||||
* Cloud sessions are in preview, so the gate is a toggle the user flips in
|
||||
* Settings → General (default off) rather than a remote rollout flag. If a
|
||||
* PostHog flag is later added to control the toggle's visibility, wire it
|
||||
* through the sidecar's get_feature_flags command (see the commented gate in
|
||||
* settings-view.tsx).
|
||||
*/
|
||||
export function isCloudAgentsEnabled(): boolean {
|
||||
const override = process.env.CLINE_CODE_CLOUD_AGENTS?.trim().toLowerCase();
|
||||
if (override === "1" || override === "true") {
|
||||
return true;
|
||||
}
|
||||
if (override === "0" || override === "false") {
|
||||
return false;
|
||||
}
|
||||
return readDesktopSettings().cloudSessionsEnabled;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
setHomeDirIfUnset,
|
||||
watchManagedHubBuildMismatch,
|
||||
} from "@cline/core";
|
||||
import { captureSdkError, claimHubDaemonProcess } from "@cline/shared";
|
||||
import { captureSdkError } from "@cline/shared";
|
||||
import { prewarmWorkspaceMetadata } from "./chat-session";
|
||||
import { configureConnectorCliLaunch } from "./connectors";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./context";
|
||||
import { createDesktopObservability } from "./observability";
|
||||
import { resolveWorkspaceRoot } from "./paths";
|
||||
import { runRemoteHelperEntrypoint } from "./remote-helper";
|
||||
import { startServer } from "./server";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
import { buildTelemetrySelfcheckReport } from "./telemetry-selfcheck";
|
||||
@@ -186,10 +187,7 @@ async function runEntrypoint(): Promise<void> {
|
||||
runTelemetrySelfcheck();
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
|
||||
@@ -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,
|
||||
@@ -21,8 +22,9 @@ function createContext(workspaceRoot: string): SidecarContext {
|
||||
hubClient: null,
|
||||
workspaceRoot,
|
||||
unsubscribeSessionEvents: null,
|
||||
cloudSessionManager: null,
|
||||
hubBuildMismatch: null,
|
||||
};
|
||||
} as unknown as SidecarContext;
|
||||
}
|
||||
|
||||
describe("desktop MCP settings", () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SidecarContext } from "./types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./commands", () => ({
|
||||
handleCommand: mocks.handleCommand,
|
||||
}));
|
||||
|
||||
import { createFetchHandler } from "./server";
|
||||
|
||||
const server = {
|
||||
port: 3126,
|
||||
upgrade: vi.fn(() => true),
|
||||
};
|
||||
|
||||
describe("realtime session endpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns the short-lived realtime setup expected by the AI SDK hook", async () => {
|
||||
mocks.handleCommand.mockResolvedValue({
|
||||
kind: "realtime",
|
||||
providerId: "vercel-ai-gateway",
|
||||
modelId: "openai/gpt-realtime",
|
||||
supportsTools: true,
|
||||
token: "ephemeral-token",
|
||||
url: "wss://realtime.example.test/session",
|
||||
expiresAt: 1_785_280_000,
|
||||
transport: "vercel-ai-gateway",
|
||||
sessionConfig: { outputModalities: ["audio"] },
|
||||
});
|
||||
const ctx = {} as SidecarContext;
|
||||
const response = await createFetchHandler(ctx, vi.fn())(
|
||||
new Request("http://127.0.0.1:3126/api/modes/realtime/session", {
|
||||
method: "POST",
|
||||
headers: { origin: "tauri://localhost" },
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(mocks.handleCommand).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
"create_mode_session",
|
||||
{ mode: "realtimeVoice" },
|
||||
);
|
||||
expect(response?.status).toBe(200);
|
||||
if (!response) throw new Error("Missing realtime endpoint response");
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
token: "ephemeral-token",
|
||||
url: "wss://realtime.example.test/session",
|
||||
expiresAt: 1_785_280_000,
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "run_cline",
|
||||
description:
|
||||
"Send the user's complete request to the active Cline agent. You must call this exactly once for every user utterance. Cline owns conversation history, workspace context, tools, MCP, approvals, and persistence. After the tool returns, speak its response faithfully.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
request: {
|
||||
type: "string",
|
||||
description:
|
||||
"The user's complete request, preserving all relevant detail.",
|
||||
},
|
||||
},
|
||||
required: ["request"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invoke the sidecar command for an untrusted origin", async () => {
|
||||
const response = await createFetchHandler({} as SidecarContext, vi.fn())(
|
||||
new Request("http://127.0.0.1:3126/api/modes/realtime/session", {
|
||||
method: "POST",
|
||||
headers: { origin: "https://attacker.example" },
|
||||
}),
|
||||
server,
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
expect(mocks.handleCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
RemoteEnvironmentConnection,
|
||||
RemoteEnvironmentProfile,
|
||||
RemoteEnvironmentService,
|
||||
RemoteEnvironmentStatus,
|
||||
} from "./remote-environments";
|
||||
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 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 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;
|
||||
|
||||
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);
|
||||
|
||||
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(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") {
|
||||
return {
|
||||
stdout: "/srv/code/zeta\0/srv/code/project\0",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (input.command === "git" && input.args[0] === "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: "git",
|
||||
args: ["ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
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("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("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,732 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type RemoteCommandResult,
|
||||
type RemoteEnvironmentDependencies,
|
||||
RemoteEnvironmentService,
|
||||
type RemoteEnvironmentServiceOptions,
|
||||
type RemoteTunnelProcess,
|
||||
runRemoteProcess,
|
||||
} from "./remote-environments";
|
||||
|
||||
class FakeTunnel extends EventEmitter implements RemoteTunnelProcess {
|
||||
public readonly pid = 4242;
|
||||
public exitCode: number | null = null;
|
||||
public killed = false;
|
||||
|
||||
public kill(): boolean {
|
||||
this.killed = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface Invocation {
|
||||
executable: string;
|
||||
args: string[];
|
||||
options: { timeoutMs: number; inputFile?: string };
|
||||
}
|
||||
|
||||
function success(stdout = "", stderr = ""): RemoteCommandResult {
|
||||
return { stdout, stderr, exitCode: 0 };
|
||||
}
|
||||
|
||||
function inspection(
|
||||
platform: string,
|
||||
arch: string,
|
||||
home: string,
|
||||
prefix = "",
|
||||
): RemoteCommandResult {
|
||||
return success(
|
||||
`${prefix}\0CLINE_REMOTE_INSPECT_V1\0${platform}\0${arch}\0${home}\0`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RemoteEnvironmentService", () => {
|
||||
let testDirectory: string;
|
||||
let profilesPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDirectory = await mkdtemp(join(tmpdir(), "cline-remote-environments-"));
|
||||
profilesPath = join(
|
||||
testDirectory,
|
||||
"data",
|
||||
"settings",
|
||||
"remote-environments.json",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createService(
|
||||
overrides: Partial<RemoteEnvironmentDependencies> = {},
|
||||
options: Omit<
|
||||
RemoteEnvironmentServiceOptions,
|
||||
"profilesPath" | "dependencies"
|
||||
> = {},
|
||||
): RemoteEnvironmentService {
|
||||
let id = 0;
|
||||
return new RemoteEnvironmentService({
|
||||
...options,
|
||||
profilesPath,
|
||||
dependencies: {
|
||||
now: () => new Date("2026-08-06T12:00:00.000Z"),
|
||||
randomId: () => `test-id-${++id}`,
|
||||
requestHubShutdown: async () => true,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("drains ProxyCommand output after SSH exits", async () => {
|
||||
const lateDiagnosticProgram =
|
||||
'const { spawn } = require("node:child_process");' +
|
||||
'spawn(process.execPath, ["-e", "setTimeout(() => process.stderr.write(\\"remote helper failed\\\\n\\"), 40)"], { stdio: ["ignore", "ignore", 2] });' +
|
||||
'process.stderr.write("gcloud NumPy warning\\n");' +
|
||||
"process.exit(23);";
|
||||
|
||||
const result = await runRemoteProcess(
|
||||
process.execPath,
|
||||
["-e", lateDiagnosticProgram],
|
||||
{ timeoutMs: 2_000 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 23, stdout: "" });
|
||||
expect(result.stderr).toContain("gcloud NumPy warning");
|
||||
expect(result.stderr).toContain("remote helper failed");
|
||||
});
|
||||
|
||||
it("persists profiles atomically with private permissions and updates in place", async () => {
|
||||
const service = createService();
|
||||
const created = await service.upsert({
|
||||
name: " Build box ",
|
||||
host: "build.example.com",
|
||||
user: "alice",
|
||||
port: 2222,
|
||||
identityFile: "~/.ssh/build_ed25519",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "test-id-1",
|
||||
name: "Build box",
|
||||
host: "build.example.com",
|
||||
createdAt: "2026-08-06T12:00:00.000Z",
|
||||
updatedAt: "2026-08-06T12:00:00.000Z",
|
||||
});
|
||||
expect((await stat(profilesPath)).mode & 0o777).toBe(0o600);
|
||||
|
||||
const stored = JSON.parse(await readFile(profilesPath, "utf8"));
|
||||
expect(stored).toEqual({ version: 1, profiles: [created] });
|
||||
const updated = await service.upsert({
|
||||
...created,
|
||||
name: "Build box renamed",
|
||||
});
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.createdAt).toBe(created.createdAt);
|
||||
expect(await service.list()).toEqual([updated]);
|
||||
await expect(
|
||||
service.upsert({ ...updated, host: "other.example.com" }),
|
||||
).rejects.toThrow("Create a new remote environment instead");
|
||||
expect(await service.list()).toEqual([updated]);
|
||||
|
||||
const reloaded = createService();
|
||||
expect(await reloaded.list()).toEqual([updated]);
|
||||
});
|
||||
|
||||
it("validates profile fields before persisting them", async () => {
|
||||
const service = createService();
|
||||
await expect(
|
||||
service.upsert({ name: "bad", host: "-oProxyCommand=bad" }),
|
||||
).rejects.toThrow("SSH host");
|
||||
await expect(
|
||||
service.upsert({ name: "bad", host: "host", port: 70_000 }),
|
||||
).rejects.toThrow("between 1 and 65535");
|
||||
await expect(service.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("tests SSH connectivity with safe non-interactive OpenSSH options", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
return inspection("Linux", "x86_64", "/home/alice");
|
||||
},
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "Remote",
|
||||
host: "ssh-alias",
|
||||
user: "alice",
|
||||
port: 2202,
|
||||
identityFile: "/keys/remote key",
|
||||
});
|
||||
|
||||
await expect(service.test(profile.id)).resolves.toMatchObject({
|
||||
profileId: profile.id,
|
||||
state: "available",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "x64",
|
||||
remoteHome: "/home/alice",
|
||||
});
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(invocations[0]?.executable).toBe("ssh");
|
||||
expect(invocations[0]?.args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"ConnectTimeout=10",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-p",
|
||||
"2202",
|
||||
"-i",
|
||||
"/keys/remote key",
|
||||
"alice@ssh-alias",
|
||||
]),
|
||||
);
|
||||
expect(invocations[0]?.args.at(-1)).toContain("uname -s");
|
||||
});
|
||||
|
||||
it("leaves the SSH port unset so an OpenSSH config alias can choose it", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
return inspection("Linux", "x86_64", "/home/alice");
|
||||
},
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "Configured host",
|
||||
host: "pi-from-ssh-config",
|
||||
});
|
||||
|
||||
await service.test(profile.id);
|
||||
|
||||
expect(invocations[0]?.args).not.toContain("-p");
|
||||
expect(invocations[0]?.args.at(-2)).toBe("pi-from-ssh-config");
|
||||
});
|
||||
|
||||
it("parses framed inspection data after noisy SSH startup output", async () => {
|
||||
const service = createService({
|
||||
runProcess: async () => ({
|
||||
...inspection(
|
||||
"Linux",
|
||||
"aarch64",
|
||||
"/home/pi",
|
||||
"Welcome to the Pi server\n.bashrc says hello\n",
|
||||
),
|
||||
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
|
||||
}),
|
||||
});
|
||||
const profile = await service.upsert({ name: "Pi", host: "pi" });
|
||||
|
||||
await expect(service.test(profile.id)).resolves.toMatchObject({
|
||||
state: "available",
|
||||
remotePlatform: "linux",
|
||||
remoteArch: "arm64",
|
||||
remoteHome: "/home/pi",
|
||||
});
|
||||
});
|
||||
|
||||
it("bootstraps the exact helper and creates a loopback-only SSH tunnel", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const tunnel = new FakeTunnel();
|
||||
const spawnTunnel = vi.fn(() => tunnel);
|
||||
const waitForTunnel = vi.fn(async () => undefined);
|
||||
const requestHubShutdown = vi.fn(async () => {
|
||||
expect(tunnel.killed).toBe(false);
|
||||
return true;
|
||||
});
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/dev");
|
||||
}
|
||||
if (command.includes("'test' '-x'")) {
|
||||
return { stdout: "", stderr: "", exitCode: 1 };
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"remote-secret"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async ({ platform, arch }) => {
|
||||
expect({ platform, arch }).toEqual({
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
});
|
||||
return "/opt/cline/code-sidecar-linux-arm64";
|
||||
},
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "abcdef0123456789fedcba9876543210",
|
||||
reservePort: async () => 43117,
|
||||
spawnTunnel,
|
||||
waitForTunnel,
|
||||
requestHubShutdown,
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "ARM builder",
|
||||
host: "arm-builder",
|
||||
});
|
||||
|
||||
const connection = await service.connect(profile.id);
|
||||
expect(connection).toMatchObject({
|
||||
profile,
|
||||
profileId: profile.id,
|
||||
state: "connected",
|
||||
endpoint: "ws://127.0.0.1:43117/hub",
|
||||
authToken: "remote-secret",
|
||||
workspaceRoot: "/home/dev",
|
||||
homeDir: "/home/dev",
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
remoteHubUrl: "ws://127.0.0.1:25463/hub",
|
||||
localPort: 43117,
|
||||
});
|
||||
expect(service.getActive()).toEqual(connection);
|
||||
expect(service.getConnection(profile.id)).toEqual(connection);
|
||||
|
||||
const upload = invocations.find(
|
||||
(invocation) => invocation.options.inputFile,
|
||||
);
|
||||
expect(upload).toMatchObject({
|
||||
executable: "ssh",
|
||||
options: { inputFile: "/opt/cline/code-sidecar-linux-arm64" },
|
||||
});
|
||||
expect(upload?.args.at(-1)).toContain("umask 077; cat >");
|
||||
const ensure = invocations.find((invocation) =>
|
||||
invocation.args.at(-1)?.includes("--remote-hub-ensure"),
|
||||
);
|
||||
expect(ensure?.args.at(-1)).toContain("'/home/dev'");
|
||||
expect(ensure?.args.at(-1)).toContain(
|
||||
"'/home/dev/.cline/data/remote/desktop-hub.json'",
|
||||
);
|
||||
expect(spawnTunnel).toHaveBeenCalledWith(
|
||||
"ssh",
|
||||
expect.arrayContaining([
|
||||
"-N",
|
||||
"ExitOnForwardFailure=yes",
|
||||
"-L",
|
||||
"127.0.0.1:43117:127.0.0.1:25463",
|
||||
"arm-builder",
|
||||
]),
|
||||
);
|
||||
expect(waitForTunnel).toHaveBeenCalledWith(43117, tunnel, 10_000);
|
||||
|
||||
await expect(service.disconnect()).resolves.toBe(true);
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:43117/hub",
|
||||
"remote-secret",
|
||||
);
|
||||
expect(tunnel.killed).toBe(true);
|
||||
expect(service.getActive()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bounds Hub shutdown before closing the SSH tunnel", async () => {
|
||||
const tunnel = new FakeTunnel();
|
||||
const requestHubShutdown = vi.fn(
|
||||
async () => await new Promise<boolean>(() => undefined),
|
||||
);
|
||||
const service = createService(
|
||||
{
|
||||
runProcess: async (_executable, args) => {
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/pi");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/opt/cline/helper",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => 43_000,
|
||||
spawnTunnel: () => tunnel,
|
||||
waitForTunnel: async () => undefined,
|
||||
requestHubShutdown,
|
||||
},
|
||||
{ hubShutdownTimeoutMs: 5 },
|
||||
);
|
||||
const profile = await service.upsert({ name: "Pi", host: "pi" });
|
||||
await service.connect(profile.id);
|
||||
|
||||
await expect(service.disconnect(profile.id)).resolves.toBe(true);
|
||||
expect(requestHubShutdown).toHaveBeenCalledOnce();
|
||||
expect(tunnel.killed).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses an already-installed content-addressed helper", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Darwin", "arm64", "/Users/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
'{"url":"ws://localhost:29000/hub","authToken":"token"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/Applications/Cline.app/sidecar",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => 40000,
|
||||
spawnTunnel: () => new FakeTunnel(),
|
||||
waitForTunnel: async () => undefined,
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "Mac",
|
||||
host: "mac",
|
||||
});
|
||||
await service.connect(profile.id);
|
||||
|
||||
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows renaming but keeps a profile's SSH destination immutable", async () => {
|
||||
const tunnel = new FakeTunnel();
|
||||
const spawnTunnel = vi.fn(() => tunnel);
|
||||
const service = createService({
|
||||
runProcess: async (_executable, args) => {
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/pi");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/opt/cline/helper",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => 42_000,
|
||||
spawnTunnel,
|
||||
waitForTunnel: async () => undefined,
|
||||
});
|
||||
const created = await service.upsert({
|
||||
name: "Pi",
|
||||
host: "pi-from-ssh-config",
|
||||
user: "pi",
|
||||
identityFile: "/keys/pi_ed25519",
|
||||
});
|
||||
const first = await service.connect(created.id);
|
||||
|
||||
const renamed = await service.upsert({
|
||||
...created,
|
||||
name: "Pi renamed",
|
||||
});
|
||||
expect(service.getConnection(created.id)?.profile).toEqual(renamed);
|
||||
await expect(service.connect(created.id)).resolves.toMatchObject({
|
||||
localPort: first.localPort,
|
||||
profile: renamed,
|
||||
});
|
||||
expect(spawnTunnel).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (const destinationUpdate of [
|
||||
{ host: "different-host" },
|
||||
{ user: "different-user" },
|
||||
{ port: 22 },
|
||||
]) {
|
||||
await expect(
|
||||
service.upsert({ ...renamed, ...destinationUpdate }),
|
||||
).rejects.toThrow("Create a new remote environment instead");
|
||||
}
|
||||
await expect(
|
||||
service.upsert({
|
||||
...renamed,
|
||||
identityFile: "/keys/replacement_ed25519",
|
||||
}),
|
||||
).rejects.toThrow("Disconnect the remote environment");
|
||||
|
||||
expect(service.getConnection(created.id)).toMatchObject({
|
||||
localPort: first.localPort,
|
||||
profile: renamed,
|
||||
});
|
||||
expect(await service.list()).toEqual([renamed]);
|
||||
expect(spawnTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.killed).toBe(false);
|
||||
|
||||
await service.disconnect(created.id);
|
||||
const updatedIdentity = await service.upsert({
|
||||
...renamed,
|
||||
identityFile: "/keys/replacement_ed25519",
|
||||
});
|
||||
expect(await service.list()).toEqual([updatedIdentity]);
|
||||
});
|
||||
|
||||
it("fails clearly when no helper exists for the remote target and never downloads one", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
return args.at(-1)?.includes("uname -s")
|
||||
? inspection("Linux", "aarch64", "/home/dev")
|
||||
: success();
|
||||
},
|
||||
resolveHelperBinary: async () => undefined,
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "Remote",
|
||||
host: "remote",
|
||||
});
|
||||
|
||||
await expect(service.connect(profile.id)).rejects.toThrow(
|
||||
"unsupported in SSH v0: no compatible desktop helper binary",
|
||||
);
|
||||
expect(invocations.some((invocation) => invocation.options.inputFile)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the active tunnel alive when a replacement connection fails", async () => {
|
||||
const tunnels: FakeTunnel[] = [];
|
||||
let nextPort = 41_000;
|
||||
const service = createService({
|
||||
runProcess: async (_executable, args) => {
|
||||
const destination = args.at(-2);
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", "/home/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return destination === "host-b"
|
||||
? { stdout: "", stderr: "bootstrap failed", exitCode: 1 }
|
||||
: success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/opt/cline/helper",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => nextPort++,
|
||||
spawnTunnel: () => {
|
||||
const tunnel = new FakeTunnel();
|
||||
tunnels.push(tunnel);
|
||||
return tunnel;
|
||||
},
|
||||
waitForTunnel: async () => undefined,
|
||||
});
|
||||
const hostA = await service.upsert({
|
||||
name: "Host A",
|
||||
host: "host-a",
|
||||
});
|
||||
const hostB = await service.upsert({
|
||||
name: "Host B",
|
||||
host: "host-b",
|
||||
});
|
||||
|
||||
const firstConnection = await service.connect(hostA.id);
|
||||
await expect(service.connect(hostB.id)).rejects.toThrow("bootstrap failed");
|
||||
|
||||
expect(service.getActive()).toEqual(firstConnection);
|
||||
expect(service.getConnection(hostA.id)).toEqual(firstConnection);
|
||||
expect(tunnels[0]?.killed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the previous tunnel until the runtime switch commits", async () => {
|
||||
const tunnels: FakeTunnel[] = [];
|
||||
let nextPort = 42_000;
|
||||
const requestHubShutdown = vi.fn(async () => true);
|
||||
const service = createService({
|
||||
runProcess: async (_executable, args) => {
|
||||
const destination = args.at(-2) ?? "host-a";
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "aarch64", `/home/${destination}`);
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
`{"url":"ws://127.0.0.1:25463/hub","authToken":"${destination}-token"}\n`,
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/opt/cline/helper",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => nextPort++,
|
||||
spawnTunnel: () => {
|
||||
const tunnel = new FakeTunnel();
|
||||
tunnels.push(tunnel);
|
||||
return tunnel;
|
||||
},
|
||||
waitForTunnel: async () => undefined,
|
||||
requestHubShutdown,
|
||||
});
|
||||
const hostA = await service.upsert({ name: "Host A", host: "host-a" });
|
||||
const hostB = await service.upsert({ name: "Host B", host: "host-b" });
|
||||
|
||||
const connectionA = await service.connect(hostA.id);
|
||||
const connectionB = await service.connect(hostB.id);
|
||||
|
||||
expect(service.getActive()).toEqual(connectionB);
|
||||
expect(service.getConnection(hostA.id)).toEqual(connectionA);
|
||||
expect(service.getConnection(hostB.id)).toEqual(connectionB);
|
||||
expect(tunnels[0]?.killed).toBe(false);
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
|
||||
expect(service.activateConnection(hostA.id)).toBe(true);
|
||||
expect(service.getActive()).toEqual(connectionA);
|
||||
expect(service.activateConnection("missing-host")).toBe(false);
|
||||
expect(service.getActive()).toEqual(connectionA);
|
||||
expect(service.activateConnection(hostB.id)).toBe(true);
|
||||
|
||||
expect(await service.disconnect(hostA.id)).toBe(true);
|
||||
expect(tunnels[0]?.killed).toBe(true);
|
||||
expect(tunnels[1]?.killed).toBe(false);
|
||||
expect(service.getConnection(hostB.id)).toEqual(connectionB);
|
||||
expect(service.getActive()).toEqual(connectionB);
|
||||
});
|
||||
|
||||
it("reports tunnel loss separately from ordinary connection errors", async () => {
|
||||
const tunnel = new FakeTunnel();
|
||||
const onConnectionLost = vi.fn();
|
||||
const requestHubShutdown = vi.fn(async () => true);
|
||||
const service = new RemoteEnvironmentService({
|
||||
profilesPath,
|
||||
onConnectionLost,
|
||||
dependencies: {
|
||||
now: () => new Date("2026-08-06T12:00:00.000Z"),
|
||||
randomId: () => "lost-profile",
|
||||
runProcess: async (_executable, args) => {
|
||||
const command = args.at(-1) ?? "";
|
||||
if (command.includes("uname -s")) {
|
||||
return inspection("Linux", "x86_64", "/home/dev");
|
||||
}
|
||||
if (command.includes("--remote-hub-ensure")) {
|
||||
return success(
|
||||
'{"url":"ws://127.0.0.1:25463/hub","authToken":"token"}\n',
|
||||
);
|
||||
}
|
||||
return success();
|
||||
},
|
||||
resolveHelperBinary: async () => "/opt/cline/helper",
|
||||
fileReadable: async () => true,
|
||||
hashFile: async () => "0123456789abcdef",
|
||||
reservePort: async () => 41_000,
|
||||
spawnTunnel: () => tunnel,
|
||||
waitForTunnel: async () => undefined,
|
||||
requestHubShutdown,
|
||||
},
|
||||
});
|
||||
const profile = await service.upsert({
|
||||
name: "Remote",
|
||||
host: "remote",
|
||||
});
|
||||
await service.connect(profile.id);
|
||||
|
||||
tunnel.emit("exit", 255, null);
|
||||
|
||||
expect(service.getActive()).toBeUndefined();
|
||||
expect(onConnectionLost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileId: profile.id,
|
||||
state: "error",
|
||||
}),
|
||||
);
|
||||
// The tunnel is already gone, so leave the dedicated owner record for a
|
||||
// later reconnect instead of risking shutdown of an unrelated local Hub.
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quotes command arguments and rejects non-zero remote commands", async () => {
|
||||
const invocations: Invocation[] = [];
|
||||
let failCommand = false;
|
||||
const service = createService({
|
||||
runProcess: async (executable, args, options) => {
|
||||
invocations.push({ executable, args, options });
|
||||
if (args.at(-1)?.includes("uname -s")) {
|
||||
return inspection("Linux", "x86_64", "/home/dev");
|
||||
}
|
||||
return failCommand
|
||||
? { stdout: "", stderr: "permission denied", exitCode: 13 }
|
||||
: success("ok");
|
||||
},
|
||||
});
|
||||
const profile = await service.upsert({ name: "Remote", host: "remote" });
|
||||
await expect(
|
||||
service.run(profile.id, {
|
||||
command: "printf",
|
||||
args: ["%s", "a'b; touch /tmp/not-created"],
|
||||
cwd: "/tmp/a b",
|
||||
}),
|
||||
).resolves.toEqual({ stdout: "ok", stderr: "", exitCode: 0 });
|
||||
expect(invocations.at(-1)?.args.at(-1)).toBe(
|
||||
`cd '/tmp/a b' && exec 'printf' '%s' 'a'"'"'b; touch /tmp/not-created'`,
|
||||
);
|
||||
|
||||
failCommand = true;
|
||||
await expect(
|
||||
service.run(profile.id, { command: "false", args: [] }),
|
||||
).rejects.toThrow("permission denied");
|
||||
});
|
||||
|
||||
it("does not let a ProxyCommand warning mask the remote helper diagnostic", async () => {
|
||||
const service = createService({
|
||||
runProcess: async () => ({
|
||||
stdout: "Timed out waiting for detached hub startup.\n",
|
||||
stderr: "gcloud: NumPy is not installed; tunnel may be slower.\n",
|
||||
exitCode: 255,
|
||||
}),
|
||||
});
|
||||
const profile = await service.upsert({ name: "GCP", host: "gcp-iap" });
|
||||
|
||||
const error = await service
|
||||
.run(profile.id, { command: "remote-helper", args: [] })
|
||||
.catch((failure: unknown) => failure);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain("exit 255");
|
||||
expect((error as Error).message).toContain(
|
||||
"gcloud: NumPy is not installed",
|
||||
);
|
||||
expect((error as Error).message).toContain(
|
||||
"Timed out waiting for detached hub startup",
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes profiles", async () => {
|
||||
const service = createService();
|
||||
const profile = await service.upsert({ name: "Remote", host: "remote" });
|
||||
|
||||
await expect(service.delete(profile.id)).resolves.toBe(true);
|
||||
await expect(service.delete(profile.id)).resolves.toBe(false);
|
||||
await expect(service.list()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("turns SSH failures into an error status during connection tests", async () => {
|
||||
const service = createService({
|
||||
runProcess: async () => ({
|
||||
stdout: "",
|
||||
stderr: "Host key verification failed",
|
||||
exitCode: 255,
|
||||
}),
|
||||
});
|
||||
const profile = await service.upsert({ name: "Remote", host: "remote" });
|
||||
await expect(service.test(profile.id)).resolves.toMatchObject({
|
||||
state: "error",
|
||||
message: expect.stringContaining("Host key verification failed"),
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type RemoteHelperDependencies,
|
||||
runRemoteHelperEntrypoint,
|
||||
} from "./remote-helper";
|
||||
|
||||
function createDependencies(
|
||||
overrides: Partial<RemoteHelperDependencies> = {},
|
||||
): {
|
||||
dependencies: RemoteHelperDependencies;
|
||||
output: string[];
|
||||
} {
|
||||
const output: string[] = [];
|
||||
return {
|
||||
output,
|
||||
dependencies: {
|
||||
ensureDetachedHubServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "desktop-owner-token",
|
||||
})),
|
||||
claimHubDaemonProcess: vi.fn(() => false),
|
||||
loadHubDaemon: vi.fn(async () => undefined),
|
||||
ensureLoginShellPath: vi.fn(async () => ({
|
||||
status: "skipped" as const,
|
||||
reason: "test",
|
||||
})),
|
||||
setHomeDirIfUnset: vi.fn(),
|
||||
homeDir: () => "/home/pi",
|
||||
cwd: () => "/home/pi",
|
||||
env: {},
|
||||
writeOutput: (value) => output.push(value),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("remote helper entrypoint", () => {
|
||||
it("starts only the explicitly owned desktop Hub discovery record", async () => {
|
||||
const { dependencies, output } = createDependencies();
|
||||
const discoveryPath = "/home/pi/.cline/data/remote/desktop-hub.json";
|
||||
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(
|
||||
[
|
||||
"code-sidecar",
|
||||
"--remote-hub-ensure",
|
||||
"--cwd",
|
||||
"/home/pi",
|
||||
"--discovery-path",
|
||||
discoveryPath,
|
||||
],
|
||||
dependencies,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(dependencies.env.CLINE_HUB_DISCOVERY_PATH).toBe(discoveryPath);
|
||||
expect(dependencies.ensureDetachedHubServer).toHaveBeenCalledWith(
|
||||
"/home/pi",
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
allowPortFallback: true,
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(output.join(""))).toMatchObject({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "desktop-owner-token",
|
||||
cwd: "/home/pi",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses bootstrap without an explicit discovery owner", async () => {
|
||||
const { dependencies } = createDependencies();
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(
|
||||
["code-sidecar", "--remote-hub-ensure"],
|
||||
dependencies,
|
||||
),
|
||||
).rejects.toThrow("--discovery-path is required");
|
||||
});
|
||||
|
||||
it("hosts the detached daemon when the one-shot sentinel is claimed", async () => {
|
||||
const loadHubDaemon = vi.fn(async () => undefined);
|
||||
const { dependencies } = createDependencies({
|
||||
claimHubDaemonProcess: () => true,
|
||||
loadHubDaemon,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRemoteHelperEntrypoint(["code-sidecar"], dependencies),
|
||||
).resolves.toBe(true);
|
||||
expect(loadHubDaemon).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { homedir } from "node:os";
|
||||
import { ensureDetachedHubServer, setHomeDirIfUnset } from "@cline/core";
|
||||
import { claimHubDaemonProcess } from "@cline/shared";
|
||||
import { ensureLoginShellPath } from "./shell-path";
|
||||
|
||||
export type RemoteHelperDependencies = {
|
||||
ensureDetachedHubServer: typeof ensureDetachedHubServer;
|
||||
claimHubDaemonProcess: typeof claimHubDaemonProcess;
|
||||
loadHubDaemon: () => Promise<unknown>;
|
||||
ensureLoginShellPath: typeof ensureLoginShellPath;
|
||||
setHomeDirIfUnset: typeof setHomeDirIfUnset;
|
||||
homeDir: () => string;
|
||||
cwd: () => string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
writeOutput: (output: string) => void;
|
||||
};
|
||||
|
||||
const defaultDependencies: RemoteHelperDependencies = {
|
||||
ensureDetachedHubServer,
|
||||
claimHubDaemonProcess,
|
||||
loadHubDaemon: () => import("@cline/core/hub/daemon-entry"),
|
||||
ensureLoginShellPath,
|
||||
setHomeDirIfUnset,
|
||||
homeDir: homedir,
|
||||
cwd: () => process.cwd(),
|
||||
env: process.env,
|
||||
writeOutput: (output) => process.stdout.write(output),
|
||||
};
|
||||
|
||||
function readArgument(argv: string[], name: string): string | undefined {
|
||||
const index = argv.indexOf(name);
|
||||
const value = index >= 0 ? argv[index + 1] : undefined;
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function configureDedicatedDiscovery(
|
||||
argv: string[],
|
||||
dependencies: RemoteHelperDependencies,
|
||||
): string {
|
||||
const discoveryPath = readArgument(argv, "--discovery-path");
|
||||
if (!discoveryPath) {
|
||||
throw new Error("--discovery-path is required for remote Hub management");
|
||||
}
|
||||
// This explicit owner record is the safety boundary: the remote helper never
|
||||
// reads or shuts down the user's default CLI-owned Hub discovery record.
|
||||
dependencies.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
|
||||
return discoveryPath;
|
||||
}
|
||||
|
||||
export async function runRemoteHubEnsure(
|
||||
argv = process.argv,
|
||||
dependencies: RemoteHelperDependencies = defaultDependencies,
|
||||
): Promise<void> {
|
||||
dependencies.setHomeDirIfUnset(dependencies.homeDir());
|
||||
await dependencies.ensureLoginShellPath();
|
||||
const cwd = readArgument(argv, "--cwd") ?? dependencies.cwd();
|
||||
configureDedicatedDiscovery(argv, dependencies);
|
||||
const result = await dependencies.ensureDetachedHubServer(cwd, {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
pathname: "/hub",
|
||||
allowPortFallback: true,
|
||||
});
|
||||
dependencies.writeOutput(
|
||||
`${JSON.stringify({
|
||||
...result,
|
||||
cwd,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the SSH bootstrap command and the detached-daemon sentinel. The
|
||||
* full desktop sidecar imports this function for same-platform remote hosts;
|
||||
* packaged Linux SSH helpers compile this file directly and contain no desktop
|
||||
* HTTP/WebSocket server or UI command router.
|
||||
*/
|
||||
export async function runRemoteHelperEntrypoint(
|
||||
argv = process.argv,
|
||||
dependencies: RemoteHelperDependencies = defaultDependencies,
|
||||
): Promise<boolean> {
|
||||
if (argv.includes("--remote-hub-ensure")) {
|
||||
await runRemoteHubEnsure(argv, dependencies);
|
||||
return true;
|
||||
}
|
||||
// Claim rather than read: consuming the sentinel keeps daemon-hosted
|
||||
// sessions from handing it to every process they spawn.
|
||||
if (dependencies.claimHubDaemonProcess()) {
|
||||
await dependencies.loadHubDaemon();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
void (async () => {
|
||||
if (!(await runRemoteHelperEntrypoint())) {
|
||||
throw new Error("A remote helper command is required");
|
||||
}
|
||||
})().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_RECORDED_AUDIO_BASE64_BYTES,
|
||||
MAX_RECORDED_AUDIO_BYTES,
|
||||
@@ -24,6 +27,20 @@ function createTelemetryHandler(capture = vi.fn()) {
|
||||
};
|
||||
}
|
||||
|
||||
const originalSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSessionDataDir === undefined) {
|
||||
delete process.env.CLINE_SESSION_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_SESSION_DATA_DIR = originalSessionDataDir;
|
||||
}
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("sidecar WebSocket payload limit", () => {
|
||||
it("accepts every recording allowed by the voice input size limit", () => {
|
||||
const handler = createWebSocketHandler({} as SidecarContext);
|
||||
@@ -105,6 +122,41 @@ describe("sidecar HTTP origin checks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session video artifacts", () => {
|
||||
it("serves a generated video only from the session artifact directory", async () => {
|
||||
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-video-artifact-"));
|
||||
temporaryDirectories.push(sessionsDir);
|
||||
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
|
||||
const artifactsDir = join(sessionsDir, "session-1", "artifacts");
|
||||
mkdirSync(artifactsDir, { recursive: true });
|
||||
writeFileSync(join(artifactsDir, "video-result.mp4"), "video-bytes");
|
||||
|
||||
const response = await createHandler()(
|
||||
new Request(
|
||||
"http://127.0.0.1:3126/api/session-artifacts/session-1/video-result.mp4",
|
||||
{ headers: { origin: "tauri://localhost" } },
|
||||
),
|
||||
createTestServer(),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(response?.headers.get("content-type")).toBe("video/mp4");
|
||||
await expect(response?.text()).resolves.toBe("video-bytes");
|
||||
});
|
||||
|
||||
it("rejects untrusted origins for session artifacts", async () => {
|
||||
const response = await createHandler()(
|
||||
new Request(
|
||||
"http://127.0.0.1:3126/api/session-artifacts/session-1/video.mp4",
|
||||
{ headers: { origin: "https://attacker.example" } },
|
||||
),
|
||||
createTestServer(),
|
||||
);
|
||||
|
||||
expect(response?.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop error telemetry", () => {
|
||||
it("captures sanitized webview error reports with structured context", async () => {
|
||||
const server = createTestServer();
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { captureSdkError } from "@cline/shared";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
captureSdkError,
|
||||
REALTIME_CLINE_TOOLS,
|
||||
type RealtimeVoiceModeSession,
|
||||
} from "@cline/shared";
|
||||
import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
|
||||
import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits";
|
||||
import { handleCommand } from "./commands";
|
||||
@@ -6,6 +14,7 @@ import { encodeSidecarEvent, sendEvent } from "./context";
|
||||
import { fetchMarketplaceCatalog } from "./marketplace";
|
||||
import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth";
|
||||
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
|
||||
import { sharedSessionDataDir } from "./paths";
|
||||
import {
|
||||
BunRuntime,
|
||||
SIDECAR_HOST,
|
||||
@@ -40,6 +49,21 @@ const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
function artifactContentType(filename: string): string {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||
if (lower.endsWith(".wav")) return "audio/wav";
|
||||
if (lower.endsWith(".aac")) return "audio/aac";
|
||||
if (lower.endsWith(".m4a")) return "audio/mp4";
|
||||
if (lower.endsWith(".weba")) return "audio/webm";
|
||||
if (lower.endsWith(".flac")) return "audio/flac";
|
||||
if (lower.endsWith(".ogg")) return "audio/ogg";
|
||||
if (filename.toLowerCase().endsWith(".webm")) return "video/webm";
|
||||
if (filename.toLowerCase().endsWith(".mov")) return "video/quicktime";
|
||||
if (filename.toLowerCase().endsWith(".mpeg")) return "video/mpeg";
|
||||
return "video/mp4";
|
||||
}
|
||||
|
||||
function readOrigin(req: Request): string | undefined {
|
||||
const origin = req.headers.get("origin")?.trim();
|
||||
return origin ? origin : undefined;
|
||||
@@ -205,6 +229,73 @@ export function createFetchHandler(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
url.pathname.startsWith("/api/session-artifacts/")
|
||||
) {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const segments = url.pathname
|
||||
.slice("/api/session-artifacts/".length)
|
||||
.split("/")
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
const [sessionId, artifactName, ...extra] = segments;
|
||||
if (
|
||||
!sessionId ||
|
||||
!artifactName ||
|
||||
extra.length > 0 ||
|
||||
sessionId === "." ||
|
||||
sessionId === ".." ||
|
||||
artifactName === "." ||
|
||||
artifactName === ".." ||
|
||||
basename(sessionId) !== sessionId ||
|
||||
basename(artifactName) !== artifactName ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(sessionId) ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9._ -]*$/.test(artifactName)
|
||||
) {
|
||||
return new Response("Invalid artifact path", { status: 400 });
|
||||
}
|
||||
const artifactPath = join(
|
||||
sharedSessionDataDir(),
|
||||
sessionId,
|
||||
"artifacts",
|
||||
artifactName,
|
||||
);
|
||||
const artifactStat = await stat(artifactPath).catch(() => null);
|
||||
if (!artifactStat?.isFile()) {
|
||||
return new Response("Artifact not found", { status: 404 });
|
||||
}
|
||||
const range = req.headers.get("range")?.match(/^bytes=(\d+)-(\d*)$/);
|
||||
const start = range ? Number(range[1]) : 0;
|
||||
const requestedEnd = range?.[2]
|
||||
? Number(range[2])
|
||||
: artifactStat.size - 1;
|
||||
const end = Math.min(requestedEnd, artifactStat.size - 1);
|
||||
if (start < 0 || start > end || start >= artifactStat.size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { "content-range": `bytes */${artifactStat.size}` },
|
||||
});
|
||||
}
|
||||
const body = Readable.toWeb(
|
||||
createReadStream(artifactPath, { start, end }),
|
||||
);
|
||||
return new Response(body as unknown as BodyInit, {
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
...corsHeaders(req),
|
||||
"accept-ranges": "bytes",
|
||||
"cache-control": "private, max-age=31536000, immutable",
|
||||
"content-length": String(end - start + 1),
|
||||
"content-type": artifactContentType(artifactName),
|
||||
...(range
|
||||
? { "content-range": `bytes ${start}-${end}/${artifactStat.size}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/transport" &&
|
||||
isTrustedRequestOrigin(req) &&
|
||||
@@ -213,6 +304,46 @@ export function createFetchHandler(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname === "/api/modes/realtime/session" &&
|
||||
req.method === "POST"
|
||||
) {
|
||||
if (!isTrustedRequestOrigin(req)) {
|
||||
return createJsonResponse(
|
||||
req,
|
||||
{ error: "Untrusted request origin" },
|
||||
403,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const session = (await handleCommand(ctx, "create_mode_session", {
|
||||
mode: "realtimeVoice",
|
||||
})) as RealtimeVoiceModeSession;
|
||||
if (session.kind !== "realtime") {
|
||||
throw new Error("Realtime mode returned an unexpected session");
|
||||
}
|
||||
return createJsonResponse(req, {
|
||||
token: session.token,
|
||||
url: session.url,
|
||||
...(session.expiresAt === undefined
|
||||
? {}
|
||||
: { expiresAt: session.expiresAt }),
|
||||
tools: session.supportsTools ? REALTIME_CLINE_TOOLS : [],
|
||||
});
|
||||
} catch (error) {
|
||||
return createJsonResponse(
|
||||
req,
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create realtime session",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(req, await fetchMarketplaceCatalog());
|
||||
|
||||
@@ -48,6 +48,9 @@ export function discoverChatSessions(
|
||||
const out: JsonRecord[] = [];
|
||||
const store = new SqliteSessionStore();
|
||||
for (const [sessionId, session] of ctx.liveSessions.entries()) {
|
||||
if (session.config.executionTarget === "cloud") {
|
||||
continue;
|
||||
}
|
||||
if (!session.busy && !session.prompt && session.messages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("readSessionMessages", () => {
|
||||
expect.objectContaining({
|
||||
id: "assistant-message_tool_use_2",
|
||||
createdAt: assistantTimestamp + 1,
|
||||
meta: expect.objectContaining({ toolCallId: "tool-use" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -128,6 +129,92 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects generated video artifact blocks", async () => {
|
||||
const sessionId = `video-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-video",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "video",
|
||||
mediaType: "video/mp4",
|
||||
path: `/tmp/session/artifacts/video-result.mp4`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
videos: [
|
||||
{
|
||||
id: "assistant-video_video_0",
|
||||
mediaType: "video/mp4",
|
||||
artifactName: "video-result.mp4",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects generated audio artifact blocks", async () => {
|
||||
const sessionId = `audio-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-audio",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "audio",
|
||||
mediaType: "audio/mpeg",
|
||||
path: "/tmp/session/artifacts/audio-result.mp3",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
audios: [
|
||||
{
|
||||
id: "assistant-audio_audio_0",
|
||||
mediaType: "audio/mpeg",
|
||||
artifactName: "audio-result.mp3",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves absolute user run counts when older messages are omitted", async () => {
|
||||
const sessionId = `run-count-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { basename, dirname } from "node:path";
|
||||
import {
|
||||
getUserRunSpan,
|
||||
projectSessionMessagesForDisplay,
|
||||
@@ -333,28 +333,37 @@ export async function readSessionMessages(
|
||||
ctx: Pick<SidecarContext, "liveSessions">,
|
||||
sessionId: string,
|
||||
maxMessages = 800,
|
||||
/** Explicit authoritative source for remote sessions; bypasses local disk. */
|
||||
sourceMessages?: unknown[],
|
||||
): 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 messages =
|
||||
persisted && persisted.length > 0
|
||||
const isRemoteRead = sourceMessages !== undefined;
|
||||
const persisted = sourceMessages
|
||||
? undefined
|
||||
: (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 = (sourceMessages ??
|
||||
(persisted && persisted.length > 0
|
||||
? persisted
|
||||
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
|
||||
: (ctx.liveSessions.get(sessionId)?.messages ??
|
||||
[]))) as MessageWithMetadata[];
|
||||
const max = Math.max(1, maxMessages);
|
||||
const start = Math.max(0, messages.length - max);
|
||||
const displayMessages = projectSessionMessagesForDisplay(
|
||||
messages.slice(start),
|
||||
messages.slice(start) as MessageWithMetadata[],
|
||||
).map((entry) => ({
|
||||
message: entry.message,
|
||||
sourceIndex: start + entry.sourceIndex,
|
||||
}));
|
||||
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) {
|
||||
@@ -462,6 +471,16 @@ export async function readSessionMessages(
|
||||
|
||||
const textParts: string[] = [];
|
||||
const images: Array<{ id: string; mediaType: string; data: string }> = [];
|
||||
const videos: Array<{
|
||||
id: string;
|
||||
mediaType: string;
|
||||
artifactName: string;
|
||||
}> = [];
|
||||
const audios: Array<{
|
||||
id: string;
|
||||
mediaType: string;
|
||||
artifactName: string;
|
||||
}> = [];
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
let textSegmentIndex = 0;
|
||||
@@ -517,6 +536,7 @@ export async function readSessionMessages(
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: {
|
||||
toolName,
|
||||
...(toolUseId ? { toolCallId: toolUseId } : {}),
|
||||
hookEventName: "history_tool_use",
|
||||
},
|
||||
});
|
||||
@@ -547,6 +567,7 @@ export async function readSessionMessages(
|
||||
? (target.meta as JsonRecord)
|
||||
: {}),
|
||||
toolName,
|
||||
...(toolUseId ? { toolCallId: toolUseId } : {}),
|
||||
hookEventName: "history_tool_result",
|
||||
};
|
||||
}
|
||||
@@ -560,6 +581,7 @@ export async function readSessionMessages(
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: {
|
||||
toolName: "tool_result",
|
||||
...(toolUseId ? { toolCallId: toolUseId } : {}),
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
});
|
||||
@@ -588,6 +610,30 @@ export async function readSessionMessages(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === "video") {
|
||||
const mediaType = trimNonEmptyString(record.mediaType);
|
||||
const path = trimNonEmptyString(record.path);
|
||||
if (mediaType && path) {
|
||||
videos.push({
|
||||
id: `${messageIdBase}_video_${blockIdx}`,
|
||||
mediaType,
|
||||
artifactName: basename(path),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === "audio") {
|
||||
const mediaType = trimNonEmptyString(record.mediaType);
|
||||
const path = trimNonEmptyString(record.path);
|
||||
if (mediaType && path) {
|
||||
audios.push({
|
||||
id: `${messageIdBase}_audio_${blockIdx}`,
|
||||
mediaType,
|
||||
artifactName: basename(path),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === "media" && isGeneratedMedia(record.media)) {
|
||||
flushTextParts();
|
||||
out.push({
|
||||
@@ -628,6 +674,44 @@ export async function readSessionMessages(
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (videos.length > 0) {
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
target.videos = videos;
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_videos`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
videos,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (audios.length > 0) {
|
||||
const target = out
|
||||
.slice(outStartIndex)
|
||||
.find((item) => item.role === role);
|
||||
if (target) {
|
||||
target.audios = audios;
|
||||
} else {
|
||||
out.push({
|
||||
id: `${messageIdBase}_audios`,
|
||||
sessionId,
|
||||
role,
|
||||
content: "",
|
||||
audios,
|
||||
createdAt: nextPartCreatedAt(),
|
||||
meta: textMeta,
|
||||
});
|
||||
textMeta = undefined;
|
||||
}
|
||||
}
|
||||
if (reasoningParts.length > 0 || reasoningRedacted) {
|
||||
const reasoning = reasoningParts.join("\n").trim();
|
||||
const target = out
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -8,6 +8,12 @@ import type {
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type {
|
||||
RemoteEnvironmentConnection,
|
||||
RemoteEnvironmentService,
|
||||
} from "./remote-environments";
|
||||
|
||||
export const LOCAL_ENVIRONMENT_ID = "local";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -36,6 +42,7 @@ export type ChatSessionCommandRequest = {
|
||||
checkpointRunCount?: number;
|
||||
forkBeforeRunCount?: number;
|
||||
delivery?: "queue" | "steer";
|
||||
source?: "desktop" | "realtime";
|
||||
config?: JsonRecord;
|
||||
attachments?: ChatTurnAttachments;
|
||||
};
|
||||
@@ -49,6 +56,7 @@ export type PromptInQueue = {
|
||||
};
|
||||
|
||||
export type LiveSession = {
|
||||
environmentId?: string;
|
||||
config: JsonRecord;
|
||||
messages: MessageWithMetadata[];
|
||||
promptsInQueue: PromptInQueue[];
|
||||
@@ -68,6 +76,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;
|
||||
@@ -82,7 +100,7 @@ export type ToolApprovalRequestItem = {
|
||||
|
||||
export type PendingToolApproval = {
|
||||
item: ToolApprovalRequestItem;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
resolve: (result: ToolApprovalResult) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type AskQuestionRequestItem = {
|
||||
@@ -115,12 +133,18 @@ 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;
|
||||
unsubscribeSessionEvents: (() => void) | null;
|
||||
cloudSessionManager: {
|
||||
dispose(): Promise<void>;
|
||||
isCloudSession(sessionId: string): boolean;
|
||||
} | null;
|
||||
/**
|
||||
* Latest managed Hub build mismatch, broadcast as `hub_build_mismatch` and
|
||||
* replayed to webviews that connect after the event fired.
|
||||
|
||||
+1
@@ -519,6 +519,7 @@ dependencies = [
|
||||
name = "cline-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
|
||||
@@ -7,9 +7,10 @@ edition = "2021"
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2.11.1", features = ["image-png", "tray-icon"] }
|
||||
tauri = { version = "2.11.1", features = ["image-png", "macos-private-api", "tray-icon"] }
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
rfd = "0.15"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main-window",
|
||||
"description": "Permissions required by the main desktop window.",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "avatar-overlay"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-set-title",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::fs;
|
||||
@@ -14,17 +15,24 @@ use tauri::menu::{Menu, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
Emitter, Manager, RunEvent, State, WindowEvent,
|
||||
Emitter, Manager, RunEvent, State, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const UPDATE_INITIAL_DELAY: Duration = Duration::from_secs(10);
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const AVATAR_OVERLAY_WINDOW_LABEL: &str = "avatar-overlay";
|
||||
const DEFAULT_AVATAR_ID: &str = "cline-bot";
|
||||
const AVATAR_CHANGED_EVENT: &str = "avatar-changed";
|
||||
const AVATAR_SHOWN_EVENT: &str = "avatar-shown";
|
||||
const AVATAR_COLLAPSED_WIDTH: f64 = 144.0;
|
||||
const AVATAR_COLLAPSED_HEIGHT: f64 = 156.0;
|
||||
const TRAY_ICON_ID: &str = "cline-code";
|
||||
const TRAY_OPEN_MENU_ID: &str = "tray-open";
|
||||
const TRAY_NEW_SESSION_MENU_ID: &str = "tray-new-session";
|
||||
const TRAY_SETTINGS_MENU_ID: &str = "tray-settings";
|
||||
const TRAY_TOGGLE_AVATAR_MENU_ID: &str = "tray-toggle-avatar";
|
||||
const TRAY_QUIT_MENU_ID: &str = "tray-quit";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_IN_MENU_ID: &str = "view-zoom-in";
|
||||
@@ -68,6 +76,45 @@ struct AppContext {
|
||||
workspace_root: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AvatarManifest {
|
||||
id: String,
|
||||
display_name: String,
|
||||
description: String,
|
||||
sprite_version_number: u8,
|
||||
spritesheet_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AvatarOption {
|
||||
id: String,
|
||||
display_name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SelectedAvatar {
|
||||
id: String,
|
||||
display_name: String,
|
||||
description: String,
|
||||
sprite_url: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SelectedAvatarPreference {
|
||||
id: String,
|
||||
#[serde(default = "default_avatar_enabled")]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_avatar_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateStatus {
|
||||
@@ -460,6 +507,14 @@ fn spawn_desktop_backend_process(context: &AppContext) -> Result<Child, String>
|
||||
));
|
||||
};
|
||||
|
||||
// The debug shell launches a compiled Bun sidecar, so it does not inherit
|
||||
// Bun's `--conditions=development` signal. Mark it explicitly; otherwise
|
||||
// the sidecar discovers the production hub and can execute sessions with
|
||||
// an installed SDK instead of the sources in this checkout.
|
||||
if cfg!(debug_assertions) && std::env::var_os("CLINE_BUILD_ENV").is_none() {
|
||||
command.env("CLINE_BUILD_ENV", "development");
|
||||
}
|
||||
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -806,6 +861,284 @@ fn show_main_window(app: &tauri::AppHandle) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
fn avatars_root() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||||
Ok(PathBuf::from(home).join(".cline").join("avatars"))
|
||||
}
|
||||
|
||||
fn avatar_manifest_path(avatar_dir: &Path) -> Option<PathBuf> {
|
||||
["avatar.json", "pet.json"]
|
||||
.into_iter()
|
||||
.map(|file_name| avatar_dir.join(file_name))
|
||||
.find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn read_custom_avatars() -> Vec<(AvatarManifest, PathBuf)> {
|
||||
let Ok(root) = avatars_root() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut avatars = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(manifest_path) = avatar_manifest_path(&entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(body) = fs::read(manifest_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(manifest) = serde_json::from_slice::<AvatarManifest>(&body) else {
|
||||
continue;
|
||||
};
|
||||
if manifest.sprite_version_number != 2
|
||||
|| manifest.id.trim().is_empty()
|
||||
|| manifest.display_name.trim().is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(avatar_dir) = entry.path().canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(sprite_path) = avatar_dir.join(&manifest.spritesheet_path).canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
if sprite_path.is_file() && sprite_path.starts_with(&avatar_dir) {
|
||||
avatars.push((manifest, sprite_path));
|
||||
}
|
||||
}
|
||||
avatars.sort_by(|(left, _), (right, _)| left.display_name.cmp(&right.display_name));
|
||||
avatars
|
||||
}
|
||||
|
||||
fn avatar_preference() -> SelectedAvatarPreference {
|
||||
let Ok(root) = avatars_root() else {
|
||||
return SelectedAvatarPreference {
|
||||
id: DEFAULT_AVATAR_ID.to_string(),
|
||||
enabled: true,
|
||||
};
|
||||
};
|
||||
fs::read(root.join("selected.json"))
|
||||
.ok()
|
||||
.and_then(|body| serde_json::from_slice::<SelectedAvatarPreference>(&body).ok())
|
||||
.unwrap_or_else(|| SelectedAvatarPreference {
|
||||
id: DEFAULT_AVATAR_ID.to_string(),
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_avatar_preference(preference: &SelectedAvatarPreference) -> Result<(), String> {
|
||||
let root = avatars_root()?;
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("failed creating avatar directory: {error}"))?;
|
||||
let mut body = serde_json::to_vec_pretty(preference)
|
||||
.map_err(|error| format!("failed encoding avatar preference: {error}"))?;
|
||||
body.push(b'\n');
|
||||
fs::write(root.join("selected.json"), body)
|
||||
.map_err(|error| format!("failed saving avatar preference: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_avatars() -> Vec<AvatarOption> {
|
||||
let mut avatars = vec![
|
||||
AvatarOption {
|
||||
id: DEFAULT_AVATAR_ID.to_string(),
|
||||
display_name: "Cline Bot".to_string(),
|
||||
description: "A cheerful compact Cline avatar with expressive black oval eyes."
|
||||
.to_string(),
|
||||
},
|
||||
AvatarOption {
|
||||
id: "mom".to_string(),
|
||||
display_name: "Mom".to_string(),
|
||||
description: "A serene celestial oracle desktop companion.".to_string(),
|
||||
},
|
||||
];
|
||||
for (manifest, _) in read_custom_avatars() {
|
||||
let option = AvatarOption {
|
||||
id: manifest.id.clone(),
|
||||
display_name: manifest.display_name,
|
||||
description: manifest.description,
|
||||
};
|
||||
if let Some(existing) = avatars.iter_mut().find(|avatar| avatar.id == option.id) {
|
||||
*existing = option;
|
||||
} else {
|
||||
avatars.push(option);
|
||||
}
|
||||
}
|
||||
avatars
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_selected_avatar() -> SelectedAvatar {
|
||||
let preference = avatar_preference();
|
||||
let selected_id = preference.id;
|
||||
if let Some((manifest, sprite_path)) = read_custom_avatars()
|
||||
.into_iter()
|
||||
.find(|(manifest, _)| manifest.id == selected_id)
|
||||
{
|
||||
if let Ok(bytes) = fs::read(&sprite_path) {
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mime_type = match sprite_path.extension().and_then(|value| value.to_str()) {
|
||||
Some("png") => "image/png",
|
||||
_ => "image/webp",
|
||||
};
|
||||
return SelectedAvatar {
|
||||
id: manifest.id,
|
||||
display_name: manifest.display_name,
|
||||
description: manifest.description,
|
||||
sprite_url: format!("data:{mime_type};base64,{encoded}"),
|
||||
enabled: preference.enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
match selected_id.as_str() {
|
||||
"mom" => SelectedAvatar {
|
||||
id: "mom".to_string(),
|
||||
display_name: "Mom".to_string(),
|
||||
description: "A serene celestial oracle desktop companion.".to_string(),
|
||||
sprite_url: "/avatars/mom/spritesheet.webp".to_string(),
|
||||
enabled: preference.enabled,
|
||||
},
|
||||
_ => SelectedAvatar {
|
||||
id: DEFAULT_AVATAR_ID.to_string(),
|
||||
display_name: "Cline Bot".to_string(),
|
||||
description: "A cheerful compact Cline avatar with expressive black oval eyes."
|
||||
.to_string(),
|
||||
sprite_url: "/avatars/cline-bot/spritesheet.webp".to_string(),
|
||||
enabled: preference.enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_selected_avatar(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||
if !list_avatars().iter().any(|avatar| avatar.id == id) {
|
||||
return Err(format!("unknown avatar: {id}"));
|
||||
}
|
||||
let preference = SelectedAvatarPreference {
|
||||
id,
|
||||
enabled: avatar_preference().enabled,
|
||||
};
|
||||
write_avatar_preference(&preference)?;
|
||||
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_CHANGED_EVENT, ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_avatar_enabled(app: tauri::AppHandle, enabled: bool) -> Result<(), String> {
|
||||
let preference = SelectedAvatarPreference {
|
||||
id: avatar_preference().id,
|
||||
enabled,
|
||||
};
|
||||
write_avatar_preference(&preference)?;
|
||||
if let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) {
|
||||
if preference.enabled {
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("failed showing avatar overlay: {error}"))?;
|
||||
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_SHOWN_EVENT, ());
|
||||
} else {
|
||||
window
|
||||
.hide()
|
||||
.map_err(|error| format!("failed hiding avatar overlay: {error}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn handle_avatar_overlay_action(app: tauri::AppHandle, action: String) -> Result<(), String> {
|
||||
match action.as_str() {
|
||||
"open-cline" => show_main_window(&app),
|
||||
"hide-avatar" => {
|
||||
let preference = SelectedAvatarPreference {
|
||||
id: avatar_preference().id,
|
||||
enabled: false,
|
||||
};
|
||||
write_avatar_preference(&preference)?;
|
||||
if let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) {
|
||||
window
|
||||
.hide()
|
||||
.map_err(|error| format!("failed hiding avatar overlay: {error}"))?;
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown avatar overlay action: {action}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_avatar_overlay(app: &tauri::App) -> tauri::Result<()> {
|
||||
let url = if cfg!(debug_assertions) {
|
||||
WebviewUrl::External(
|
||||
"http://localhost:3125/avatar-overlay"
|
||||
.parse()
|
||||
.expect("avatar overlay development URL must be valid"),
|
||||
)
|
||||
} else {
|
||||
WebviewUrl::App("avatar-overlay.html".into())
|
||||
};
|
||||
|
||||
let overlay = WebviewWindowBuilder::new(app, AVATAR_OVERLAY_WINDOW_LABEL, url)
|
||||
.title("Cline Bot")
|
||||
.inner_size(AVATAR_COLLAPSED_WIDTH, AVATAR_COLLAPSED_HEIGHT)
|
||||
.resizable(false)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.visible_on_all_workspaces(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(false)
|
||||
.visible(avatar_preference().enabled)
|
||||
.build()?;
|
||||
|
||||
if let Some(main) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
if let (Ok(main_position), Ok(main_size), Ok(overlay_size)) = (
|
||||
main.outer_position(),
|
||||
main.inner_size(),
|
||||
overlay.outer_size(),
|
||||
) {
|
||||
let scale = main.scale_factor().unwrap_or(1.0);
|
||||
let margin = (16.0 * scale).round() as i32;
|
||||
let x = main_position.x.saturating_add(margin);
|
||||
let y = main_position
|
||||
.y
|
||||
.saturating_add(main_size.height as i32)
|
||||
.saturating_sub(overlay_size.height as i32)
|
||||
.saturating_sub(margin);
|
||||
let _ = overlay.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_avatar_overlay(app: &tauri::AppHandle) {
|
||||
let Some(window) = app.get_webview_window(AVATAR_OVERLAY_WINDOW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
let _ = write_avatar_preference(&SelectedAvatarPreference {
|
||||
id: avatar_preference().id,
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = app.emit_to(AVATAR_OVERLAY_WINDOW_LABEL, AVATAR_SHOWN_EVENT, ());
|
||||
let _ = write_avatar_preference(&SelectedAvatarPreference {
|
||||
id: avatar_preference().id,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_desktop_menu_action(app: &tauri::AppHandle, action: &str) {
|
||||
show_main_window(app);
|
||||
app.state::<DesktopMenuActionState>().enqueue(action);
|
||||
@@ -931,6 +1264,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
.item(&running_sessions)
|
||||
.separator()
|
||||
.text(TRAY_SETTINGS_MENU_ID, "Settings")
|
||||
.text(TRAY_TOGGLE_AVATAR_MENU_ID, "Show/Hide Avatar")
|
||||
.separator()
|
||||
.text(TRAY_QUIT_MENU_ID, "Quit")
|
||||
.build()?;
|
||||
@@ -957,6 +1291,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
TRAY_OPEN_MENU_ID => show_main_window(app),
|
||||
TRAY_NEW_SESSION_MENU_ID => queue_desktop_menu_action(app, "new-session"),
|
||||
TRAY_SETTINGS_MENU_ID => queue_desktop_menu_action(app, "open-settings"),
|
||||
TRAY_TOGGLE_AVATAR_MENU_ID => toggle_avatar_overlay(app),
|
||||
TRAY_QUIT_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
@@ -1018,6 +1353,7 @@ fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_application_menu(app)?;
|
||||
setup_tray_icon(app)?;
|
||||
setup_avatar_overlay(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
if let Err(error) = ensure_desktop_backend_started(&backend_state, &app_context) {
|
||||
@@ -1049,6 +1385,11 @@ fn main() {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
} else if window.label() == AVATAR_OVERLAY_WINDOW_LABEL {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -1059,6 +1400,11 @@ fn main() {
|
||||
restart_to_apply_update,
|
||||
check_for_update_now,
|
||||
set_app_icon,
|
||||
list_avatars,
|
||||
get_selected_avatar,
|
||||
set_selected_avatar,
|
||||
set_avatar_enabled,
|
||||
handle_avatar_overlay_action,
|
||||
drain_desktop_menu_actions,
|
||||
set_tray_status
|
||||
])
|
||||
@@ -1066,10 +1412,7 @@ fn main() {
|
||||
.expect("error while building tauri app")
|
||||
.run(|app_handle, event| match event {
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => show_main_window(app_handle),
|
||||
RunEvent::Reopen { .. } => show_main_window(app_handle),
|
||||
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
||||
app_handle
|
||||
.state::<Arc<DesktopBackendState>>()
|
||||
@@ -1085,6 +1428,46 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn avatar_preference_defaults_to_enabled_and_supports_explicit_state() {
|
||||
let default_preference: SelectedAvatarPreference =
|
||||
serde_json::from_str(r#"{"id":"mom"}"#).expect("preference should decode");
|
||||
assert_eq!(default_preference.id, "mom");
|
||||
assert!(default_preference.enabled);
|
||||
|
||||
let disabled_preference: SelectedAvatarPreference =
|
||||
serde_json::from_str(r#"{"id":"mom","enabled":false}"#)
|
||||
.expect("disabled preference should decode");
|
||||
assert!(!disabled_preference.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_manifest_prefers_avatar_json_and_accepts_pet_json() {
|
||||
let test_dir = std::env::temp_dir().join(format!(
|
||||
"cline-avatar-manifest-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
fs::create_dir_all(&test_dir).expect("test directory should be created");
|
||||
|
||||
fs::write(test_dir.join("pet.json"), b"{}").expect("pet manifest should be written");
|
||||
assert_eq!(
|
||||
avatar_manifest_path(&test_dir)
|
||||
.and_then(|path| path.file_name().map(|name| name.to_owned())),
|
||||
Some(std::ffi::OsString::from("pet.json"))
|
||||
);
|
||||
|
||||
fs::write(test_dir.join("avatar.json"), b"{}")
|
||||
.expect("avatar manifest should be written");
|
||||
assert_eq!(
|
||||
avatar_manifest_path(&test_dir)
|
||||
.and_then(|path| path.file_name().map(|name| name.to_owned())),
|
||||
Some(std::ffi::OsString::from("avatar.json"))
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&test_dir).expect("test directory should be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_menu_actions_are_buffered_in_order_until_drained() {
|
||||
let state = DesktopMenuActionState::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline Code",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14-beta.1",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
@@ -18,6 +18,7 @@
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
@@ -38,7 +39,7 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"externalBin": ["bin/code-sidecar"],
|
||||
"resources": ["icons/dock/*.png"],
|
||||
"resources": ["icons/dock/*.png", "bin/remote-helpers/*"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -9,5 +9,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: [fileURLToPath(new URL("./vitest.setup.ts", import.meta.url))],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Node 25 exposes an empty localStorage object when no backing file is set.
|
||||
// jsdom can inherit that object instead of installing its Storage instance,
|
||||
// so provide the browser contract tests expect when the native methods are
|
||||
// unavailable.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.localStorage?.getItem !== "function"
|
||||
) {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
getItem(key) {
|
||||
return values.get(String(key)) ?? null;
|
||||
},
|
||||
key(index) {
|
||||
return [...values.keys()][index] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
values.delete(String(key));
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(String(key), String(value));
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default function AvatarOverlayLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<>
|
||||
<style>{"html, body { background: transparent !important; }"}</style>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AVATAR_CHANGED_EVENT,
|
||||
AVATAR_SHOWN_EVENT,
|
||||
getSelectedAvatar,
|
||||
performAvatarOverlayAction,
|
||||
type SelectedAvatar,
|
||||
} from "@/lib/avatar";
|
||||
import {
|
||||
AVATAR_ATLAS_COLUMNS,
|
||||
AVATAR_ATLAS_ROWS,
|
||||
AVATAR_CELL_HEIGHT,
|
||||
AVATAR_CELL_WIDTH,
|
||||
AVATAR_DISPLAY_SCALE,
|
||||
AVATAR_IDLE_DURATIONS_MS,
|
||||
AVATAR_JUMP_DURATIONS_MS,
|
||||
AVATAR_WAVE_DURATIONS_MS,
|
||||
avatarFrameBackgroundPosition,
|
||||
} from "@/lib/avatar-sprite";
|
||||
|
||||
type Animation = "idle" | "jumping" | "waving";
|
||||
type ContextMenuPosition = { x: number; y: number };
|
||||
|
||||
const DISPLAY_WIDTH = AVATAR_CELL_WIDTH * AVATAR_DISPLAY_SCALE;
|
||||
const DISPLAY_HEIGHT = AVATAR_CELL_HEIGHT * AVATAR_DISPLAY_SCALE;
|
||||
const CONTEXT_MENU_WIDTH = 132;
|
||||
const CONTEXT_MENU_HEIGHT = 70;
|
||||
|
||||
export default function AvatarOverlayPage() {
|
||||
const [avatar, setAvatar] = useState<SelectedAvatar | null>(null);
|
||||
const [animation, setAnimation] = useState<Animation>("waving");
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuPosition | null>(
|
||||
null,
|
||||
);
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragTimerRef = useRef<number | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const suppressActivationRef = useRef(false);
|
||||
|
||||
const loadAvatar = useCallback(async () => {
|
||||
const selected = await getSelectedAvatar();
|
||||
setAvatar(selected);
|
||||
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
|
||||
getCurrentWindow().setTitle(selected.displayName),
|
||||
);
|
||||
setAnimation("waving");
|
||||
setFrame(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAvatar();
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void import("@tauri-apps/api/event").then(async ({ listen }) => {
|
||||
const stopChanged = await listen(AVATAR_CHANGED_EVENT, () => {
|
||||
if (!disposed) void loadAvatar();
|
||||
});
|
||||
const stopShown = await listen(AVATAR_SHOWN_EVENT, () => {
|
||||
if (!disposed) {
|
||||
setAnimation("waving");
|
||||
setFrame(0);
|
||||
}
|
||||
});
|
||||
const stop = () => {
|
||||
stopChanged();
|
||||
stopShown();
|
||||
};
|
||||
if (disposed) stop();
|
||||
else unlisten = stop;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [loadAvatar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const dismissOutside = (event: PointerEvent) => {
|
||||
if (contextMenuRef.current?.contains(event.target as Node)) return;
|
||||
suppressActivationRef.current = true;
|
||||
setContextMenu(null);
|
||||
};
|
||||
const dismissOnBlur = () => setContextMenu(null);
|
||||
window.addEventListener("pointerdown", dismissOutside, true);
|
||||
window.addEventListener("blur", dismissOnBlur);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", dismissOutside, true);
|
||||
window.removeEventListener("blur", dismissOnBlur);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const durations =
|
||||
animation === "jumping"
|
||||
? AVATAR_JUMP_DURATIONS_MS
|
||||
: animation === "waving"
|
||||
? AVATAR_WAVE_DURATIONS_MS
|
||||
: AVATAR_IDLE_DURATIONS_MS;
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (animation !== "idle" && frame === durations.length - 1) {
|
||||
setAnimation("idle");
|
||||
setFrame(0);
|
||||
return;
|
||||
}
|
||||
setFrame((current) => (current + 1) % durations.length);
|
||||
}, durations[frame]);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [animation, frame]);
|
||||
|
||||
const clearDragTimer = () => {
|
||||
if (dragTimerRef.current !== null) {
|
||||
window.clearTimeout(dragTimerRef.current);
|
||||
dragTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
if (event.button !== 0 || contextMenu) return;
|
||||
draggingRef.current = false;
|
||||
clearDragTimer();
|
||||
dragTimerRef.current = window.setTimeout(() => {
|
||||
draggingRef.current = true;
|
||||
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
|
||||
getCurrentWindow().startDragging(),
|
||||
);
|
||||
}, 180);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const wasDragging = draggingRef.current;
|
||||
clearDragTimer();
|
||||
draggingRef.current = false;
|
||||
if (suppressActivationRef.current) {
|
||||
suppressActivationRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!wasDragging && !contextMenu) {
|
||||
void performAvatarOverlayAction("open-cline");
|
||||
}
|
||||
};
|
||||
|
||||
const closeAvatar = async () => {
|
||||
setContextMenu(null);
|
||||
await performAvatarOverlayAction("hide-avatar");
|
||||
};
|
||||
|
||||
const animationRow = animation === "waving" ? 3 : animation === "jumping" ? 4 : 0;
|
||||
|
||||
return (
|
||||
<main
|
||||
aria-label={`${avatar?.displayName ?? "Avatar"} desktop avatar`}
|
||||
className="relative h-screen w-screen select-none overflow-hidden bg-transparent"
|
||||
>
|
||||
<div
|
||||
aria-label="Open Cline"
|
||||
className="absolute bottom-0 right-0 cursor-pointer"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
clearDragTimer();
|
||||
setContextMenu({
|
||||
x: Math.min(event.clientX, window.innerWidth - CONTEXT_MENU_WIDTH),
|
||||
y: Math.min(
|
||||
event.clientY,
|
||||
window.innerHeight - CONTEXT_MENU_HEIGHT,
|
||||
),
|
||||
});
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={() => {
|
||||
if (animation === "idle") {
|
||||
setAnimation("jumping");
|
||||
setFrame(0);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={clearDragTimer}
|
||||
onPointerUp={handlePointerUp}
|
||||
role="button"
|
||||
style={{ height: DISPLAY_HEIGHT, width: DISPLAY_WIDTH }}
|
||||
tabIndex={0}
|
||||
>
|
||||
{avatar ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `url(${JSON.stringify(avatar.spriteUrl)})`,
|
||||
backgroundPosition: avatarFrameBackgroundPosition(
|
||||
animationRow,
|
||||
frame,
|
||||
AVATAR_DISPLAY_SCALE,
|
||||
),
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${AVATAR_ATLAS_COLUMNS * AVATAR_CELL_WIDTH * AVATAR_DISPLAY_SCALE}px ${AVATAR_ATLAS_ROWS * AVATAR_CELL_HEIGHT * AVATAR_DISPLAY_SCALE}px`,
|
||||
height: DISPLAY_HEIGHT,
|
||||
imageRendering: "pixelated",
|
||||
width: DISPLAY_WIDTH,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{contextMenu ? (
|
||||
<div
|
||||
className="absolute z-30 w-[132px] overflow-hidden rounded-md border border-white/15 bg-neutral-950/95 p-1 text-[11px] text-white shadow-xl backdrop-blur"
|
||||
ref={contextMenuRef}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
void performAvatarOverlayAction("open-cline");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Open Cline
|
||||
</button>
|
||||
<button
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-red-300 hover:bg-white/10"
|
||||
onClick={() => void closeAvatar()}
|
||||
type="button"
|
||||
>
|
||||
Close avatar
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -134,6 +134,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cline-thinking-slider-shimmer {
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
transparent 20%,
|
||||
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 42%,
|
||||
color-mix(in oklab, var(--primary-foreground) 70%, transparent) 50%,
|
||||
color-mix(in oklab, var(--primary-foreground) 45%, transparent) 58%,
|
||||
transparent 80%
|
||||
);
|
||||
background-position: 180% 0;
|
||||
background-size: 220% 100%;
|
||||
animation: cline-thinking-slider-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cline-thinking-slider-shimmer {
|
||||
to {
|
||||
background-position: -180% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Softens the welcome <-> conversation swap: the hero and the message grid
|
||||
* replace each other in a single commit, which otherwise reads as a hard
|
||||
* white flash. Plays whenever the element (re)becomes visible — display:none
|
||||
@@ -160,6 +180,10 @@
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.cline-thinking-slider-shimmer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cline-view-enter {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,44 @@ describe("AgentHeader title bar", () => {
|
||||
});
|
||||
|
||||
describe("AgentHeader title editor", () => {
|
||||
it("shows the workspace selector below the session title", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentHeader
|
||||
onNewThread={vi.fn()}
|
||||
status="idle"
|
||||
title="Session"
|
||||
workspace={{
|
||||
currentBranch: "main",
|
||||
onListGitBranches: vi.fn(async () => ({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
})),
|
||||
onRefreshWorkspaces: vi.fn(async () => undefined),
|
||||
onSwitchGitBranch: vi.fn(async () => true),
|
||||
onSwitchWorkspace: vi.fn(async () => true),
|
||||
workspaces: ["/workspace/cline"],
|
||||
workspaceRoot: "/workspace/cline",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const workspace = container.querySelector("#git-branch-btn");
|
||||
const newSession = container.querySelector('[aria-label="New session"]');
|
||||
const title = container.querySelector('[title="Session"]');
|
||||
expect(workspace).not.toBeNull();
|
||||
expect((workspace as HTMLButtonElement | null)?.disabled).toBe(true);
|
||||
expect(workspace?.className).toContain("text-xs");
|
||||
expect(workspace?.className).toContain("text-muted-foreground");
|
||||
expect(workspace?.parentElement?.parentElement?.parentElement).toBe(
|
||||
title?.parentElement?.parentElement,
|
||||
);
|
||||
expect(workspace?.parentElement?.parentElement?.parentElement).not.toBe(
|
||||
newSession?.parentElement,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the displayed title width when editing starts", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -66,7 +104,7 @@ describe("AgentHeader title editor", () => {
|
||||
"max-md:pl-28",
|
||||
);
|
||||
expect(container.querySelector("header")?.className).toContain(
|
||||
"max-md:h-7",
|
||||
"max-md:h-12",
|
||||
);
|
||||
expect(container.querySelector("header")?.className).toContain(
|
||||
"md:group-data-[state=collapsed]/sidebar-wrapper:pl-7",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { normalizeTitle } from "./utils";
|
||||
import { WorkspaceSelector } from "./views/chat/workspace-selector";
|
||||
|
||||
type AgentHeaderProps = {
|
||||
title?: string;
|
||||
@@ -60,6 +61,16 @@ type AgentHeaderProps = {
|
||||
/** Set when the open session is itself a child agent run. */
|
||||
parentSession?: { sessionId: string; title?: string };
|
||||
onOpenParentSession?: (parentSessionId: string) => void | Promise<void>;
|
||||
workspace?: {
|
||||
currentBranch: string | null;
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
|
||||
};
|
||||
};
|
||||
|
||||
function AgentHeaderImpl({
|
||||
@@ -83,6 +94,7 @@ function AgentHeaderImpl({
|
||||
onOpenAgentSession,
|
||||
parentSession,
|
||||
onOpenParentSession,
|
||||
workspace,
|
||||
}: AgentHeaderProps) {
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [titleInput, setTitleInput] = useState("");
|
||||
@@ -124,7 +136,7 @@ function AgentHeaderImpl({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-7 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
|
||||
className="flex h-12 items-center justify-between gap-2 px-4 max-md:h-12 max-md:pl-28 md:group-data-[state=collapsed]/sidebar-wrapper:pl-7"
|
||||
data-tauri-drag-region="deep"
|
||||
>
|
||||
{/* Left: thread title */}
|
||||
@@ -140,88 +152,111 @@ function AgentHeaderImpl({
|
||||
}
|
||||
tone={statusTone}
|
||||
/>
|
||||
{!canEditTitle ? (
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium text-foreground"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</span>
|
||||
) : isEditingTitle ? (
|
||||
<form
|
||||
className="m-0 min-w-0 max-w-full shrink-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitTitle();
|
||||
}}
|
||||
style={{ width: titleEditorWidth }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 w-full text-sm"
|
||||
disabled={renamingTitle}
|
||||
onBlur={() => {
|
||||
void submitTitle();
|
||||
}}
|
||||
onChange={(event) => setTitleInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{!canEditTitle ? (
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium text-foreground"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</span>
|
||||
) : isEditingTitle ? (
|
||||
<form
|
||||
className="m-0 min-w-0 max-w-full shrink-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitTitle();
|
||||
}}
|
||||
style={{ width: titleEditorWidth }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 w-full text-sm"
|
||||
disabled={renamingTitle}
|
||||
onBlur={() => {
|
||||
void submitTitle();
|
||||
}}
|
||||
onChange={(event) => setTitleInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setTitleInput(threadTitle);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
value={titleInput}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
canEditTitle &&
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-accent",
|
||||
)}
|
||||
disabled={renamingTitle}
|
||||
onClick={(event) => {
|
||||
if (!canEditTitle || renamingTitle) {
|
||||
return;
|
||||
}
|
||||
setTitleEditorWidth(
|
||||
event.currentTarget.getBoundingClientRect().width,
|
||||
);
|
||||
setTitleInput(threadTitle);
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
value={titleInput}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
canEditTitle &&
|
||||
"rounded px-1 py-0.5 transition-colors hover:bg-surface-hover",
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
type="button"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</button>
|
||||
)}
|
||||
disabled={renamingTitle}
|
||||
onClick={(event) => {
|
||||
if (!canEditTitle || renamingTitle) {
|
||||
return;
|
||||
}
|
||||
setTitleEditorWidth(
|
||||
event.currentTarget.getBoundingClientRect().width,
|
||||
);
|
||||
setTitleInput(threadTitle);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
type="button"
|
||||
title={threadTitle}
|
||||
>
|
||||
{threadTitle}
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Session actions"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
id="show-more-btn"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={!canDeleteSession || deletingSession}
|
||||
onClick={triggerDeleteSession}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>{deletingSession ? "Deleting..." : "Delete session"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{showSessionActions ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Session actions"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
id="show-more-btn"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={!canDeleteSession || deletingSession}
|
||||
onClick={triggerDeleteSession}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span>
|
||||
{deletingSession ? "Deleting..." : "Delete session"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
{workspace ? (
|
||||
<WorkspaceSelector
|
||||
className="text-xs text-muted-foreground disabled:opacity-100"
|
||||
currentBranch={workspace.currentBranch}
|
||||
disabled
|
||||
onListGitBranches={workspace.onListGitBranches}
|
||||
onPickWorkspaceDirectory={workspace.onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={workspace.onRefreshWorkspaces}
|
||||
onSwitchGitBranch={workspace.onSwitchGitBranch}
|
||||
onSwitchWorkspace={workspace.onSwitchWorkspace}
|
||||
placement="bottom"
|
||||
workspaces={workspace.workspaces}
|
||||
workspaceRoot={workspace.workspaceRoot}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSessionActions ? (
|
||||
@@ -265,7 +300,7 @@ function AgentHeaderImpl({
|
||||
) : (
|
||||
<Button
|
||||
aria-label="New session"
|
||||
className="flex items-center gap-1 rounded-md text-sm text-muted-foreground hover:bg-surface-hover hover:text-foreground transition-colors"
|
||||
className="hidden items-center gap-1 rounded-md text-sm text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
onClick={() => onNewThread?.()}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -250,6 +250,21 @@ describe("AgentSidebar session organization", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("labels a cloud session by repository", () => {
|
||||
expect(
|
||||
getSessionOverviewItems({
|
||||
...makeThread("cloud", 1),
|
||||
origin: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
workspacePath: "https://github.com/cline/cline",
|
||||
}),
|
||||
).toContainEqual([
|
||||
"Repository",
|
||||
"https://github.com/cline/cline",
|
||||
"https://github.com/cline/cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the full first line of the session title", () => {
|
||||
const firstLine =
|
||||
"This is a complete session title that is intentionally longer than seventy characters for the hover overview";
|
||||
@@ -557,6 +572,9 @@ describe("AgentSidebar session organization", () => {
|
||||
onHome={vi.fn()}
|
||||
onNewThread={onNewThread}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
realtimeVoiceControl={
|
||||
<span data-testid="realtime-voice-control" />
|
||||
}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
@@ -569,8 +587,12 @@ describe("AgentSidebar session organization", () => {
|
||||
|
||||
const logo = container.querySelector('[aria-label="Cline home"]');
|
||||
const newSession = container.querySelector('[aria-label="New Session"]');
|
||||
const realtimeVoice = container.querySelector(
|
||||
'[data-testid="realtime-voice-control"]',
|
||||
);
|
||||
expect(logo).not.toBeNull();
|
||||
expect(newSession).not.toBeNull();
|
||||
expect(realtimeVoice?.parentElement).toBe(newSession?.parentElement);
|
||||
expect(newSession?.textContent).toBe("");
|
||||
await click(newSession as Element);
|
||||
expect(onNewThread).toHaveBeenCalledOnce();
|
||||
@@ -586,6 +608,9 @@ describe("AgentSidebar session organization", () => {
|
||||
onHome={vi.fn()}
|
||||
onNewThread={vi.fn()}
|
||||
onSettingsSectionChange={vi.fn()}
|
||||
realtimeVoiceControl={
|
||||
<span data-testid="realtime-voice-control" />
|
||||
}
|
||||
sessionHistory={makeSessionHistory([], vi.fn())}
|
||||
setView={vi.fn()}
|
||||
settingsSection="General"
|
||||
@@ -598,6 +623,14 @@ describe("AgentSidebar session organization", () => {
|
||||
|
||||
expect(container.querySelector('[aria-label="Cline home"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="New Session"]')).toBeNull();
|
||||
const collapsedLogo = container.querySelector('[aria-label="Cline home"]');
|
||||
const collapsedRealtimeVoice = container.querySelector(
|
||||
'[data-testid="realtime-voice-control"]',
|
||||
);
|
||||
expect(
|
||||
collapsedLogo?.compareDocumentPosition(collapsedRealtimeVoice as Node) ??
|
||||
0,
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(
|
||||
container.querySelector('[aria-label="Expand sidebar"]')?.className,
|
||||
).toContain("mt-auto");
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
Clock3,
|
||||
Cloud,
|
||||
Code,
|
||||
FileText,
|
||||
Filter,
|
||||
FolderTree,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Network,
|
||||
PanelLeftOpen,
|
||||
Pencil,
|
||||
Plug,
|
||||
@@ -90,6 +92,7 @@ import {
|
||||
isBetaVersion,
|
||||
productNameForVersion,
|
||||
} from "@/lib/app-channel";
|
||||
import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
ALL_SESSION_SOURCES,
|
||||
@@ -140,6 +143,7 @@ const SETTINGS_SECTION_ICONS = {
|
||||
Models: Bot,
|
||||
Channels: Radio,
|
||||
Schedules: Clock3,
|
||||
Remote: Network,
|
||||
Account: CircleUserRound,
|
||||
Plugins: Plug,
|
||||
Skills: Activity,
|
||||
@@ -222,6 +226,7 @@ export function AgentSidebar({
|
||||
view,
|
||||
activeSessionId,
|
||||
sessionHistory,
|
||||
realtimeVoiceControl,
|
||||
}: {
|
||||
canNavigateBack?: boolean;
|
||||
canNavigateForward?: boolean;
|
||||
@@ -235,6 +240,7 @@ export function AgentSidebar({
|
||||
view: AppView;
|
||||
activeSessionId?: string | null;
|
||||
sessionHistory: UseSessionHistoryResult;
|
||||
realtimeVoiceControl?: ReactNode;
|
||||
}) {
|
||||
const { isMobile, setOpen, setOpenMobile, state } = useSidebar();
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
@@ -631,8 +637,8 @@ export function AgentSidebar({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 shrink-0 items-center justify-between px-2",
|
||||
isCollapsed && "px-1.5",
|
||||
"flex min-h-10 shrink-0 items-center justify-between px-3",
|
||||
isCollapsed && "flex-col items-center justify-start gap-1 px-1.5",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
@@ -704,18 +710,26 @@ export function AgentSidebar({
|
||||
) : null}
|
||||
{!isCollapsed ? <AppUpdateIndicator /> : null}
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-0.5",
|
||||
isCollapsed && "flex-col",
|
||||
)}
|
||||
>
|
||||
{realtimeVoiceControl}
|
||||
{!isCollapsed ? (
|
||||
<Button
|
||||
aria-label="New Session"
|
||||
className="size-8 shrink-0 justify-center px-0"
|
||||
onClick={openNewThread}
|
||||
title="New Session"
|
||||
type="button"
|
||||
variant="sidebarItem"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCollapsed ? (
|
||||
@@ -986,9 +1000,9 @@ export function AgentSidebar({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes "
|
||||
{normalizeTitle(deleteConfirmThread?.title ?? "this session")}"
|
||||
from local history.
|
||||
{deleteConfirmThread?.origin === "cloud"
|
||||
? `This deletes "${normalizeTitle(deleteConfirmThread?.title ?? "this session")}" and its cloud workspace.`
|
||||
: `This removes "${normalizeTitle(deleteConfirmThread?.title ?? "this session")}" from local history.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -1092,11 +1106,13 @@ function ThreadItem({
|
||||
const pending = pendingAction !== null;
|
||||
const statusDotClass = pending
|
||||
? "bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
? "bg-green-500"
|
||||
: unread
|
||||
? "bg-blue-500"
|
||||
: "";
|
||||
: thread.status === "provisioning"
|
||||
? "animate-pulse bg-yellow-400"
|
||||
: thread.status === "running"
|
||||
? "bg-green-500"
|
||||
: unread
|
||||
? "bg-blue-500"
|
||||
: "";
|
||||
const infoItems = getSessionOverviewItems(thread);
|
||||
|
||||
if (editing) {
|
||||
@@ -1139,8 +1155,14 @@ function ThreadItem({
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="block max-w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-normal leading-tight">
|
||||
{title}
|
||||
<span className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden text-sm font-normal leading-tight">
|
||||
{thread.origin === "cloud" ? (
|
||||
<Cloud
|
||||
aria-label="Cloud session"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{thread.pinned ? (
|
||||
@@ -1187,6 +1209,12 @@ function ThreadItem({
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<SessionContextMenuContent
|
||||
allowFavorite={thread.origin !== "cloud"}
|
||||
allowFork={thread.origin !== "cloud"}
|
||||
// Provisioning placeholders have no server session to rename or
|
||||
// delete yet (the sidecar rejects both until the create settles).
|
||||
allowRename={!isCloudProvisioningSessionId(thread.id)}
|
||||
allowDelete={!isCloudProvisioningSessionId(thread.id)}
|
||||
favorited={Boolean(thread.pinned)}
|
||||
onDelete={onDelete}
|
||||
onFork={onFork}
|
||||
@@ -1210,8 +1238,10 @@ export function getSessionOverviewItems(
|
||||
const workspacePath = thread.workspacePath || thread.codebase;
|
||||
const items: Array<[string, string | null | undefined, string?]> = [
|
||||
[
|
||||
"Workspace",
|
||||
workspaceDisplayName(workspacePath),
|
||||
thread.origin === "cloud" ? "Repository" : "Workspace",
|
||||
thread.origin === "cloud"
|
||||
? thread.repoUrl
|
||||
: workspaceDisplayName(workspacePath),
|
||||
workspacePath || undefined,
|
||||
],
|
||||
["Branch", thread.gitBranch],
|
||||
@@ -1279,6 +1309,10 @@ function EditableSessionTitle({
|
||||
}
|
||||
|
||||
function SessionContextMenuContent({
|
||||
allowFavorite,
|
||||
allowFork,
|
||||
allowRename,
|
||||
allowDelete = true,
|
||||
favorited,
|
||||
onRename,
|
||||
onToggleFavorite,
|
||||
@@ -1286,6 +1320,10 @@ function SessionContextMenuContent({
|
||||
onDelete,
|
||||
pendingAction,
|
||||
}: {
|
||||
allowFavorite: boolean;
|
||||
allowFork: boolean;
|
||||
allowRename: boolean;
|
||||
allowDelete?: boolean;
|
||||
favorited: boolean;
|
||||
onRename: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
@@ -1296,28 +1334,34 @@ function SessionContextMenuContent({
|
||||
const pending = pendingAction !== null;
|
||||
return (
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
{favorited ? "Unfavorite" : "Favorite"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={pending} onSelect={onRename}>
|
||||
{pendingAction === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="size-4" />
|
||||
)}
|
||||
{pendingAction === "rename" ? "Renaming..." : "Rename"}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={pending} onSelect={onFork}>
|
||||
{pendingAction === "fork" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GitFork className="size-4" />
|
||||
)}
|
||||
{pendingAction === "fork" ? "Forking..." : "Fork"}
|
||||
</ContextMenuItem>
|
||||
{allowFavorite ? (
|
||||
<ContextMenuItem disabled={pending} onSelect={onToggleFavorite}>
|
||||
<Star className={cn("size-4", favorited && "fill-current")} />
|
||||
{favorited ? "Unfavorite" : "Favorite"}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{allowRename ? (
|
||||
<ContextMenuItem disabled={pending} onSelect={onRename}>
|
||||
{pendingAction === "rename" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="size-4" />
|
||||
)}
|
||||
{pendingAction === "rename" ? "Renaming..." : "Rename"}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{allowFork ? (
|
||||
<ContextMenuItem disabled={pending} onSelect={onFork}>
|
||||
{pendingAction === "fork" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GitFork className="size-4" />
|
||||
)}
|
||||
{pendingAction === "fork" ? "Forking..." : "Fork"}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem
|
||||
disabled={pending}
|
||||
disabled={pending || !allowDelete}
|
||||
onSelect={onDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AnimatedOrb({
|
||||
className,
|
||||
intensity = 0,
|
||||
}: {
|
||||
className?: string;
|
||||
intensity?: number;
|
||||
}) {
|
||||
const normalizedIntensity = Math.min(1, Math.max(0, intensity));
|
||||
const animationStyle = {
|
||||
"--orb-fast-duration": `${Math.max(1.8, 6 - normalizedIntensity * 4.2)}s`,
|
||||
"--orb-slow-duration": `${Math.max(1.2, 3.5 - normalizedIntensity * 2.3)}s`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative block size-5 shrink-0 overflow-hidden rounded-full bg-[#ebf4ff]",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...animationStyle,
|
||||
filter: `saturate(${1 + normalizedIntensity * 0.5}) drop-shadow(0 0 ${normalizedIntensity * 8}px rgba(118, 11, 226, 0.55))`,
|
||||
transform: `scale(${1 + normalizedIntensity * 0.22})`,
|
||||
transition: "transform 75ms ease-out, filter 100ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span className="absolute inset-0 animate-[spin_var(--orb-fast-duration)_linear_infinite] blur-[2.5px]">
|
||||
<span className="absolute left-[8%] top-[12%] size-[55%] rounded-full bg-[#760be2]" />
|
||||
<span className="absolute right-[3%] top-[18%] size-[45%] rounded-full bg-[#f5adff]" />
|
||||
<span className="absolute bottom-[2%] left-[18%] size-[58%] rounded-full bg-[#ebf4ff]" />
|
||||
<span className="absolute bottom-[18%] right-[4%] size-[38%] rounded-full bg-[#760be2]" />
|
||||
</span>
|
||||
<span className="absolute inset-[12%] animate-[spin_var(--orb-slow-duration)_linear_infinite_reverse] rounded-full bg-[linear-gradient(176deg,#f5adff_0%,#ebf4ff_35%,#760be2_100%)] blur-[2px]" />
|
||||
<span className="pointer-events-none absolute inset-0 rounded-full bg-linear-to-b from-white/55 via-transparent to-[#760be2]/15" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
ChatPromptCompletion,
|
||||
ToolApprovalRequestItem,
|
||||
} from "@/hooks/chat-session/types";
|
||||
import type { ChatSessionStatus } from "@/lib/chat-schema";
|
||||
|
||||
export type RealtimeChatBridge = {
|
||||
threadId: string;
|
||||
sessionId: string | null;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
status: ChatSessionStatus;
|
||||
hasChatHistory: boolean;
|
||||
pendingToolApprovals: ToolApprovalRequestItem[];
|
||||
pendingQuestionCount: number;
|
||||
sendPrompt: (prompt: string) => Promise<ChatPromptCompletion | undefined>;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,13 +21,15 @@ function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
forceMount,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal forceMount={forceMount}>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
forceMount={forceMount}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/model-selection";
|
||||
import {
|
||||
buildUserInstructionSlashCommands,
|
||||
buildWorkspaceFileSearchKey,
|
||||
ChatInputBar,
|
||||
} from "./chat-input-bar";
|
||||
|
||||
@@ -72,6 +73,7 @@ vi.mock("@/components/ai-elements/speech-input", async () => {
|
||||
vi.mock("@/lib/provider-model-catalog", () => ({
|
||||
loadProviderModelCatalog: loadProviderModelCatalogMock,
|
||||
loadProviderModels: loadProviderModelsMock,
|
||||
MODE_SETTINGS_CHANGED_EVENT: "cline:mode-settings-changed",
|
||||
subscribeToProviderModels: subscribeToProviderModelsMock,
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT: "cline:test-voice-input-settings-changed",
|
||||
}));
|
||||
@@ -202,6 +204,24 @@ async function renderVoiceComposer({
|
||||
}
|
||||
|
||||
describe("ChatInputBar", () => {
|
||||
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({
|
||||
@@ -226,6 +246,203 @@ describe("ChatInputBar", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows cloud image and model selection without replacing local defaults", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["anthropic", "cline"],
|
||||
providerModels: {
|
||||
anthropic: ["claude-test"],
|
||||
cline: ["cline-test", "cline-alt"],
|
||||
},
|
||||
providerReasoningModels: { anthropic: [], cline: [] },
|
||||
});
|
||||
const localSelection = {
|
||||
lastProvider: "anthropic",
|
||||
lastModelByProvider: { anthropic: "claude-test" },
|
||||
};
|
||||
window.localStorage.setItem(
|
||||
MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify(localSelection),
|
||||
);
|
||||
const onProviderChange = vi.fn();
|
||||
const onModelChange = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "",
|
||||
workspaces: [],
|
||||
listWorkspaces: vi.fn(async () => []),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
cloudBranch="feature/cloud"
|
||||
executionTarget="cloud"
|
||||
gitBranch="no-git"
|
||||
hasActiveSession
|
||||
mode="act"
|
||||
model="claude-test"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "no-git",
|
||||
branches: [],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={onModelChange}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={onProviderChange}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => false)}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
provider="anthropic"
|
||||
reasoningEffort="low"
|
||||
repoUrl="https://github.com/cline/cline"
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
variant="conversation"
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onProviderChange).toHaveBeenCalledWith("cline");
|
||||
});
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Attach images"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('input[type="file"]')?.accept,
|
||||
).toBe("image/*");
|
||||
expect(container.querySelector("#git-branch-btn")).toBeNull();
|
||||
expect(container.textContent).toContain("cline/cline / feature/cloud");
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model and provider"]',
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
const modelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model and provider"]',
|
||||
);
|
||||
await act(async () => modelTrigger?.click());
|
||||
const cloudModel = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model: cline-test"]',
|
||||
);
|
||||
await act(async () => cloudModel?.click());
|
||||
const alternateModel = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
".cline-ui-search-combobox__option",
|
||||
),
|
||||
).find((button) => button.textContent?.includes("cline-alt"));
|
||||
expect(alternateModel).not.toBeUndefined();
|
||||
await act(async () => alternateModel?.click());
|
||||
expect(onModelChange).toHaveBeenCalledWith("cline-alt");
|
||||
expect(
|
||||
JSON.parse(
|
||||
window.localStorage.getItem(MODEL_SELECTION_STORAGE_KEY) ?? "null",
|
||||
),
|
||||
).toEqual(localSelection);
|
||||
});
|
||||
|
||||
it("blocks a new cloud message until a GitHub repository is selected", async () => {
|
||||
const onSend = vi.fn();
|
||||
const render = async (repoUrl?: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
value={{
|
||||
workspaceRoot: "",
|
||||
workspaces: [],
|
||||
listWorkspaces: vi.fn(async () => []),
|
||||
refreshWorkspaces: vi.fn(async () => undefined),
|
||||
switchWorkspace: vi.fn(async () => true),
|
||||
pickWorkspaceDirectory: vi.fn(async () => null),
|
||||
selectChat: vi.fn(async () => true),
|
||||
}}
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
executionTarget="cloud"
|
||||
gitBranch="no-git"
|
||||
hasActiveSession={false}
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "no-git",
|
||||
branches: [],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
onReasoningChange={vi.fn()}
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={onSend}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => false)}
|
||||
promptDraft={{ version: 0, value: "Continue in cloud" }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
reasoningEffort="low"
|
||||
repoUrl={repoUrl}
|
||||
status="idle"
|
||||
summary={{ toolCalls: 0, tokensIn: 0, tokensOut: 0 }}
|
||||
thinking
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const sendButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Send message"]',
|
||||
);
|
||||
const promptInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[role="combobox"]',
|
||||
);
|
||||
expect(sendButton?.disabled).toBe(true);
|
||||
expect(sendButton?.title).toBe("Choose a repository");
|
||||
expect(container.textContent).toContain("Repository required");
|
||||
expect(promptInput?.placeholder).toBe("Choose a repository");
|
||||
await act(async () => {
|
||||
promptInput?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
await render("https://github.com/cline/cline");
|
||||
expect(sendButton?.disabled).toBe(false);
|
||||
await act(async () => sendButton?.click());
|
||||
expect(onSend).toHaveBeenCalledWith("Continue in cloud");
|
||||
});
|
||||
|
||||
it("top-aligns the textarea in the taller welcome composer", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -242,6 +459,7 @@ describe("ChatInputBar", () => {
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
environmentId="local"
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
@@ -756,12 +974,12 @@ describe("ChatInputBar", () => {
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onOpenVoiceInputSettings={onOpenVoiceInputSettings}
|
||||
onPromptInputChange={vi.fn()}
|
||||
onProviderChange={vi.fn()}
|
||||
@@ -769,7 +987,7 @@ describe("ChatInputBar", () => {
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => false)}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
@@ -786,8 +1004,25 @@ describe("ChatInputBar", () => {
|
||||
};
|
||||
|
||||
await render("idle");
|
||||
const compactModelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
expect(compactModelTrigger).not.toBeNull();
|
||||
expect(
|
||||
compactModelTrigger?.querySelector(".lucide-chevron-down"),
|
||||
).toBeNull();
|
||||
const levelIcon = compactModelTrigger?.querySelector(
|
||||
"[aria-hidden='true']",
|
||||
);
|
||||
expect(levelIcon?.className).toContain("-scale-x-100");
|
||||
expect(levelIcon?.querySelector(".lucide-signal")?.classList).toContain(
|
||||
"text-primary",
|
||||
);
|
||||
expect(compactModelTrigger?.lastElementChild).toBe(levelIcon);
|
||||
await act(async () => compactModelTrigger?.click());
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
await vi.waitFor(() => {
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("cline");
|
||||
expect(subscribeToProviderModelsMock).toHaveBeenCalled();
|
||||
});
|
||||
const providerModelsListener =
|
||||
subscribeToProviderModelsMock.mock.calls[0]?.[0];
|
||||
@@ -797,47 +1032,108 @@ describe("ChatInputBar", () => {
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
const trigger = document.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Effort"]',
|
||||
);
|
||||
expect(trigger?.textContent).toContain("High");
|
||||
expect(trigger?.value).toBe("3");
|
||||
expect(trigger?.getAttribute("aria-valuetext")).toBe("High");
|
||||
expect(trigger?.disabled).toBe(true);
|
||||
expect(trigger?.className).toContain("h-4");
|
||||
expect(document.body.textContent).toContain("EffortHigh");
|
||||
expect(document.body.textContent).not.toContain("ThinkingHigh");
|
||||
expect(
|
||||
trigger?.querySelector('[data-slot="select-value"]')?.parentElement
|
||||
document.querySelector('[data-slot="thinking-level-markers"]')
|
||||
?.children,
|
||||
).toHaveLength(5);
|
||||
const markers = document.querySelector(
|
||||
'[data-slot="thinking-level-markers"]',
|
||||
)?.children;
|
||||
expect(markers?.[0]?.className).toContain("bg-zinc-500");
|
||||
expect(markers?.[4]?.className).toContain("bg-zinc-500");
|
||||
expect(markers?.[3]?.className).toContain("opacity-50");
|
||||
expect(markers?.[3]?.className).not.toContain("opacity-0");
|
||||
expect(markers?.[0]?.className).not.toContain("bg-primary-foreground");
|
||||
expect(
|
||||
document.querySelector('[data-slot="thinking-level-markers"]')
|
||||
?.className,
|
||||
).toContain("max-[560px]:sr-only");
|
||||
).toContain("top-0");
|
||||
expect(
|
||||
[...document.querySelectorAll("span")]
|
||||
.find((element) => element.textContent?.includes("High"))
|
||||
?.querySelector(".lucide-signal-high"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
const compactModelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model and provider"]',
|
||||
);
|
||||
expect(compactModelTrigger?.disabled).toBe(false);
|
||||
await act(async () => compactModelTrigger?.click());
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
document.querySelectorAll<HTMLButtonElement>('[aria-label^="Provider:"]'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
|
||||
).toHaveLength(2);
|
||||
expect(container.textContent).toContain("refreshed-model");
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Close model selector"]')
|
||||
?.click(),
|
||||
document.querySelectorAll<HTMLButtonElement>('[aria-label^="Model:"]'),
|
||||
).toHaveLength(1);
|
||||
expect(document.body.textContent).toContain("refreshed-model");
|
||||
const modelMenuTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
expect(modelMenuTrigger?.className).toContain("w-full");
|
||||
expect(modelMenuTrigger?.className).toContain("max-w-none");
|
||||
const providerMenuTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
await act(async () => {
|
||||
providerMenuTrigger?.click();
|
||||
});
|
||||
expect(modelMenuTrigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[aria-label^="Provider:"]')
|
||||
?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
document.querySelector<HTMLElement>('[aria-label="Search provider"]')
|
||||
?.className,
|
||||
).toContain("right-full");
|
||||
await act(async () => {
|
||||
providerMenuTrigger?.click();
|
||||
modelMenuTrigger?.click();
|
||||
});
|
||||
const modelMenu = document.querySelector<HTMLElement>(
|
||||
'[aria-label="Search model"]',
|
||||
);
|
||||
expect(modelMenu?.className).toContain("right-full");
|
||||
expect(modelMenu?.className).toContain("top-0");
|
||||
expect(modelMenu?.className).not.toContain("bottom-0");
|
||||
await act(async () => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }),
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("false"),
|
||||
);
|
||||
expect(compactModelTrigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
const promptInput = container.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[role="combobox"]',
|
||||
);
|
||||
await act(async () => {
|
||||
promptInput?.blur();
|
||||
promptInput?.dispatchEvent(new Event("focusin", { bubbles: true }));
|
||||
});
|
||||
expect(promptInput?.rows).toBe(2);
|
||||
expect(promptInput?.className).toContain("field-sizing-content");
|
||||
expect(promptInput?.className).toContain("overflow-y-auto");
|
||||
expect(promptInput?.className).not.toContain("self-start");
|
||||
expect(promptInput?.style.minHeight).toBe("2.5rem");
|
||||
expect(promptInput?.style.maxHeight).toBe("6.25rem");
|
||||
const emptySpeechTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Record speech"]',
|
||||
);
|
||||
expect(emptySpeechTrigger).not.toBeNull();
|
||||
expect(promptInput?.parentElement?.contains(emptySpeechTrigger)).toBe(true);
|
||||
expect(onOpenVoiceInputSettings).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
if (!promptInput) return;
|
||||
@@ -849,6 +1145,15 @@ describe("ChatInputBar", () => {
|
||||
promptInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
expect(promptInput?.rows).toBe(2);
|
||||
expect(
|
||||
container.querySelector('[aria-label="Record speech"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Send message"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Configure realtime voice"]'),
|
||||
).toBeNull();
|
||||
|
||||
await render("starting");
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).toBeNull();
|
||||
@@ -856,16 +1161,9 @@ describe("ChatInputBar", () => {
|
||||
expect(container.querySelector('[aria-label="Stop agent"]')).not.toBeNull();
|
||||
|
||||
expect(onReasoningChange).not.toHaveBeenCalled();
|
||||
const providerTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Provider:"]',
|
||||
);
|
||||
expect(providerTrigger?.parentElement?.parentElement?.className).toContain(
|
||||
"max-[560px]:hidden",
|
||||
);
|
||||
expect(compactModelTrigger?.className).toContain("max-[560px]:inline-flex");
|
||||
expect(compactModelTrigger?.querySelector(".lucide-cpu")).not.toBeNull();
|
||||
const workspaceTrigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
expect(
|
||||
compactModelTrigger?.querySelector(".lucide-signal-high"),
|
||||
).not.toBeNull();
|
||||
const attachTrigger = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Attach files"]',
|
||||
);
|
||||
@@ -876,11 +1174,16 @@ describe("ChatInputBar", () => {
|
||||
'[aria-label="Thinking level"]',
|
||||
);
|
||||
const leftControls = attachTrigger?.parentElement;
|
||||
expect(
|
||||
attachTrigger?.querySelector(".lucide-paperclip")?.classList,
|
||||
).toContain("size-3");
|
||||
expect(leftControls?.className).toContain("max-[560px]:flex-nowrap");
|
||||
expect(leftControls?.contains(compactModelTrigger ?? null)).toBe(true);
|
||||
expect(leftControls?.contains(thinkingTrigger ?? null)).toBe(true);
|
||||
expect(leftControls?.contains(speechTrigger ?? null)).toBe(false);
|
||||
|
||||
const workspaceTrigger =
|
||||
container.querySelector<HTMLButtonElement>("#git-branch-btn");
|
||||
expect(workspaceTrigger?.disabled).toBe(true);
|
||||
expect(workspaceTrigger?.className).toContain("max-[560px]:size-7");
|
||||
expect(workspaceTrigger?.textContent).toContain("cline");
|
||||
@@ -912,7 +1215,7 @@ describe("ChatInputBar", () => {
|
||||
expect(onOpenVoiceInputSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects High from the supported model thinking menu", async () => {
|
||||
it("selects High with the supported model thinking slider", async () => {
|
||||
loadProviderModelCatalogMock.mockResolvedValue({
|
||||
providers: [],
|
||||
enabledProviderIds: ["cline"],
|
||||
@@ -935,16 +1238,11 @@ describe("ChatInputBar", () => {
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
@@ -953,7 +1251,6 @@ describe("ChatInputBar", () => {
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
@@ -966,31 +1263,36 @@ describe("ChatInputBar", () => {
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
});
|
||||
const modelSettings = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
await act(async () => modelSettings?.click());
|
||||
const trigger = await vi.waitFor(() => {
|
||||
const element = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Thinking level"]',
|
||||
const element = document.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Effort"]',
|
||||
);
|
||||
expect(element?.disabled).toBe(false);
|
||||
return element as HTMLButtonElement;
|
||||
return element as HTMLInputElement;
|
||||
});
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const highOption = await vi.waitFor(() => {
|
||||
const element = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((option) => option.textContent?.includes("High"));
|
||||
expect(element).toBeDefined();
|
||||
return element as HTMLElement;
|
||||
expect(onReasoningChange).toHaveBeenLastCalledWith({
|
||||
reasoningEffort: "medium",
|
||||
thinking: true,
|
||||
});
|
||||
onReasoningChange.mockClear();
|
||||
await act(async () => {
|
||||
highOption.dispatchEvent(
|
||||
new MouseEvent("pointerup", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
highOption.click();
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(trigger, "3");
|
||||
trigger.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
trigger.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onReasoningChange).toHaveBeenCalledWith({
|
||||
@@ -1022,16 +1324,11 @@ describe("ChatInputBar", () => {
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={onEditPromptInQueue}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
@@ -1040,7 +1337,6 @@ describe("ChatInputBar", () => {
|
||||
onRemoveAttachment={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={onSteerPromptInQueue}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onRemovePromptInQueue={onRemovePromptInQueue}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[
|
||||
@@ -1301,7 +1597,12 @@ describe("ChatInputBar", () => {
|
||||
expect(loadProviderModelsMock).toHaveBeenCalledWith("openrouter");
|
||||
});
|
||||
// The composer displays the session's model...
|
||||
const modelTrigger = container.querySelector<HTMLButtonElement>(
|
||||
const modelSettings = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
expect(modelSettings?.textContent).toContain("old-session-model");
|
||||
await act(async () => modelSettings?.click());
|
||||
const modelTrigger = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label^="Model:"]',
|
||||
);
|
||||
expect(modelTrigger?.textContent).toContain("old-session-model");
|
||||
@@ -1318,9 +1619,8 @@ describe("ChatInputBar", () => {
|
||||
// An explicit pick in the model dropdown DOES update the remembered
|
||||
// selection.
|
||||
await act(async () => modelTrigger?.click());
|
||||
const panel = document.querySelector('[role="dialog"]');
|
||||
const option = [
|
||||
...(panel?.querySelectorAll<HTMLButtonElement>("button") ?? []),
|
||||
...document.querySelectorAll<HTMLButtonElement>('[role="dialog"] button'),
|
||||
].find((entry) => entry.textContent?.includes("user-picked-model"));
|
||||
expect(option).toBeTruthy();
|
||||
await act(async () => option?.click());
|
||||
@@ -1446,17 +1746,12 @@ describe("ChatInputBar token ring", () => {
|
||||
>
|
||||
<ChatInputBar
|
||||
attachments={[]}
|
||||
gitBranch="main"
|
||||
mode="act"
|
||||
model="test-model"
|
||||
modelContextWindow={modelContextWindow}
|
||||
onAbort={vi.fn()}
|
||||
onAttachFiles={vi.fn()}
|
||||
onEditPromptInQueue={vi.fn()}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onModeToggle={vi.fn()}
|
||||
onModelChange={vi.fn()}
|
||||
onPromptInputChange={vi.fn()}
|
||||
@@ -1466,7 +1761,6 @@ describe("ChatInputBar token ring", () => {
|
||||
onRemovePromptInQueue={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onSteerPromptInQueue={vi.fn()}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
promptDraft={{ version: 0, value: "" }}
|
||||
promptsInQueue={[]}
|
||||
provider="cline"
|
||||
@@ -1517,6 +1811,10 @@ describe("ChatInputBar token ring", () => {
|
||||
);
|
||||
expect(trigger?.textContent).toBe("");
|
||||
const ring = trigger?.querySelector("svg");
|
||||
const inputBar = trigger?.closest(".max-w-full");
|
||||
expect(inputBar?.classList.contains("w-full")).toBe(true);
|
||||
expect(inputBar?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(inputBar?.classList.contains("max-w-full")).toBe(true);
|
||||
expect(ring?.classList.contains("size-3.5")).toBe(true);
|
||||
expect(ring?.getAttribute("height")).toBe("22");
|
||||
expect(ring?.getAttribute("width")).toBe("22");
|
||||
@@ -1526,21 +1824,15 @@ describe("ChatInputBar token ring", () => {
|
||||
expect(
|
||||
Number(progressCircle?.getAttribute("stroke-dashoffset")),
|
||||
).toBeCloseTo(circumference * 0.25);
|
||||
|
||||
if (!trigger?.parentElement) {
|
||||
throw new Error("Expected token usage trigger group");
|
||||
}
|
||||
const usageGroup = trigger.parentElement;
|
||||
const workspaceSelector = usageGroup.querySelector("#git-branch-btn");
|
||||
if (!workspaceSelector) {
|
||||
throw new Error("Expected workspace selector in token usage group");
|
||||
}
|
||||
expect(usageGroup.classList.contains("gap-0")).toBe(true);
|
||||
expect(usageGroup.contains(workspaceSelector)).toBe(true);
|
||||
expect(container.querySelector("#git-branch-btn")).toBeNull();
|
||||
expect(trigger?.parentElement?.classList.contains("gap-0")).toBe(true);
|
||||
const modelSettings = container.querySelector(
|
||||
'[aria-label="Model settings"]',
|
||||
);
|
||||
expect(
|
||||
Boolean(
|
||||
trigger.compareDocumentPosition(workspaceSelector) &
|
||||
Node.DOCUMENT_POSITION_PRECEDING,
|
||||
modelSettings?.compareDocumentPosition(trigger as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,21 @@ import {
|
||||
formatDisplayUserInput,
|
||||
} from "@cline/shared/browser";
|
||||
import { AgentPromptQueue, SearchCombobox } from "@cline/ui";
|
||||
import { ArrowUp, Brain, CircleStop, Cpu, Paperclip, X } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Brain,
|
||||
CircleStop,
|
||||
Cloud,
|
||||
Cpu,
|
||||
Paperclip,
|
||||
Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
SignalZero,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
SpeechInput,
|
||||
@@ -31,6 +45,7 @@ import { formatCostUsd } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatSessionConfig, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
import { imageFilesFromClipboard } from "@/lib/clipboard-images";
|
||||
import { cloudRepositoryLabel } from "@/lib/cloud-repositories";
|
||||
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
|
||||
import {
|
||||
readModelSelectionStorageFromWindow,
|
||||
@@ -40,6 +55,7 @@ import { normalizeProviderId } from "@/lib/provider-id";
|
||||
import {
|
||||
loadProviderModelCatalog,
|
||||
loadProviderModels,
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
subscribeToProviderModels,
|
||||
type TranscriptionModelTarget,
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
|
||||
@@ -132,6 +148,7 @@ const FALLBACK_PROVIDER_REASONING_MODELS: Record<string, string[]> = {
|
||||
openrouter: ["anthropic/claude-sonnet-4.6"],
|
||||
gemini: ["gemini-3-pro-latest"],
|
||||
};
|
||||
const CLINE_ONLY_PROVIDER_IDS = ["cline"];
|
||||
|
||||
type ReasoningEffort = NonNullable<ChatSessionConfig["reasoningEffort"]>;
|
||||
type ReasoningEffortOption = {
|
||||
@@ -151,7 +168,36 @@ const EFFORT_LEVELS: ReasoningEffortOption[] = [
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Extra", value: "xhigh" },
|
||||
];
|
||||
|
||||
function ReasoningEffortIcon({
|
||||
value,
|
||||
className = "size-3",
|
||||
}: {
|
||||
value: ReasoningEffortOption["value"];
|
||||
className?: string;
|
||||
}) {
|
||||
const LevelIcon =
|
||||
value === "none"
|
||||
? SignalZero
|
||||
: value === "low"
|
||||
? SignalLow
|
||||
: value === "medium"
|
||||
? SignalMedium
|
||||
: value === "high"
|
||||
? SignalHigh
|
||||
: Signal;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("relative inline-flex -scale-x-100", className)}
|
||||
>
|
||||
<Signal className="absolute inset-0 size-full text-primary opacity-50" />
|
||||
<LevelIcon className="relative size-full text-primary" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
|
||||
const PROMPT_INPUT_WELCOME_ROWS = 3;
|
||||
const PROMPT_INPUT_EXPANDED_ROWS = 2;
|
||||
const PROMPT_INPUT_MAX_ROWS = 5;
|
||||
const PROMPT_INPUT_LINE_HEIGHT_REM = 1.25;
|
||||
@@ -269,7 +315,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;
|
||||
provider: string;
|
||||
@@ -280,6 +335,10 @@ type ChatInputBarProps = {
|
||||
reasoningEffort: ChatSessionConfig["reasoningEffort"];
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
gitBranch: string | null;
|
||||
executionTarget?: "local" | "cloud";
|
||||
repoUrl?: string;
|
||||
cloudBranch?: string;
|
||||
hasActiveSession?: boolean;
|
||||
promptDraft: PromptDraft;
|
||||
onPromptInputChange: (value: string) => void;
|
||||
onProviderChange: (provider: string) => void;
|
||||
@@ -313,6 +372,7 @@ type ChatInputBarProps = {
|
||||
};
|
||||
|
||||
function ChatInputBarImpl({
|
||||
environmentId,
|
||||
variant = "conversation",
|
||||
status,
|
||||
provider,
|
||||
@@ -322,6 +382,10 @@ function ChatInputBarImpl({
|
||||
thinking,
|
||||
reasoningEffort,
|
||||
gitBranch,
|
||||
executionTarget = "local",
|
||||
repoUrl,
|
||||
cloudBranch,
|
||||
hasActiveSession = false,
|
||||
promptDraft,
|
||||
onPromptInputChange,
|
||||
onProviderChange,
|
||||
@@ -432,13 +496,27 @@ function ChatInputBarImpl({
|
||||
},
|
||||
[model, provider],
|
||||
);
|
||||
const canSend = hasDraft && !speechInputActive;
|
||||
const needsCloudRepository =
|
||||
executionTarget === "cloud" && !hasActiveSession && !repoUrl?.trim();
|
||||
const cloudSettingsLocked = executionTarget === "cloud" && hasActiveSession;
|
||||
const canSend =
|
||||
hasDraft &&
|
||||
!speechInputActive &&
|
||||
!needsCloudRepository &&
|
||||
(executionTarget !== "cloud" || promptInput.trim().length > 0);
|
||||
const cloudContextLabel = useMemo(
|
||||
() =>
|
||||
[cloudRepositoryLabel(repoUrl ?? "", "Cloud"), cloudBranch?.trim()]
|
||||
.filter(Boolean)
|
||||
.join(" / "),
|
||||
[cloudBranch, repoUrl],
|
||||
);
|
||||
const handleSend = useCallback(() => {
|
||||
if (speechInputActive) return;
|
||||
if (!canSend) return;
|
||||
const prompt = promptInput.trim();
|
||||
setPromptInput("");
|
||||
onSend(prompt);
|
||||
}, [onSend, promptInput, setPromptInput, speechInputActive]);
|
||||
}, [canSend, onSend, promptInput, setPromptInput]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [transcriptionTarget, setTranscriptionTarget] =
|
||||
useState<TranscriptionModelTarget | null>(null);
|
||||
@@ -475,7 +553,10 @@ function ChatInputBarImpl({
|
||||
const mentionKey = activeMention
|
||||
? `${activeMention.start}:${activeMention.query}`
|
||||
: null;
|
||||
const mentionOpen = mentionKey !== null && dismissedMentionKey !== mentionKey;
|
||||
const mentionOpen =
|
||||
executionTarget === "local" &&
|
||||
mentionKey !== null &&
|
||||
dismissedMentionKey !== mentionKey;
|
||||
const [mentionFiles, setMentionFiles] = useState<string[]>([]);
|
||||
const [mentionLoading, setMentionLoading] = useState(false);
|
||||
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
||||
@@ -493,22 +574,28 @@ function ChatInputBarImpl({
|
||||
const slashKey = activeSlash
|
||||
? `${activeSlash.slashIndex}:${activeSlash.query}`
|
||||
: null;
|
||||
const slashOpen = slashKey !== null && dismissedSlashKey !== slashKey;
|
||||
// Slash commands resolve against locally installed skills/workflows, which
|
||||
// the cloud sandbox cannot run — gate them like @-mentions.
|
||||
const slashOpen =
|
||||
executionTarget === "local" &&
|
||||
slashKey !== null &&
|
||||
dismissedSlashKey !== slashKey;
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
|
||||
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
|
||||
);
|
||||
const [slashLoading, setSlashLoading] = useState(false);
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadId = 0;
|
||||
const loadVoiceInput = () => {
|
||||
const loadModeSettings = () => {
|
||||
const currentLoadId = ++loadId;
|
||||
loadProviderModelCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled && currentLoadId === loadId) {
|
||||
updateTranscriptionTarget(catalog.voiceInput);
|
||||
updateTranscriptionTarget(
|
||||
catalog.modes?.voiceInput ?? catalog.voiceInput,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -517,14 +604,31 @@ function ChatInputBarImpl({
|
||||
}
|
||||
});
|
||||
};
|
||||
loadVoiceInput();
|
||||
window.addEventListener(VOICE_INPUT_SETTINGS_CHANGED_EVENT, loadVoiceInput);
|
||||
const handleModeSettingsChanged = (event: Event) => {
|
||||
const mode = (event as CustomEvent<{ mode?: string }>).detail?.mode;
|
||||
if (!mode || mode === "voiceInput") {
|
||||
loadModeSettings();
|
||||
}
|
||||
};
|
||||
loadModeSettings();
|
||||
window.addEventListener(
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
handleModeSettingsChanged,
|
||||
);
|
||||
window.addEventListener(
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
|
||||
loadModeSettings,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
loadId += 1;
|
||||
window.removeEventListener(
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
handleModeSettingsChanged,
|
||||
);
|
||||
window.removeEventListener(
|
||||
VOICE_INPUT_SETTINGS_CHANGED_EVENT,
|
||||
loadVoiceInput,
|
||||
loadModeSettings,
|
||||
);
|
||||
};
|
||||
}, [updateTranscriptionTarget]);
|
||||
@@ -757,6 +861,16 @@ function ChatInputBarImpl({
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
[reasoningEffort, thinking],
|
||||
);
|
||||
const [modelSettingsOpen, setModelSettingsOpen] = useState(false);
|
||||
const thinkingSliderRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
executionTarget === "cloud" &&
|
||||
normalizeProviderId(provider) !== "cline"
|
||||
) {
|
||||
onProviderChange("cline");
|
||||
}
|
||||
}, [executionTarget, onProviderChange, provider]);
|
||||
const hasExplicitReasoningSelection =
|
||||
thinking !== undefined || reasoningEffort !== undefined;
|
||||
const effortLabel =
|
||||
@@ -766,9 +880,11 @@ function ChatInputBarImpl({
|
||||
? "None"
|
||||
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
|
||||
const promptInputRows =
|
||||
variant === "welcome" || promptInputFocused
|
||||
? PROMPT_INPUT_EXPANDED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS;
|
||||
variant === "welcome"
|
||||
? PROMPT_INPUT_WELCOME_ROWS
|
||||
: promptInputFocused
|
||||
? PROMPT_INPUT_EXPANDED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS;
|
||||
const handleEffortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (modelSupportsReasoning !== true) {
|
||||
@@ -821,7 +937,11 @@ function ChatInputBarImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${workspaceRoot}::${activeMention.query}`;
|
||||
const requestKey = buildWorkspaceFileSearchKey(
|
||||
environmentId,
|
||||
workspaceRoot,
|
||||
activeMention.query,
|
||||
);
|
||||
if (mentionLastRequestKeyRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
@@ -843,6 +963,7 @@ function ChatInputBarImpl({
|
||||
const results = await desktopClient.invoke<string[]>(
|
||||
"search_workspace_files",
|
||||
{
|
||||
environmentId,
|
||||
workspaceRoot,
|
||||
query: activeMention.query,
|
||||
limit: 10,
|
||||
@@ -873,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) => {
|
||||
@@ -993,7 +1120,7 @@ function ChatInputBarImpl({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card",
|
||||
"w-full min-w-0 max-w-full bg-card",
|
||||
variant === "welcome"
|
||||
? "overflow-visible rounded-xl border border-border/90 bg-surface-1/40 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
|
||||
: "overflow-visible rounded-xl border border-border bg-surface-2 backdrop-blur-sm focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
@@ -1101,9 +1228,9 @@ function ChatInputBarImpl({
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Empty composer space forwards pointer focus to the nested textarea; keyboard users focus the textarea directly. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
"flex gap-2 rounded-lg border border-border bg-background px-3 py-2.5 transition-all focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome"
|
||||
? "min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0"
|
||||
? "min-h-16 items-end rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0"
|
||||
: "min-h-24 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:border-transparent focus-within:ring-0",
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
@@ -1252,11 +1379,17 @@ function ChatInputBarImpl({
|
||||
placeholder={
|
||||
speechInputProcessing
|
||||
? "Transcribing voice input…"
|
||||
: variant === "welcome"
|
||||
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
|
||||
: isBusy
|
||||
: needsCloudRepository
|
||||
? "Choose a repository"
|
||||
: isBusy && variant !== "welcome"
|
||||
? "Agent is working... submit to queue another message"
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
: executionTarget === "cloud"
|
||||
? // Mentions and slash commands are local-only; do not
|
||||
// advertise them in cloud sessions.
|
||||
"Describe what Cline should do in this repository."
|
||||
: variant === "welcome"
|
||||
? "Ask to make changes, @mention files, reference #PRs, or run /commands."
|
||||
: "Enter your question or type / for commands or @ for context"
|
||||
}
|
||||
readOnly={speechInputActive}
|
||||
ref={promptInputRef}
|
||||
@@ -1270,22 +1403,30 @@ function ChatInputBarImpl({
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
"flex shrink-0 items-center gap-1",
|
||||
variant === "conversation" && "self-end",
|
||||
)}
|
||||
>
|
||||
{needsCloudRepository ? (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="max-w-40 text-right text-[11px] leading-4 text-muted-foreground"
|
||||
>
|
||||
Repository required
|
||||
</span>
|
||||
) : null}
|
||||
{canAbort && (
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
className={cn(
|
||||
"bg-foreground p-1.5 text-background hover:bg-destructive",
|
||||
"inline-grid size-7 shrink-0 place-items-center bg-foreground p-0 text-background transition-colors hover:bg-primary/80",
|
||||
variant === "welcome" ? "rounded-md" : "rounded-full",
|
||||
)}
|
||||
onClick={onAbort}
|
||||
title="Stop the agent (Esc)"
|
||||
type="button"
|
||||
>
|
||||
<CircleStop className="size-3" />
|
||||
<CircleStop className="size-2" />
|
||||
</button>
|
||||
)}
|
||||
<SpeechInput
|
||||
@@ -1326,23 +1467,27 @@ function ChatInputBarImpl({
|
||||
: "Configure voice input in Settings → Models"
|
||||
}
|
||||
/>
|
||||
{(!isBusy || canSend) && (
|
||||
{!isBusy || canSend ? (
|
||||
<button
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
"p-1.5 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"inline-grid size-7 shrink-0 place-items-center p-0 transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variant === "welcome"
|
||||
? "rounded-md bg-[linear-gradient(145deg,var(--primary-emphasis),var(--primary))] text-white shadow-sm hover:brightness-110"
|
||||
: "rounded-full bg-primary text-background hover:bg-primary/80",
|
||||
? "rounded-md bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "rounded-full bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
disabled={!canSend}
|
||||
onClick={handleSend}
|
||||
title="Send (Enter)"
|
||||
title={
|
||||
needsCloudRepository
|
||||
? "Choose a repository"
|
||||
: "Send (Enter)"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ArrowUp className="size-3" />
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1372,7 +1517,9 @@ function ChatInputBarImpl({
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 rounded-b-xl border-t border-border bg-muted/20 px-2 py-2 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
|
||||
<button
|
||||
aria-label="Attach files"
|
||||
aria-label={
|
||||
executionTarget === "cloud" ? "Attach images" : "Attach files"
|
||||
}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-surface-hover"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
@@ -1380,7 +1527,7 @@ function ChatInputBarImpl({
|
||||
<Paperclip className="size-3" />
|
||||
</button>
|
||||
<input
|
||||
accept="*/*"
|
||||
accept={executionTarget === "cloud" ? "image/*" : "*/*"}
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
@@ -1391,96 +1538,221 @@ function ChatInputBarImpl({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<div className="hidden shrink-0 items-center rounded-md bg-muted p-0.5">
|
||||
<button
|
||||
aria-pressed={mode === "plan"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 ",
|
||||
mode === "plan"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "plan") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Plan
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={mode === "act"}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 ",
|
||||
mode === "act"
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (mode !== "act") onModeToggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Act
|
||||
</button>
|
||||
{executionTarget === "cloud" ? (
|
||||
<span className="truncate text-[11px]" title={cloudContextLabel}>
|
||||
{cloudContextLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0">
|
||||
{executionTarget === "cloud" ? (
|
||||
<>
|
||||
<ModelSelector
|
||||
allowedProviderIds={CLINE_ONLY_PROVIDER_IDS}
|
||||
autoCorrectModel={!cloudSettingsLocked}
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
persistSelection={false}
|
||||
provider={provider}
|
||||
/>
|
||||
<Select
|
||||
disabled
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 border-0"
|
||||
size="sm"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{effortLabel}
|
||||
<ReasoningEffortIcon
|
||||
className="size-3"
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
/>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Popover
|
||||
onOpenChange={setModelSettingsOpen}
|
||||
open={modelSettingsOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-label="Model settings"
|
||||
className="flex h-7 max-w-md items-center gap-1 rounded-md px-2 text-[11px] transition-colors hover:bg-accent"
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{provider}
|
||||
</span>
|
||||
<span className="truncate text-foreground">{model}</span>
|
||||
<ReasoningEffortIcon
|
||||
className="size-3 shrink-0"
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 space-y-1 p-2"
|
||||
forceMount
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(() =>
|
||||
thinkingSliderRef.current?.focus(),
|
||||
);
|
||||
}}
|
||||
side="top"
|
||||
>
|
||||
<ModelSelector
|
||||
allowedProviderIds={
|
||||
executionTarget === "cloud"
|
||||
? CLINE_ONLY_PROVIDER_IDS
|
||||
: undefined
|
||||
}
|
||||
autoCorrectModel={!cloudSettingsLocked}
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
persistSelection={executionTarget !== "cloud"}
|
||||
provider={provider}
|
||||
variant="menu"
|
||||
/>
|
||||
<div className="rounded-md px-2 py-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Effort
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-foreground">
|
||||
{effortLabel}
|
||||
<ReasoningEffortIcon
|
||||
className="size-3.5"
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative mt-3 h-4">
|
||||
<input
|
||||
aria-label="Effort"
|
||||
aria-valuetext={effortLabel}
|
||||
className="relative z-10 block h-4 w-full cursor-pointer appearance-none rounded-full disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={
|
||||
cloudSettingsLocked ||
|
||||
modelSupportsReasoning !== true
|
||||
}
|
||||
max={EFFORT_LEVELS.length - 1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const option =
|
||||
EFFORT_LEVELS[Number(event.currentTarget.value)];
|
||||
if (option) handleEffortChange(option.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !== "ArrowLeft" &&
|
||||
event.key !== "ArrowRight"
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
const direction =
|
||||
event.key === "ArrowRight" ? 1 : -1;
|
||||
const nextIndex = Math.min(
|
||||
EFFORT_LEVELS.length - 1,
|
||||
Math.max(0, effortIndex + direction),
|
||||
);
|
||||
const option = EFFORT_LEVELS[nextIndex];
|
||||
if (option) handleEffortChange(option.value);
|
||||
}}
|
||||
ref={thinkingSliderRef}
|
||||
step={1}
|
||||
type="range"
|
||||
value={effortIndex}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-2.5 right-2.5 top-0 z-20 flex h-4 items-center justify-between"
|
||||
data-slot="thinking-level-markers"
|
||||
>
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<span
|
||||
className="size-1 rounded-full bg-zinc-500 opacity-50"
|
||||
key={option.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select
|
||||
disabled={modelSupportsReasoning !== true}
|
||||
onValueChange={handleEffortChange}
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="h-7 gap-1.5 border-0 px-2 text-sm shadow-none"
|
||||
size="sm"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{effortLabel}
|
||||
<ReasoningEffortIcon
|
||||
className="size-3"
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
/>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 shrink-0">
|
||||
<ModelSelector
|
||||
isBusy={isBusy}
|
||||
model={model}
|
||||
onModelChange={onModelChange}
|
||||
onModelSupportsReasoningChange={
|
||||
handleModelSupportsReasoningChange
|
||||
}
|
||||
onProviderChange={onProviderChange}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
disabled={modelSupportsReasoning !== true}
|
||||
onValueChange={handleEffortChange}
|
||||
value={EFFORT_LEVELS[effortIndex]?.value ?? "low"}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Thinking level"
|
||||
className="gap-1.5 border-0 px-2 text-sm shadow-none data-[size=sm]:h-7 [&>svg:last-child]:hidden max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 bg-transparent! hover:bg-surface-hover!"
|
||||
size="sm"
|
||||
title={
|
||||
modelSupportsReasoning === false
|
||||
? "The selected model does not report reasoning support"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Brain className="size-3" />
|
||||
<span className="max-[560px]:sr-only">
|
||||
<SelectValue>{effortLabel}</SelectValue>
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{EFFORT_LEVELS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex min-w-0 items-center gap-2 max-[560px]:shrink-0">
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-0">
|
||||
{variant === "conversation" ? (
|
||||
<div className="flex min-w-0 items-center gap-0">
|
||||
<div className="min-w-0 overflow-visible">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
disabled
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
{executionTarget !== "cloud" && gitBranch !== undefined ? (
|
||||
<div className="min-w-0 overflow-visible">
|
||||
<WorkspaceSelector
|
||||
currentBranch={gitBranch}
|
||||
disabled
|
||||
onListGitBranches={onListGitBranches}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaces={workspaces}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<TokenUsageRing
|
||||
usage={{
|
||||
contextWindow: modelContextWindow,
|
||||
@@ -1506,19 +1778,27 @@ export const ChatInputBar = memo(ChatInputBarImpl);
|
||||
// Memoized: the selectors load/hold the full provider-model catalog, so they
|
||||
// should not re-render for every keystroke in the composer textarea.
|
||||
const ModelSelector = memo(function ModelSelector({
|
||||
allowedProviderIds,
|
||||
autoCorrectModel = true,
|
||||
persistSelection = true,
|
||||
provider,
|
||||
model,
|
||||
isBusy,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onModelSupportsReasoningChange,
|
||||
variant = "toolbar",
|
||||
}: {
|
||||
allowedProviderIds?: string[];
|
||||
autoCorrectModel?: boolean;
|
||||
persistSelection?: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
isBusy: boolean;
|
||||
onProviderChange: (provider: string) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onModelSupportsReasoningChange: (supportsReasoning: boolean | null) => void;
|
||||
variant?: "toolbar" | "menu";
|
||||
}) {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const [providerModels, setProviderModels] = useState<
|
||||
@@ -1538,10 +1818,13 @@ const ModelSelector = memo(function ModelSelector({
|
||||
const visibleProviderModels = useMemo(() => {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const providerId of enabledProviderIds) {
|
||||
if (allowedProviderIds && !allowedProviderIds.includes(providerId)) {
|
||||
continue;
|
||||
}
|
||||
next[providerId] = providerModels[providerId] ?? [];
|
||||
}
|
||||
return next;
|
||||
}, [enabledProviderIds, providerModels]);
|
||||
}, [allowedProviderIds, enabledProviderIds, providerModels]);
|
||||
const providers = useMemo(
|
||||
() => Object.keys(visibleProviderModels),
|
||||
[visibleProviderModels],
|
||||
@@ -1679,6 +1962,9 @@ const ModelSelector = memo(function ModelSelector({
|
||||
// happened to use.
|
||||
const rememberSelection = useCallback(
|
||||
(providerId: string, modelId: string | undefined) => {
|
||||
if (!persistSelection) {
|
||||
return;
|
||||
}
|
||||
const normalizedId = normalizeProviderId(providerId);
|
||||
if (!normalizedId) {
|
||||
return;
|
||||
@@ -1701,7 +1987,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
[persistSelection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1716,13 +2002,22 @@ const ModelSelector = memo(function ModelSelector({
|
||||
if (providers.length === 0) {
|
||||
return;
|
||||
}
|
||||
// isBusy also covers a locked cloud composer: silently "correcting" an
|
||||
// attached cloud session's model (e.g. an org-catalog id missing from
|
||||
// the local list) would push a real model change to the remote session
|
||||
// on the next send, contradicting the locked-settings tooltip.
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
if (resolvedProvider && resolvedProvider !== normalizedProvider) {
|
||||
onProviderChange(resolvedProvider);
|
||||
}
|
||||
if (resolvedModel && resolvedModel !== model) {
|
||||
if (autoCorrectModel && resolvedModel && resolvedModel !== model) {
|
||||
onModelChange(resolvedModel);
|
||||
}
|
||||
}, [
|
||||
autoCorrectModel,
|
||||
isBusy,
|
||||
model,
|
||||
onModelChange,
|
||||
onProviderChange,
|
||||
@@ -1788,7 +2083,10 @@ const ModelSelector = memo(function ModelSelector({
|
||||
},
|
||||
[onModelChange, rememberSelection, resolvedProvider],
|
||||
);
|
||||
const renderProviderSelect = (triggerClassName: string) => (
|
||||
const renderProviderSelect = (
|
||||
triggerClassName: string,
|
||||
placement: "top" | "right" | "bottom" | "left" = "top",
|
||||
) => (
|
||||
<SearchCombobox
|
||||
ariaLabel="Provider"
|
||||
className={triggerClassName}
|
||||
@@ -1797,7 +2095,7 @@ const ModelSelector = memo(function ModelSelector({
|
||||
onValueChange={handleProviderSelect}
|
||||
options={providers.map((value) => ({ label: value, value }))}
|
||||
placeholder="Provider"
|
||||
placement="top"
|
||||
placement={placement}
|
||||
searchPlaceholder="Search providers"
|
||||
value={resolvedProvider}
|
||||
/>
|
||||
@@ -1805,8 +2103,11 @@ const ModelSelector = memo(function ModelSelector({
|
||||
const renderModelSelect = (
|
||||
triggerClassName: string,
|
||||
closeMobileMenu = false,
|
||||
placement: "top" | "right" | "bottom" | "left" = "top",
|
||||
align: "start" | "end" = "start",
|
||||
) => (
|
||||
<SearchCombobox
|
||||
align={align}
|
||||
ariaLabel="Model"
|
||||
className={triggerClassName}
|
||||
disabled={isBusy || modelsForProvider.length === 0}
|
||||
@@ -1817,11 +2118,41 @@ const ModelSelector = memo(function ModelSelector({
|
||||
}}
|
||||
options={modelsForProvider.map((value) => ({ label: value, value }))}
|
||||
placeholder="Model"
|
||||
placement="top"
|
||||
placement={placement}
|
||||
searchPlaceholder="Search models"
|
||||
value={resolvedModel}
|
||||
/>
|
||||
);
|
||||
if (variant === "menu") {
|
||||
return (
|
||||
<div className="grid gap-1 text-xs">
|
||||
<div
|
||||
className="rounded-md px-2 py-1.5 transition-colors hover:bg-accent"
|
||||
data-slot="provider-selector-row"
|
||||
>
|
||||
<div className="mb-0.5 text-[10px] text-muted-foreground">
|
||||
Provider
|
||||
</div>
|
||||
{renderProviderSelect(
|
||||
"h-6 w-full max-w-none justify-start border-0 bg-transparent !p-0 text-left text-xs text-foreground shadow-none hover:bg-transparent",
|
||||
"left",
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="rounded-md px-2 py-1.5 transition-colors hover:bg-accent"
|
||||
data-slot="model-selector-row"
|
||||
>
|
||||
<div className="mb-0.5 text-[10px] text-muted-foreground">Model</div>
|
||||
{renderModelSelect(
|
||||
"h-6 w-full max-w-none justify-start border-0 bg-transparent !p-0 text-left text-xs text-foreground shadow-none hover:bg-transparent",
|
||||
false,
|
||||
"left",
|
||||
"start",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 shrink-0 text-sm">
|
||||
@@ -1868,10 +2199,9 @@ const ModelSelector = memo(function ModelSelector({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-0.5 max-[560px]:hidden">
|
||||
{renderProviderSelect("max-w-28")}
|
||||
<div className="bg-border-2 h-4 w-[0.1rem]" />
|
||||
{renderModelSelect("max-w-52")}
|
||||
<div className="flex min-w-0 items-center gap-0 max-[560px]:hidden">
|
||||
{renderProviderSelect("max-w-28 text-[11px] text-muted-foreground")}
|
||||
{renderModelSelect("max-w-52 text-[11px] text-foreground")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -693,7 +693,7 @@ describe("ChatMessages tool disclosures", () => {
|
||||
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
|
||||
[]),
|
||||
];
|
||||
expect(assistantActionButtons).toHaveLength(2);
|
||||
expect(assistantActionButtons).toHaveLength(3);
|
||||
expect(
|
||||
assistantActionButtons.every(
|
||||
(action) => action.getAttribute("data-slot") === "icon-button",
|
||||
|
||||
@@ -19,12 +19,15 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageImage,
|
||||
ChatMessageVideo,
|
||||
ChatSessionStatus,
|
||||
} from "@/lib/chat-schema";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { STREAMING_TITLE_CLASS } from "./messages/constants";
|
||||
import {
|
||||
@@ -35,12 +38,14 @@ import {
|
||||
} from "./messages/group-messages";
|
||||
import { ChatImageLightbox } from "./messages/image-lightbox";
|
||||
import { MessageBubble } from "./messages/message-bubble";
|
||||
import { ChatVideoLightbox } from "./messages/message-media";
|
||||
import {
|
||||
ToolApprovalPanel,
|
||||
type ToolApprovalRequestItem,
|
||||
} from "./messages/tool-approval-panel";
|
||||
import { ToolMessageBlock } from "./messages/tool-message-block";
|
||||
import { buildToolPresentation } from "./messages/tool-summaries";
|
||||
import { useAssistantSpeech } from "./messages/use-assistant-speech";
|
||||
import { SessionContent } from "./session-content";
|
||||
|
||||
type ChatMessagesProps = {
|
||||
@@ -70,6 +75,9 @@ type ChatMessagesProps = {
|
||||
runCount: number,
|
||||
) => void | Promise<void>;
|
||||
onForkSession?: () => void | Promise<void>;
|
||||
onOpenVoiceOutputSettings?: () => void;
|
||||
startingLabel?: string;
|
||||
errorAction?: { label: string; url: string };
|
||||
};
|
||||
|
||||
type AskQuestionRequestItem = {
|
||||
@@ -100,6 +108,9 @@ function ChatMessagesImpl({
|
||||
onRestoreCheckpoint,
|
||||
onEditMessage,
|
||||
onForkSession,
|
||||
onOpenVoiceOutputSettings,
|
||||
startingLabel = "Thinking...",
|
||||
errorAction,
|
||||
}: ChatMessagesProps) {
|
||||
const hasMessages = messages.length > 0;
|
||||
// Scanned from the tail without copying: this component re-renders on
|
||||
@@ -176,12 +187,22 @@ function ChatMessagesImpl({
|
||||
sessionId: string | null;
|
||||
image: ChatMessageImage;
|
||||
} | null>(null);
|
||||
const [expandedVideo, setExpandedVideo] = useState<{
|
||||
sessionId: string;
|
||||
video: ChatMessageVideo;
|
||||
} | null>(null);
|
||||
const assistantSpeech = useAssistantSpeech({
|
||||
sessionId,
|
||||
onOpenVoiceOutputSettings,
|
||||
});
|
||||
const sessionVersioningPending =
|
||||
editingMessageId !== null ||
|
||||
forkingMessageId !== null ||
|
||||
Object.values(checkpointActions).includes("undoing");
|
||||
const visibleExpandedImage =
|
||||
expandedImage?.sessionId === sessionId ? expandedImage.image : null;
|
||||
const visibleExpandedVideo =
|
||||
expandedVideo?.sessionId === sessionId ? expandedVideo : null;
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
const renderItems = useMemo(() => groupChatMessages(messages), [messages]);
|
||||
@@ -212,17 +233,18 @@ function ChatMessagesImpl({
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleExpandedImage) {
|
||||
if (!visibleExpandedImage && !visibleExpandedVideo) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setExpandedImage(null);
|
||||
setExpandedVideo(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [visibleExpandedImage]);
|
||||
}, [visibleExpandedImage, visibleExpandedVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
@@ -421,6 +443,12 @@ function ChatMessagesImpl({
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
const handleExpandVideo = useCallback(
|
||||
(video: ChatMessageVideo) => {
|
||||
if (sessionId) setExpandedVideo({ sessionId, video });
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const handleForkSession = useCallback(
|
||||
async (messageId: string) => {
|
||||
@@ -504,6 +532,7 @@ function ChatMessagesImpl({
|
||||
message={message}
|
||||
runCount={userRunCountByMessage.get(message)}
|
||||
onExpandImage={handleExpandImage}
|
||||
onExpandVideo={handleExpandVideo}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onEditMessage={
|
||||
onEditMessage ? requestEditMessage : undefined
|
||||
@@ -548,6 +577,19 @@ function ChatMessagesImpl({
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
onSpeakMessage={assistantSpeech.speak}
|
||||
speechAvailable={Boolean(assistantSpeech.target)}
|
||||
speechSettingsLoaded={assistantSpeech.settingsLoaded}
|
||||
speechState={
|
||||
assistantSpeech.state?.messageId === message.id
|
||||
? assistantSpeech.state.phase
|
||||
: undefined
|
||||
}
|
||||
speechTargetLabel={
|
||||
assistantSpeech.target
|
||||
? `${assistantSpeech.target.providerName} / ${assistantSpeech.target.modelName}`
|
||||
: undefined
|
||||
}
|
||||
reasoningContent={reasoningContent}
|
||||
reasoningRedacted={reasoningMessages.some(
|
||||
(reasoningMessage) =>
|
||||
@@ -623,7 +665,7 @@ function ChatMessagesImpl({
|
||||
!isSessionSwitching ? (
|
||||
<div className="flex min-h-7 items-center gap-2 py-1 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
<span className={STREAMING_TITLE_CLASS}>{startingLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{chatTransportState !== "connected" && !shouldShowErrorBanner ? (
|
||||
@@ -638,7 +680,17 @@ function ChatMessagesImpl({
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
<p>{error}</p>
|
||||
{errorAction ? (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => void openExternalUrl(errorAction.url)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{errorAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SessionContent>
|
||||
@@ -651,6 +703,13 @@ function ChatMessagesImpl({
|
||||
onClose={() => setExpandedImage(null)}
|
||||
/>
|
||||
) : null}
|
||||
{visibleExpandedVideo ? (
|
||||
<ChatVideoLightbox
|
||||
onClose={() => setExpandedVideo(null)}
|
||||
sessionId={visibleExpandedVideo.sessionId}
|
||||
video={visibleExpandedVideo.video}
|
||||
/>
|
||||
) : null}
|
||||
<AlertDialog
|
||||
open={checkpointConfirmation !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Github,
|
||||
LoaderCircle,
|
||||
LogIn,
|
||||
RefreshCcw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CloudOnboardingVariant =
|
||||
| "signed_out"
|
||||
| "not_connected"
|
||||
| "no_repositories"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Full-width onboarding panel shown in place of the composer when a cloud
|
||||
* session cannot be started yet (signed out, GitHub not connected, or the
|
||||
* GitHub App has no repository access). Teaches what cloud sessions are and
|
||||
* walks the user through the dashboard hand-off, then auto-detects completion.
|
||||
*/
|
||||
export function CloudOnboardingCard({
|
||||
variant,
|
||||
onConnect,
|
||||
onRefresh,
|
||||
onSignIn,
|
||||
signingIn = false,
|
||||
checking = false,
|
||||
}: {
|
||||
variant: CloudOnboardingVariant;
|
||||
/** Opens the Cline dashboard integrations page in the browser. */
|
||||
onConnect: () => void;
|
||||
onRefresh: () => void;
|
||||
onSignIn?: () => void;
|
||||
signingIn?: boolean;
|
||||
/** True while a background repository re-check is in flight. */
|
||||
checking?: boolean;
|
||||
}) {
|
||||
if (variant === "error") {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/80 p-6 text-center shadow-sm backdrop-blur-sm">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Could not reach Cline Cloud
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Check your connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={checking}
|
||||
onClick={onRefresh}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcw
|
||||
aria-hidden="true"
|
||||
className={cn("size-3.5", checking && "animate-spin")}
|
||||
/>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSignedOut = variant === "signed_out";
|
||||
const isNoRepositories = variant === "no_repositories";
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card/80 shadow-sm backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-6 p-6 max-[720px]:p-5">
|
||||
<div className="flex items-start justify-between gap-6 max-[720px]:flex-col">
|
||||
<div className="min-w-0">
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-primary">
|
||||
<Sparkles aria-hidden="true" className="size-3" />
|
||||
Cloud sessions
|
||||
</p>
|
||||
<h2 className="mt-3 text-lg font-semibold text-foreground">
|
||||
{isSignedOut
|
||||
? "Run Cline in the cloud"
|
||||
: isNoRepositories
|
||||
? "Give Cline access to a repository"
|
||||
: "Connect GitHub to get started"}
|
||||
</h2>
|
||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||
Cloud sessions run on secure, isolated sandboxes on Cline's
|
||||
infrastructure. Cline clones your repository, works on a branch,
|
||||
and keeps going even when you close the app — check back in from
|
||||
any device.
|
||||
</p>
|
||||
</div>
|
||||
<CloudFlowIllustration className="shrink-0 max-[720px]:self-center" />
|
||||
</div>
|
||||
|
||||
<ol className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
|
||||
{isSignedOut ? (
|
||||
<OnboardingStep
|
||||
icon={<LogIn aria-hidden="true" className="size-4" />}
|
||||
index={1}
|
||||
title="Sign in with Cline"
|
||||
>
|
||||
Cloud sessions are part of your Cline account.
|
||||
</OnboardingStep>
|
||||
) : (
|
||||
<OnboardingStep
|
||||
done={isNoRepositories}
|
||||
icon={<Github aria-hidden="true" className="size-4" />}
|
||||
index={1}
|
||||
title="Connect GitHub"
|
||||
>
|
||||
Link your GitHub account from the Cline dashboard.
|
||||
</OnboardingStep>
|
||||
)}
|
||||
<OnboardingStep
|
||||
active={isNoRepositories}
|
||||
icon={<ShieldCheck aria-hidden="true" className="size-4" />}
|
||||
index={2}
|
||||
title="Pick your repositories"
|
||||
>
|
||||
Choose which repositories the Cline GitHub App can access.
|
||||
</OnboardingStep>
|
||||
<OnboardingStep
|
||||
icon={<GitBranch aria-hidden="true" className="size-4" />}
|
||||
index={3}
|
||||
title="Start a session"
|
||||
>
|
||||
Pick a repo and branch here, describe the task, and Cline gets to
|
||||
work in the cloud.
|
||||
</OnboardingStep>
|
||||
</ol>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isSignedOut ? (
|
||||
<Button disabled={signingIn} onClick={onSignIn} size="sm">
|
||||
<LogIn aria-hidden="true" className="size-3.5" />
|
||||
{signingIn ? "Waiting for browser…" : "Sign in with Cline"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onConnect} size="sm">
|
||||
<Github aria-hidden="true" className="size-3.5" />
|
||||
{isNoRepositories ? "Manage repository access" : "Connect GitHub"}
|
||||
<ExternalLink aria-hidden="true" className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
{isSignedOut ? null : (
|
||||
<Button
|
||||
disabled={checking}
|
||||
onClick={onRefresh}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcw
|
||||
aria-hidden="true"
|
||||
className={cn("size-3.5", checking && "animate-spin")}
|
||||
/>
|
||||
{isNoRepositories ? "Check again" : "I've connected GitHub"}
|
||||
</Button>
|
||||
)}
|
||||
{isSignedOut ? null : (
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="size-3 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
Watching for changes — this updates automatically.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingStep({
|
||||
index,
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
done = false,
|
||||
active = false,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
/** Step already completed (e.g. GitHub connected, repos pending). */
|
||||
done?: boolean;
|
||||
/** The step the user should do next. */
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"rounded-lg border border-border/70 bg-background/60 p-3.5",
|
||||
active && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex size-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold",
|
||||
done
|
||||
? "bg-success/15 text-success"
|
||||
: active
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{done ? <Check aria-hidden="true" className="size-3.5" /> : index}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative GitHub → Cline Cloud → branch flow diagram. Built from theme
|
||||
* tokens so it adapts to light/dark mode and the accent color.
|
||||
*/
|
||||
function CloudFlowIllustration({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn("flex items-center gap-2.5", className)}
|
||||
>
|
||||
<IllustrationNode>
|
||||
<Github className="size-5 text-foreground" />
|
||||
</IllustrationNode>
|
||||
<ArrowRight className="size-3.5 text-muted-foreground/70" />
|
||||
<IllustrationNode emphasized>
|
||||
<Cloud className="size-6 text-primary" />
|
||||
</IllustrationNode>
|
||||
<ArrowRight className="size-3.5 text-muted-foreground/70" />
|
||||
<IllustrationNode>
|
||||
<GitBranch className="size-5 text-foreground" />
|
||||
</IllustrationNode>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IllustrationNode({
|
||||
children,
|
||||
emphasized = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-2xl border shadow-sm",
|
||||
emphasized
|
||||
? "size-14 border-primary/30 bg-primary/10"
|
||||
: "size-11 border-border/70 bg-background/80",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// @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",
|
||||
destination: "ubuntu@builder.example.com:2200",
|
||||
selected: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "pi-server",
|
||||
label: "Raspberry Pi",
|
||||
status: "Connected",
|
||||
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).toContain(
|
||||
"ubuntu@builder.example.com:2200",
|
||||
);
|
||||
expect(document.body.textContent).toContain("Connected");
|
||||
expect(document.body.textContent).toContain("Cloud");
|
||||
expect(document.body.textContent).toContain("Coming soon");
|
||||
expect(
|
||||
menuItemContaining("Cloud").getAttribute("data-disabled"),
|
||||
).not.toBeNull();
|
||||
|
||||
await click(menuItemContaining("Local"));
|
||||
await vi.waitFor(() => {
|
||||
expect(onSelectEnvironment).toHaveBeenCalledWith("local");
|
||||
});
|
||||
|
||||
await pointerDown(trigger());
|
||||
await click(menuItemContaining("Add SSH Host"));
|
||||
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,219 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Cloud, Laptop, Loader2, Plus, Server } 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 {
|
||||
formatRemoteEnvironmentDestination,
|
||||
type RemoteEnvironmentProfile,
|
||||
} from "@/lib/remote-environments";
|
||||
import { LOCAL_WORKSPACE_ENVIRONMENT_ID } from "@/lib/workspace-paths";
|
||||
|
||||
export type EnvironmentSelectorOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
destination?: string;
|
||||
kind: "local" | "remote";
|
||||
selected: boolean;
|
||||
status?: "Active" | "Connected";
|
||||
};
|
||||
|
||||
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",
|
||||
destination: formatRemoteEnvironmentDestination(profile),
|
||||
kind: "remote",
|
||||
selected,
|
||||
status: selected ? "Connected" : undefined,
|
||||
});
|
||||
}
|
||||
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,
|
||||
status: localSelected ? "Active" : undefined,
|
||||
},
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (option.status) {
|
||||
return (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{option.status}
|
||||
</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}
|
||||
disabled={busy}
|
||||
onSelect={() => void selectEnvironment(model.local.id)}
|
||||
>
|
||||
<Laptop />
|
||||
<span>Local</span>
|
||||
{optionStatus(model.local)}
|
||||
{model.local.selected ? <Check className="ml-1" /> : null}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Remote
|
||||
</DropdownMenuLabel>
|
||||
{model.remotes.length > 0 ? (
|
||||
model.remotes.map((option) => (
|
||||
<DropdownMenuItem
|
||||
aria-current={option.selected ? "true" : undefined}
|
||||
disabled={busy}
|
||||
key={option.id}
|
||||
onSelect={() => void selectEnvironment(option.id)}
|
||||
>
|
||||
<Server />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.destination ? (
|
||||
<span className="block truncate font-mono text-[10px] text-muted-foreground">
|
||||
{option.destination}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{optionStatus(option)}
|
||||
{option.selected ? <Check className="ml-1" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<Server />
|
||||
<span className="text-muted-foreground">No SSH hosts saved</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Cloud
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled>
|
||||
<Cloud />
|
||||
<span>Cloud</span>
|
||||
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
Coming soon
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onSelect={onAddSshHost}>
|
||||
<Plus />
|
||||
<span>Add SSH Host</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ describe("formatChatMessageContent", () => {
|
||||
expect(
|
||||
formatChatMessageContent(
|
||||
"user",
|
||||
" <user_input>\nPlease fix the tests\n</user_input> ",
|
||||
' <user_input mode="act">\nPlease fix the tests\n</user_input> ',
|
||||
),
|
||||
).toBe("Please fix the tests");
|
||||
});
|
||||
|
||||
+76
-2
@@ -16,16 +16,20 @@ import {
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SplitIcon,
|
||||
Square,
|
||||
UndoIcon,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatMessageImage,
|
||||
ChatMessageMedia,
|
||||
ChatMessageVideo,
|
||||
} from "@/lib/chat-schema";
|
||||
import { MemoizedMarkdown } from "../../../ui/markdown";
|
||||
import { formatChatMessageContent } from "../message-content";
|
||||
import { MessageAudios, MessageVideos } from "./message-media";
|
||||
import { ReasoningBlock } from "./reasoning-block";
|
||||
|
||||
function AssistantImageCarousel({
|
||||
@@ -150,6 +154,8 @@ function MessageMedia({ media }: { media: ChatMessageMedia[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export type AssistantSpeechPhase = "generating" | "playing";
|
||||
|
||||
// Memoized with id-parameterized callbacks: during streaming only the message
|
||||
// object that received a delta changes identity, so all other bubbles skip
|
||||
// re-rendering (and re-running their Markdown pipeline) per flush.
|
||||
@@ -160,6 +166,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
isStreaming = false,
|
||||
onCopyMessage,
|
||||
onExpandImage,
|
||||
onExpandVideo,
|
||||
onEditMessage,
|
||||
editDisabled = false,
|
||||
editPending = false,
|
||||
@@ -173,6 +180,11 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
forkDisabled = false,
|
||||
forkPending = false,
|
||||
forkError,
|
||||
onSpeakMessage,
|
||||
speechAvailable = false,
|
||||
speechSettingsLoaded = false,
|
||||
speechState,
|
||||
speechTargetLabel,
|
||||
isLastAssistantMessage = false,
|
||||
reasoningContent,
|
||||
reasoningRedacted,
|
||||
@@ -184,6 +196,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
isStreaming?: boolean;
|
||||
onCopyMessage?: (messageId: string, content: string) => void | Promise<void>;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
onExpandVideo?: (video: ChatMessageVideo) => void;
|
||||
onEditMessage?: (
|
||||
messageId: string,
|
||||
content: string,
|
||||
@@ -204,6 +217,11 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
forkDisabled?: boolean;
|
||||
forkPending?: boolean;
|
||||
forkError?: string;
|
||||
onSpeakMessage?: (messageId: string, content: string) => void | Promise<void>;
|
||||
speechAvailable?: boolean;
|
||||
speechSettingsLoaded?: boolean;
|
||||
speechState?: AssistantSpeechPhase;
|
||||
speechTargetLabel?: string;
|
||||
isLastAssistantMessage?: boolean;
|
||||
reasoningContent: string;
|
||||
reasoningRedacted: boolean;
|
||||
@@ -221,7 +239,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
!isStreaming &&
|
||||
!isError &&
|
||||
Boolean(displayContent.trim()) &&
|
||||
Boolean(onCopyMessage || onForkSession);
|
||||
Boolean(onCopyMessage || onForkSession || onSpeakMessage);
|
||||
const shouldRenderUserActions =
|
||||
isUser &&
|
||||
Boolean(
|
||||
@@ -235,7 +253,10 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
Boolean(restoreError) ||
|
||||
Boolean(editError);
|
||||
const keepAssistantActionsVisible =
|
||||
isLastAssistantMessage || forkPending || Boolean(forkError);
|
||||
isLastAssistantMessage ||
|
||||
forkPending ||
|
||||
Boolean(forkError) ||
|
||||
Boolean(speechState);
|
||||
|
||||
const messageDate = new Date(message.createdAt);
|
||||
const hasValidMessageDate = !Number.isNaN(messageDate.getTime());
|
||||
@@ -277,6 +298,21 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.videos?.length && message.sessionId ? (
|
||||
<MessageVideos
|
||||
onExpandVideo={onExpandVideo}
|
||||
sessionId={message.sessionId}
|
||||
videos={message.videos}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.audios?.length && message.sessionId ? (
|
||||
<MessageAudios
|
||||
audios={message.audios}
|
||||
sessionId={message.sessionId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{message.media?.length ? <MessageMedia media={message.media} /> : null}
|
||||
|
||||
{displayContent ? (
|
||||
@@ -371,6 +407,44 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onSpeakMessage ? (
|
||||
<MessageAction
|
||||
disabled={!speechSettingsLoaded}
|
||||
label={
|
||||
speechState === "generating"
|
||||
? "Cancel speech generation"
|
||||
: speechState === "playing"
|
||||
? "Stop speaking assistant message"
|
||||
: speechAvailable
|
||||
? "Speak assistant message"
|
||||
: "Configure voice output"
|
||||
}
|
||||
onClick={() => void onSpeakMessage(message.id, displayContent)}
|
||||
title={
|
||||
!speechSettingsLoaded
|
||||
? "Loading voice output settings"
|
||||
: speechState === "generating"
|
||||
? "Cancel speech generation"
|
||||
: speechState === "playing"
|
||||
? "Stop speaking"
|
||||
: speechTargetLabel
|
||||
? `Speak with ${speechTargetLabel}`
|
||||
: "Configure voice output in Settings → Models"
|
||||
}
|
||||
>
|
||||
{speechState ? (
|
||||
<Square
|
||||
className={
|
||||
speechState === "generating"
|
||||
? "size-3 animate-pulse"
|
||||
: "size-3"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Volume2 className="size-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
disabled={forkDisabled || forkPending}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AudioPlayer,
|
||||
AudioPlayerControlBar,
|
||||
AudioPlayerElement,
|
||||
AudioPlayerPlayButton,
|
||||
AudioPlayerTimeDisplay,
|
||||
AudioPlayerTimeRange,
|
||||
} from "@cline/ui";
|
||||
import { ChevronLeft, ChevronRight, Loader2, Maximize2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
ChatMessageAudio,
|
||||
ChatMessageImage,
|
||||
ChatMessageVideo,
|
||||
} from "@/lib/chat-schema";
|
||||
import { resolveDesktopBackendHttpEndpoint } from "@/lib/desktop-client";
|
||||
|
||||
export function MessageImages({
|
||||
images,
|
||||
isUser,
|
||||
onExpandImage,
|
||||
}: {
|
||||
images: ChatMessageImage[];
|
||||
isUser: boolean;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
}) {
|
||||
if (!isUser) {
|
||||
return (
|
||||
<AssistantImageCarousel images={images} onExpandImage={onExpandImage} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-2">
|
||||
{images.map((image, index) => (
|
||||
<ImageButton
|
||||
alt={`Attachment ${index + 1}`}
|
||||
ariaLabel={`Expand attachment ${index + 1}`}
|
||||
image={image}
|
||||
key={image.id}
|
||||
onExpandImage={onExpandImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantImageCarousel({
|
||||
images,
|
||||
onExpandImage,
|
||||
}: {
|
||||
images: ChatMessageImage[];
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
}) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const lastIndex = images.length - 1;
|
||||
const safeIndex = Math.min(activeIndex, lastIndex);
|
||||
const image = images[safeIndex];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex((index) => Math.min(index, lastIndex));
|
||||
}, [lastIndex]);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-fit max-w-2xl">
|
||||
<ImageButton
|
||||
alt={`Generated result ${safeIndex + 1}`}
|
||||
ariaLabel={`Expand generated image ${safeIndex + 1}`}
|
||||
image={image}
|
||||
onExpandImage={onExpandImage}
|
||||
/>
|
||||
{images.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
aria-label="Previous generated image"
|
||||
className="absolute left-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === 0}
|
||||
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Next generated image"
|
||||
className="absolute right-1 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/85 text-foreground shadow-sm backdrop-blur-sm transition-opacity hover:bg-background disabled:cursor-not-allowed disabled:opacity-35"
|
||||
disabled={safeIndex === lastIndex}
|
||||
onClick={() =>
|
||||
setActiveIndex((index) => Math.min(lastIndex, index + 1))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full bg-background/85 px-2 py-0.5 text-[11px] text-foreground shadow-sm backdrop-blur-sm">
|
||||
{safeIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageButton({
|
||||
alt,
|
||||
ariaLabel,
|
||||
image,
|
||||
onExpandImage,
|
||||
}: {
|
||||
alt: string;
|
||||
ariaLabel: string;
|
||||
image: ChatMessageImage;
|
||||
onExpandImage?: (image: ChatMessageImage) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg border border-border bg-muted text-left transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onExpandImage?.(image)}
|
||||
type="button"
|
||||
>
|
||||
{/* biome-ignore lint/performance/noImgElement: In-memory data URLs cannot use Next's optimizer. */}
|
||||
<img
|
||||
alt={alt}
|
||||
className="max-h-56.25 max-w-56.25 object-contain"
|
||||
src={`data:${image.mediaType};base64,${image.data}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageVideos({
|
||||
sessionId,
|
||||
videos,
|
||||
onExpandVideo,
|
||||
}: {
|
||||
sessionId: string;
|
||||
videos: ChatMessageVideo[];
|
||||
onExpandVideo?: (video: ChatMessageVideo) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-2">
|
||||
{videos.map((video) => (
|
||||
<GeneratedVideo
|
||||
key={video.id}
|
||||
onExpandVideo={onExpandVideo}
|
||||
sessionId={sessionId}
|
||||
video={video}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatVideoLightbox({
|
||||
sessionId,
|
||||
video,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string;
|
||||
video: ChatMessageVideo;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const source = useSessionArtifactSource(sessionId, video.artifactName);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Expanded generated video"
|
||||
aria-modal="true"
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-background/95 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
>
|
||||
<button
|
||||
aria-label="Close expanded video"
|
||||
className="absolute inset-0 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center">
|
||||
{source ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: Generated videos do not include a separate caption track.
|
||||
<video
|
||||
aria-label="Expanded generated video player"
|
||||
autoPlay
|
||||
className="max-h-full max-w-full rounded-lg bg-black shadow-2xl"
|
||||
controls
|
||||
playsInline
|
||||
src={source}
|
||||
/>
|
||||
) : (
|
||||
<LoadingArtifact label="Loading video…" />
|
||||
)}
|
||||
<Button
|
||||
aria-label="Close video viewer"
|
||||
className="absolute right-0 top-0 rounded-full"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratedVideo({
|
||||
sessionId,
|
||||
video,
|
||||
onExpandVideo,
|
||||
}: {
|
||||
sessionId: string;
|
||||
video: ChatMessageVideo;
|
||||
onExpandVideo?: (video: ChatMessageVideo) => void;
|
||||
}) {
|
||||
const source = useSessionArtifactSource(sessionId, video.artifactName);
|
||||
|
||||
return (
|
||||
<div className="relative w-fit max-w-2xl overflow-hidden rounded-lg border border-border bg-black">
|
||||
{source ? (
|
||||
<>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: Generated videos do not include a separate caption track. */}
|
||||
<video
|
||||
aria-label="Generated video"
|
||||
className="max-h-96 max-w-full"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
src={source}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Expand generated video"
|
||||
className="absolute right-2 top-2 rounded-full bg-background/85 shadow-md backdrop-blur-sm"
|
||||
onClick={() => onExpandVideo?.(video)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<LoadingArtifact label="Loading video…" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageAudios({
|
||||
sessionId,
|
||||
audios,
|
||||
}: {
|
||||
sessionId: string;
|
||||
audios: ChatMessageAudio[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid max-w-2xl gap-2">
|
||||
{audios.map((audio) => (
|
||||
<GeneratedAudio audio={audio} key={audio.id} sessionId={sessionId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratedAudio({
|
||||
sessionId,
|
||||
audio,
|
||||
}: {
|
||||
sessionId: string;
|
||||
audio: ChatMessageAudio;
|
||||
}) {
|
||||
const source = useSessionArtifactSource(sessionId, audio.artifactName);
|
||||
|
||||
return source ? (
|
||||
<AudioPlayer className="w-full max-w-md text-foreground">
|
||||
<AudioPlayerElement
|
||||
aria-label="Generated audio"
|
||||
muted={false}
|
||||
onPlay={(event) => {
|
||||
event.currentTarget.muted = false;
|
||||
if (event.currentTarget.volume === 0) {
|
||||
event.currentTarget.volume = 1;
|
||||
}
|
||||
}}
|
||||
preload="metadata"
|
||||
src={source}
|
||||
/>
|
||||
<AudioPlayerControlBar className="w-full">
|
||||
<AudioPlayerPlayButton aria-label="Play or pause generated audio" />
|
||||
<AudioPlayerTimeRange />
|
||||
<AudioPlayerTimeDisplay
|
||||
aria-label="Generated audio time remaining"
|
||||
noToggle
|
||||
remaining
|
||||
/>
|
||||
</AudioPlayerControlBar>
|
||||
</AudioPlayer>
|
||||
) : (
|
||||
<LoadingArtifact label="Loading audio…" />
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingArtifact({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex h-40 w-72 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSessionArtifactSource(
|
||||
sessionId: string,
|
||||
artifactName: string,
|
||||
): string | null {
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveDesktopBackendHttpEndpoint().then((endpoint) => {
|
||||
if (cancelled) return;
|
||||
setSource(
|
||||
`${endpoint}/api/session-artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactName)}`,
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [artifactName, sessionId]);
|
||||
|
||||
return source;
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { desktopClient, writeDesktopDebugLog } from "@/lib/desktop-client";
|
||||
import {
|
||||
loadProviderModelCatalog,
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
type SpeechGenerationModelTarget,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type { AssistantSpeechPhase } from "./message-bubble";
|
||||
|
||||
type AssistantSpeechState = {
|
||||
messageId: string;
|
||||
phase: AssistantSpeechPhase;
|
||||
};
|
||||
|
||||
export function useAssistantSpeech({
|
||||
sessionId,
|
||||
onOpenVoiceOutputSettings,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
onOpenVoiceOutputSettings?: () => void;
|
||||
}) {
|
||||
const [target, setTarget] = useState<SpeechGenerationModelTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [state, setState] = useState<AssistantSpeechState | null>(null);
|
||||
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const activeAudioUrlRef = useRef<string | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let loadId = 0;
|
||||
const loadVoiceOutput = () => {
|
||||
const currentLoadId = ++loadId;
|
||||
setSettingsLoaded(false);
|
||||
void loadProviderModelCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled && currentLoadId === loadId) {
|
||||
setTarget(catalog.modes.voiceOutput);
|
||||
setSettingsLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && currentLoadId === loadId) {
|
||||
setTarget(null);
|
||||
setSettingsLoaded(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleModeSettingsChanged = (event: Event) => {
|
||||
const mode = (event as CustomEvent<{ mode?: string }>).detail?.mode;
|
||||
if (!mode || mode === "voiceOutput") loadVoiceOutput();
|
||||
};
|
||||
|
||||
loadVoiceOutput();
|
||||
window.addEventListener(
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
handleModeSettingsChanged,
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
loadId += 1;
|
||||
window.removeEventListener(
|
||||
MODE_SETTINGS_CHANGED_EVENT,
|
||||
handleModeSettingsChanged,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
requestIdRef.current += 1;
|
||||
const audio = activeAudioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
activeAudioRef.current = null;
|
||||
}
|
||||
const audioUrl = activeAudioUrlRef.current;
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
activeAudioUrlRef.current = null;
|
||||
}
|
||||
setState(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionIdRef.current !== sessionId) {
|
||||
sessionIdRef.current = sessionId;
|
||||
stop();
|
||||
}
|
||||
}, [sessionId, stop]);
|
||||
|
||||
useEffect(() => stop, [stop]);
|
||||
|
||||
const speak = useCallback(
|
||||
async (messageId: string, content: string) => {
|
||||
if (state?.messageId === messageId) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
if (!target) {
|
||||
onOpenVoiceOutputSettings?.();
|
||||
return;
|
||||
}
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
|
||||
stop();
|
||||
const requestId = requestIdRef.current;
|
||||
setState({ messageId, phase: "generating" });
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-output",
|
||||
level: "debug",
|
||||
message: "Assistant message requested generated speech",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: {
|
||||
messageId,
|
||||
providerId: target.providerId,
|
||||
modelId: target.modelId,
|
||||
textCharacters: text.length,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
audioBase64?: string;
|
||||
mediaType?: string;
|
||||
}>("synthesize_speech", { text });
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
if (!result.audioBase64) {
|
||||
throw new Error("The speech provider returned no audio");
|
||||
}
|
||||
|
||||
const mediaType = result.mediaType?.trim() || "audio/mpeg";
|
||||
const audioUrl = URL.createObjectURL(
|
||||
base64ToAudioBlob(result.audioBase64, mediaType),
|
||||
);
|
||||
const audio = new Audio(audioUrl);
|
||||
activeAudioRef.current = audio;
|
||||
activeAudioUrlRef.current = audioUrl;
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (activeAudioRef.current === audio) activeAudioRef.current = null;
|
||||
if (activeAudioUrlRef.current === audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
activeAudioUrlRef.current = null;
|
||||
}
|
||||
if (requestIdRef.current === requestId) {
|
||||
setState((current) =>
|
||||
current?.messageId === messageId ? null : current,
|
||||
);
|
||||
}
|
||||
};
|
||||
audio.addEventListener("ended", release, { once: true });
|
||||
audio.addEventListener("error", release, { once: true });
|
||||
setState({ messageId, phase: "playing" });
|
||||
await audio.play();
|
||||
} catch (error) {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
stop();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-output",
|
||||
level: "error",
|
||||
message: "Assistant message voice playback failed in the webview",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { failure: message, messageId },
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Voice playback failed",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onOpenVoiceOutputSettings, state?.messageId, stop, target],
|
||||
);
|
||||
|
||||
return { settingsLoaded, speak, state, target };
|
||||
}
|
||||
|
||||
function base64ToAudioBlob(audioBase64: string, mediaType: string): Blob {
|
||||
const binary = window.atob(audioBase64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes.buffer], { type: mediaType });
|
||||
}
|
||||
+145
@@ -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,11 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { act } 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";
|
||||
import { WelcomeScreen } from "./welcome-chat";
|
||||
|
||||
const { invokeMock, subscribeMock, accountRef } = vi.hoisted(() => ({
|
||||
invokeMock: vi.fn(
|
||||
async (_command: string, _args?: unknown) => ({}) as unknown,
|
||||
),
|
||||
subscribeMock: vi.fn(
|
||||
(_eventName: string, _handler: (payload: unknown) => void) => () =>
|
||||
undefined,
|
||||
),
|
||||
accountRef: { user: null as { id: string } | null },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: {
|
||||
invoke: invokeMock,
|
||||
subscribe: subscribeMock,
|
||||
},
|
||||
openExternalUrl: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/account-context", () => ({
|
||||
useAccount: () => ({
|
||||
user: accountRef.user,
|
||||
refreshAccount: vi.fn(async () => undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
@@ -36,6 +63,7 @@ async function renderWelcomeScreen({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
})),
|
||||
...cloudProps
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
@@ -45,7 +73,7 @@ async function renderWelcomeScreen({
|
||||
current: string;
|
||||
branches: string[];
|
||||
}>;
|
||||
}): Promise<void> {
|
||||
} & Partial<ComponentProps<typeof WelcomeScreen>>): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WorkspaceProvider
|
||||
@@ -66,6 +94,7 @@ async function renderWelcomeScreen({
|
||||
gitBranch={gitBranch}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
{...cloudProps}
|
||||
/>
|
||||
</WorkspaceProvider>,
|
||||
);
|
||||
@@ -86,6 +115,20 @@ async function clickButton(text: string, last = false): Promise<void> {
|
||||
}
|
||||
|
||||
describe("WelcomeScreen", () => {
|
||||
it("keeps the welcome composer in the upper portion of the page", async () => {
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
|
||||
const content = container.querySelector(".max-w-240");
|
||||
expect(content?.className).toContain("pt-[clamp(4rem,14vh,9rem)]");
|
||||
expect(content?.className).not.toContain("26vh");
|
||||
const composerWrapper = content?.querySelector(".mt-4");
|
||||
expect(composerWrapper?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(composerWrapper?.classList.contains("max-w-full")).toBe(true);
|
||||
});
|
||||
|
||||
// Prompt suggestions (quick-action cards, including "Review changes") are
|
||||
// temporarily disabled while we improve them; see welcome-chat.tsx.
|
||||
// Re-enable these tests when the suggestions come back.
|
||||
@@ -235,6 +278,117 @@ describe("WelcomeScreen", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a repository picked from a freshly scoped list after an org switch", async () => {
|
||||
accountRef.user = { id: "user-1" };
|
||||
const repository = (owner: string) => ({
|
||||
id: 7,
|
||||
name: "repo",
|
||||
fullName: `${owner}/repo`,
|
||||
url: `https://github.com/${owner}/repo`,
|
||||
defaultBranch: "main",
|
||||
});
|
||||
// Mount-time check sees the old org; every later fetch (the picker's
|
||||
// included) sees the new org.
|
||||
let fetches = 0;
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_cloud_repositories") {
|
||||
fetches += 1;
|
||||
return {
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [repository(fetches === 1 ? "oldorg" : "neworg")],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const onRepoUrlChange = vi.fn();
|
||||
const onCloudBranchChange = vi.fn();
|
||||
const cloudProps = {
|
||||
cloudAgentsEnabled: true,
|
||||
executionTarget: "cloud" as const,
|
||||
onRepoUrlChange,
|
||||
onCloudBranchChange,
|
||||
};
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
...cloudProps,
|
||||
});
|
||||
|
||||
// Pick the new-org repository from the picker (whose fetch is scoped
|
||||
// to the new org).
|
||||
await clickButton("Select repository");
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await clickButton("neworg/repo");
|
||||
expect(onRepoUrlChange).toHaveBeenLastCalledWith(
|
||||
"https://github.com/neworg/repo",
|
||||
);
|
||||
|
||||
// The parent applies the selection; the stale-selection guard must
|
||||
// not wipe it against the old org's snapshot.
|
||||
onRepoUrlChange.mockClear();
|
||||
onCloudBranchChange.mockClear();
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
...cloudProps,
|
||||
repoUrl: "https://github.com/neworg/repo",
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onRepoUrlChange).not.toHaveBeenCalledWith("");
|
||||
expect(onCloudBranchChange).not.toHaveBeenCalledWith("");
|
||||
accountRef.user = null;
|
||||
});
|
||||
|
||||
it("re-checks cloud setup when the sidecar broadcasts a scope change", async () => {
|
||||
accountRef.user = { id: "user-1" };
|
||||
let fetches = 0;
|
||||
invokeMock.mockImplementation(async (command: string) => {
|
||||
if (command === "list_cloud_repositories") {
|
||||
fetches += 1;
|
||||
return {
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [
|
||||
{
|
||||
id: 7,
|
||||
name: "repo",
|
||||
fullName: "org/repo",
|
||||
url: "https://github.com/org/repo",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await renderWelcomeScreen({
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
cloudAgentsEnabled: true,
|
||||
executionTarget: "cloud",
|
||||
});
|
||||
const scopeHandler = subscribeMock.mock.calls.find(
|
||||
([eventName]) => eventName === "cloud_sessions_changed",
|
||||
)?.[1] as ((payload: unknown) => void) | undefined;
|
||||
expect(scopeHandler).toBeDefined();
|
||||
|
||||
const fetchesBefore = fetches;
|
||||
await act(async () => {
|
||||
scopeHandler?.({});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetches).toBeGreaterThan(fetchesBefore);
|
||||
accountRef.user = null;
|
||||
});
|
||||
|
||||
it("selects Just chat from the pathless workspace menu", async () => {
|
||||
const selectChat = vi.fn(async () => true);
|
||||
const onListGitBranches = vi.fn(async () => ({
|
||||
|
||||
@@ -1,122 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared/browser";
|
||||
import { AgentAurora, AgentHeroHeading } from "@cline/ui";
|
||||
import { Cloud } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import {
|
||||
type CloudBranchListOptions,
|
||||
type CloudBranchListResult,
|
||||
type CloudRepositoryListResult,
|
||||
normalizeCloudRepositoryUrl,
|
||||
} from "@/lib/cloud-repositories";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CloudOnboardingCard,
|
||||
type CloudOnboardingVariant,
|
||||
} from "./cloud-onboarding";
|
||||
import { SessionContent } from "./session-content";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
// Prompt suggestions (including "Review changes") are temporarily disabled
|
||||
// while we improve them. To re-enable, uncomment the block below plus the
|
||||
// `AgentQuickActions` render in `WelcomeScreen`, restore the commented-out
|
||||
// imports (`isChatWorkspacePath`, `AgentQuickAction`, `AgentQuickActions`,
|
||||
// `useMemo`) and the `onStartChat`/`quickActions` props, and re-enable the
|
||||
// suggestion tests in `welcome-chat.test.tsx`.
|
||||
//
|
||||
// import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
// import { type AgentQuickAction, AgentQuickActions } from "@cline/ui";
|
||||
// import { useMemo } from "react";
|
||||
//
|
||||
// /** Code-centric starters, shown only when the folder is a git repository. */
|
||||
// const DEVELOPER_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
// {
|
||||
// id: "review-changes",
|
||||
// label: "Review changes",
|
||||
// description: "Review the current changes and call out anything risky.",
|
||||
// value: "Review the current changes and call out anything risky.",
|
||||
// },
|
||||
// {
|
||||
// id: "check-build",
|
||||
// label: "Check for build errors",
|
||||
// description: "Run the relevant checks and help me fix any failures.",
|
||||
// value: "Check this project for build errors and help me fix any failures.",
|
||||
// },
|
||||
// ];
|
||||
//
|
||||
// /**
|
||||
// * General-purpose starters for a plain (non-git) folder — phrased around the
|
||||
// * files the agent can see, with no developer vocabulary.
|
||||
// */
|
||||
// const FOLDER_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
// {
|
||||
// id: "summarize-folder",
|
||||
// label: "Summarize this folder",
|
||||
// description: "Get a plain-language overview of the files here.",
|
||||
// value:
|
||||
// "Look through the files in this folder and give me a plain-language summary of what's here.",
|
||||
// },
|
||||
// {
|
||||
// id: "organize-files",
|
||||
// label: "Organize these files",
|
||||
// description: "Tidy up names and structure, with your approval.",
|
||||
// value:
|
||||
// "Help me organize this folder: suggest a tidy structure and clearer file names, and check with me before moving anything.",
|
||||
// },
|
||||
// {
|
||||
// id: "draft-document",
|
||||
// label: "Draft a document",
|
||||
// description: "Start a new doc with a first draft you can edit.",
|
||||
// value:
|
||||
// "Help me draft a new document in this folder. Ask me a few questions about what it should cover, then write a first draft.",
|
||||
// },
|
||||
// ];
|
||||
//
|
||||
// /** Starters for chat with no folder selected at all. */
|
||||
// const CHAT_QUICK_ACTIONS: AgentQuickAction[] = [
|
||||
// {
|
||||
// id: "draft-document",
|
||||
// label: "Draft a document",
|
||||
// description: "Start a new doc with a first draft you can edit.",
|
||||
// value:
|
||||
// "Help me draft a document. Ask me a few questions about what it should cover, then write a first draft.",
|
||||
// },
|
||||
// {
|
||||
// id: "research-topic",
|
||||
// label: "Research a topic",
|
||||
// description: "Gather the key facts and sum them up.",
|
||||
// value:
|
||||
// "Research a topic for me: ask me what I want to learn about, then summarize the key points in plain language.",
|
||||
// },
|
||||
// {
|
||||
// id: "plan-something",
|
||||
// label: "Plan something",
|
||||
// description: "Break a goal into clear, doable steps.",
|
||||
// value:
|
||||
// "Help me plan something. Ask me what I'm trying to get done, then break it into clear steps.",
|
||||
// },
|
||||
// ];
|
||||
//
|
||||
// /**
|
||||
// * Picks starter suggestions that match what the user actually opened: code
|
||||
// * cards only make sense inside a git repo; a plain folder gets file-oriented
|
||||
// * cards; no folder at all gets folderless general-purpose cards.
|
||||
// *
|
||||
// * `gitBranch` is `null` while branch discovery for the selected folder is
|
||||
// * still pending; no cards are suggested until the folder is classified so a
|
||||
// * git repo never flashes the plain-folder set (or vice versa).
|
||||
// */
|
||||
// export function defaultQuickActionsForContext({
|
||||
// workspaceRoot,
|
||||
// gitBranch,
|
||||
// }: {
|
||||
// workspaceRoot: string;
|
||||
// gitBranch: string | null;
|
||||
// }): AgentQuickAction[] {
|
||||
// const isChatWorkspace =
|
||||
// !workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
// if (isChatWorkspace) {
|
||||
// return CHAT_QUICK_ACTIONS;
|
||||
// }
|
||||
// if (gitBranch === null) {
|
||||
// return [];
|
||||
// }
|
||||
// if (gitBranch !== "no-git") {
|
||||
// return DEVELOPER_QUICK_ACTIONS;
|
||||
// }
|
||||
// return FOLDER_QUICK_ACTIONS;
|
||||
// }
|
||||
// Used only until the API's connectUrl arrives (or when it is blank), so a
|
||||
// staging/local build still points at its own dashboard.
|
||||
const FALLBACK_CONNECT_URL = `${getClineEnvironmentConfig().appBaseUrl}/dashboard/integrations`;
|
||||
// The dashboard hand-off happens in the browser, so re-check often enough
|
||||
// that the panel flips to ready shortly after the user finishes there.
|
||||
const CLOUD_SETUP_POLL_INTERVAL_MS = 6_000;
|
||||
|
||||
type CloudSetupState = {
|
||||
status:
|
||||
| "unknown"
|
||||
| "checking"
|
||||
| "ready"
|
||||
| "not_connected"
|
||||
| "no_repositories"
|
||||
| "error";
|
||||
connectUrl: string;
|
||||
/** Normalized URLs of repositories the account can currently access. */
|
||||
repositoryUrls: string[];
|
||||
};
|
||||
|
||||
export function WelcomeScreen({
|
||||
active,
|
||||
@@ -126,6 +51,14 @@ export function WelcomeScreen({
|
||||
gitBranch,
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
executionTarget = "local",
|
||||
repoUrl = "",
|
||||
cloudBranch = "",
|
||||
onExecutionTargetChange = () => undefined,
|
||||
onRepoUrlChange = () => undefined,
|
||||
onCloudBranchChange = () => undefined,
|
||||
cloudAgentsEnabled = false,
|
||||
environmentSelector,
|
||||
}: {
|
||||
active: boolean;
|
||||
body: ReactNode;
|
||||
@@ -136,7 +69,25 @@ export function WelcomeScreen({
|
||||
gitBranch: string | null;
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
executionTarget?: "local" | "cloud";
|
||||
repoUrl?: string;
|
||||
cloudBranch?: string;
|
||||
onExecutionTargetChange?: (target: "local" | "cloud") => void;
|
||||
onRepoUrlChange?: (repoUrl: string) => void;
|
||||
onCloudBranchChange?: (branch: string) => void;
|
||||
cloudAgentsEnabled?: boolean;
|
||||
environmentSelector?: ReactNode;
|
||||
}) {
|
||||
const { user, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
const [cloudSetup, setCloudSetup] = useState<CloudSetupState>({
|
||||
status: "unknown",
|
||||
connectUrl: FALLBACK_CONNECT_URL,
|
||||
repositoryUrls: [],
|
||||
});
|
||||
const [cloudSetupChecking, setCloudSetupChecking] = useState(false);
|
||||
const cloudSetupRequestRef = useRef(0);
|
||||
const {
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
@@ -145,16 +96,181 @@ export function WelcomeScreen({
|
||||
pickWorkspaceDirectory,
|
||||
selectChat,
|
||||
} = useWorkspace();
|
||||
// Suggestions are disabled for now; see the note above.
|
||||
// const defaultActions = useMemo(
|
||||
// () => defaultQuickActionsForContext({ workspaceRoot, gitBranch }),
|
||||
// [workspaceRoot, gitBranch],
|
||||
// );
|
||||
// const actions = quickActions.length > 0 ? quickActions : defaultActions;
|
||||
const applyCloudSetupResult = useCallback(
|
||||
(result: CloudRepositoryListResult) => {
|
||||
setCloudSetup({
|
||||
status:
|
||||
result.connected === false
|
||||
? "not_connected"
|
||||
: result.repositories.length === 0
|
||||
? "no_repositories"
|
||||
: "ready",
|
||||
connectUrl: result.connectUrl?.trim() || FALLBACK_CONNECT_URL,
|
||||
repositoryUrls: result.repositories.map((repository) =>
|
||||
normalizeCloudRepositoryUrl(repository.url),
|
||||
),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const fetchCloudRepositories = useCallback(
|
||||
() =>
|
||||
desktopClient.invoke<CloudRepositoryListResult>(
|
||||
"list_cloud_repositories",
|
||||
{},
|
||||
),
|
||||
[],
|
||||
);
|
||||
const listCloudRepositories = useCallback(async () => {
|
||||
// Every successful repository fetch — the picker's own load included —
|
||||
// refreshes the snapshot the stale-selection guard below compares
|
||||
// against. Without this, an org switch leaves the guard holding the
|
||||
// old scope's list and it wipes a repository just picked from the new
|
||||
// scope's correctly filtered picker.
|
||||
const requestId = ++cloudSetupRequestRef.current;
|
||||
const result = await fetchCloudRepositories();
|
||||
if (cloudSetupRequestRef.current === requestId) {
|
||||
applyCloudSetupResult(result);
|
||||
}
|
||||
return result;
|
||||
}, [applyCloudSetupResult, fetchCloudRepositories]);
|
||||
const listCloudBranches = useCallback(
|
||||
async (repositoryId: number, options: CloudBranchListOptions = {}) => {
|
||||
const result = await desktopClient.invoke<{
|
||||
available?: boolean;
|
||||
branches?: string[];
|
||||
nextToken?: string;
|
||||
}>("list_cloud_branches", { repositoryId, ...options });
|
||||
return {
|
||||
available: result.available !== false,
|
||||
branches: Array.isArray(result.branches) ? result.branches : [],
|
||||
nextToken:
|
||||
typeof result.nextToken === "string" ? result.nextToken : undefined,
|
||||
} satisfies CloudBranchListResult;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openExternalUrl = useCallback(async (url: string) => {
|
||||
await desktopClient.invoke("open_external_url", { url });
|
||||
}, []);
|
||||
|
||||
const cloudModeActive =
|
||||
active && cloudAgentsEnabled && executionTarget === "cloud";
|
||||
const signedIn = Boolean(user);
|
||||
const accountUserId = user?.id ?? null;
|
||||
// Read by the poll interval without making the state updater impure or
|
||||
// re-subscribing the effect on every status change.
|
||||
const cloudSetupStatusRef = useRef(cloudSetup.status);
|
||||
cloudSetupStatusRef.current = cloudSetup.status;
|
||||
|
||||
const checkCloudSetup = useCallback(async () => {
|
||||
const requestId = ++cloudSetupRequestRef.current;
|
||||
setCloudSetupChecking(true);
|
||||
try {
|
||||
const result = await fetchCloudRepositories();
|
||||
if (cloudSetupRequestRef.current !== requestId) return;
|
||||
applyCloudSetupResult(result);
|
||||
} catch {
|
||||
if (cloudSetupRequestRef.current !== requestId) return;
|
||||
setCloudSetup((prev) => ({ ...prev, status: "error" }));
|
||||
} finally {
|
||||
if (cloudSetupRequestRef.current === requestId) {
|
||||
setCloudSetupChecking(false);
|
||||
}
|
||||
}
|
||||
}, [applyCloudSetupResult, fetchCloudRepositories]);
|
||||
|
||||
// Check GitHub connectivity whenever the cloud composer becomes relevant
|
||||
// or the signed-in account changes, and keep watching while onboarding is
|
||||
// on screen: the connect flow finishes in the browser, so the panel must
|
||||
// notice on its own.
|
||||
useEffect(() => {
|
||||
void accountUserId;
|
||||
if (!cloudModeActive || !signedIn) return;
|
||||
setCloudSetup((prev) =>
|
||||
prev.status === "unknown" ? { ...prev, status: "checking" } : prev,
|
||||
);
|
||||
void checkCloudSetup();
|
||||
const handleFocus = () => void checkCloudSetup();
|
||||
window.addEventListener("focus", handleFocus);
|
||||
const interval = window.setInterval(() => {
|
||||
const status = cloudSetupStatusRef.current;
|
||||
if (status === "not_connected" || status === "no_repositories") {
|
||||
void checkCloudSetup();
|
||||
}
|
||||
}, CLOUD_SETUP_POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [accountUserId, checkCloudSetup, cloudModeActive, signedIn]);
|
||||
|
||||
// Account/organization switches re-scope the repository list on the
|
||||
// sidecar side; refresh the setup snapshot immediately instead of waiting
|
||||
// for a focus event or the onboarding poll (which stops in "ready").
|
||||
useEffect(() => {
|
||||
if (!cloudModeActive || !signedIn) return;
|
||||
return desktopClient.subscribe("cloud_sessions_changed", () => {
|
||||
void checkCloudSetup();
|
||||
});
|
||||
}, [checkCloudSetup, cloudModeActive, signedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void refreshWorkspaces();
|
||||
}, [active, refreshWorkspaces]);
|
||||
if (active && executionTarget === "local") void refreshWorkspaces();
|
||||
}, [active, executionTarget, refreshWorkspaces]);
|
||||
|
||||
// A previously selected repository can disappear from the account's reach
|
||||
// (GitHub App access revoked, account/org switched). Clear the stale
|
||||
// selection so the "Repository required" gate re-engages instead of
|
||||
// letting the send fail server-side after the fact.
|
||||
useEffect(() => {
|
||||
if (!cloudModeActive || cloudSetup.status === "unknown") return;
|
||||
if (cloudSetup.status === "error" || cloudSetup.status === "checking") {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeCloudRepositoryUrl(repoUrl);
|
||||
if (!normalized) return;
|
||||
if (!cloudSetup.repositoryUrls.includes(normalized)) {
|
||||
onRepoUrlChange("");
|
||||
onCloudBranchChange("");
|
||||
}
|
||||
}, [
|
||||
cloudModeActive,
|
||||
cloudSetup,
|
||||
onCloudBranchChange,
|
||||
onRepoUrlChange,
|
||||
repoUrl,
|
||||
]);
|
||||
|
||||
const signIn = async () => {
|
||||
if (signingIn) return;
|
||||
setSigningIn(true);
|
||||
setSignInError(null);
|
||||
try {
|
||||
await desktopClient.invoke("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
invalidateProviderCatalogCache();
|
||||
await refreshAccount();
|
||||
} catch (error) {
|
||||
setSignInError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cloudOnboardingVariant: CloudOnboardingVariant | null = !cloudModeActive
|
||||
? null
|
||||
: !signedIn
|
||||
? "signed_out"
|
||||
: cloudSetup.status === "not_connected"
|
||||
? "not_connected"
|
||||
: cloudSetup.status === "no_repositories"
|
||||
? "no_repositories"
|
||||
: cloudSetup.status === "error"
|
||||
? "error"
|
||||
: null;
|
||||
const showCloudOnboarding = cloudOnboardingVariant !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -175,7 +291,7 @@ export function WelcomeScreen({
|
||||
<div
|
||||
className={cn(
|
||||
active
|
||||
? "mx-auto flex min-h-full w-full max-w-240 flex-col justify-center px-6 py-16 max-[720px]:px-4 max-[720px]:py-10"
|
||||
? "mx-auto flex w-full max-w-240 flex-col px-6 pb-32 pt-[clamp(4rem,14vh,9rem)] max-[720px]:px-4 max-[720px]:pb-20 max-[720px]:pt-16"
|
||||
: "contents",
|
||||
)}
|
||||
>
|
||||
@@ -183,18 +299,38 @@ export function WelcomeScreen({
|
||||
<div className="cline-view-enter">
|
||||
<AgentHeroHeading />
|
||||
|
||||
<div className="mt-11 flex min-w-0 items-center">
|
||||
<div className="mt-11 flex min-w-0 items-center gap-2">
|
||||
{environmentSelector}
|
||||
<WelcomeWorkspaceControls
|
||||
cloudBranch={cloudBranch}
|
||||
cloudControlsHidden={showCloudOnboarding}
|
||||
cloudEnabled={cloudAgentsEnabled}
|
||||
currentBranch={gitBranch}
|
||||
executionTarget={executionTarget}
|
||||
onCloudBranchChange={onCloudBranchChange}
|
||||
onListCloudBranches={listCloudBranches}
|
||||
onListCloudRepositories={listCloudRepositories}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onOpenExternalUrl={openExternalUrl}
|
||||
onPickWorkspaceDirectory={pickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={refreshWorkspaces}
|
||||
onExecutionTargetChange={onExecutionTargetChange}
|
||||
onRepoUrlChange={onRepoUrlChange}
|
||||
onSignIn={signIn}
|
||||
onSelectChat={selectChat}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onSwitchWorkspace={switchWorkspace}
|
||||
repoUrl={repoUrl}
|
||||
signedIn={signedIn}
|
||||
signingIn={signingIn}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{signInError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Sign in failed: {signInError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -210,23 +346,40 @@ export function WelcomeScreen({
|
||||
{body}
|
||||
</div>
|
||||
|
||||
{active && notice ? notice : null}
|
||||
{active && notice && !showCloudOnboarding ? notice : null}
|
||||
|
||||
{active && showCloudOnboarding ? (
|
||||
<div className="mt-4 w-full">
|
||||
<CloudOnboardingCard
|
||||
checking={cloudSetupChecking}
|
||||
onConnect={() => void openExternalUrl(cloudSetup.connectUrl)}
|
||||
onRefresh={() => void checkCloudSetup()}
|
||||
onSignIn={() => void signIn()}
|
||||
signingIn={signingIn}
|
||||
variant={cloudOnboardingVariant}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0 px-6 pb-6"}
|
||||
className={cn(
|
||||
active
|
||||
? "mt-4 min-w-0 w-full max-w-full"
|
||||
: "z-20 shrink-0 px-6 pb-6",
|
||||
active && showCloudOnboarding && "hidden",
|
||||
)}
|
||||
key="persistent-composer"
|
||||
>
|
||||
{active ? composer : <SessionContent>{composer}</SessionContent>}
|
||||
</div>
|
||||
|
||||
{/* Prompt suggestions are disabled for now; see the note above.
|
||||
{active ? (
|
||||
<AgentQuickActions
|
||||
actions={actions}
|
||||
className="cline-view-enter mt-11"
|
||||
onSelect={(action) => onStartChat(action.value)}
|
||||
/>
|
||||
) : null} */}
|
||||
{active && cloudModeActive && !showCloudOnboarding ? (
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-center text-xs text-muted-foreground">
|
||||
<Cloud aria-hidden="true" className="size-3 shrink-0" />
|
||||
Cloud sessions run on a secure sandbox, work on a branch, and keep
|
||||
going even when you close the app.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+527
-59
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -10,6 +11,16 @@ let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const values = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
clear: () => values.clear(),
|
||||
},
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -19,36 +30,509 @@ afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderControls({
|
||||
onSwitchWorkspace = vi.fn(async () => true),
|
||||
onPickWorkspaceDirectory = vi.fn(async (): Promise<string | null> => null),
|
||||
}: {
|
||||
onSwitchWorkspace?: (workspacePath: string) => Promise<boolean>;
|
||||
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
|
||||
} = {}): Promise<void> {
|
||||
function button(text: string): HTMLButtonElement {
|
||||
const match = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
].find((candidate) => candidate.textContent?.includes(text));
|
||||
expect(match).toBeDefined();
|
||||
return match as HTMLButtonElement;
|
||||
}
|
||||
|
||||
async function click(target: HTMLElement) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/projects/project-1"
|
||||
workspaces={["/projects/project-1"]}
|
||||
/>,
|
||||
);
|
||||
target.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function renderControls(
|
||||
overrides: Partial<ComponentProps<typeof WelcomeWorkspaceControls>> = {},
|
||||
) {
|
||||
const props: ComponentProps<typeof WelcomeWorkspaceControls> = {
|
||||
cloudEnabled: true,
|
||||
executionTarget: "local",
|
||||
repoUrl: "",
|
||||
cloudBranch: "",
|
||||
onCloudBranchChange: vi.fn(),
|
||||
signedIn: true,
|
||||
signingIn: false,
|
||||
onExecutionTargetChange: vi.fn(),
|
||||
onRepoUrlChange: vi.fn(),
|
||||
onListCloudRepositories: vi.fn(async () => ({
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [
|
||||
{
|
||||
id: 42,
|
||||
name: "cline",
|
||||
fullName: "cline/cline",
|
||||
url: "https://github.com/cline/cline",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
],
|
||||
})),
|
||||
onListCloudBranches: vi.fn(async () => ({
|
||||
available: true,
|
||||
branches: ["main", "feature/cloud"],
|
||||
})),
|
||||
onOpenExternalUrl: vi.fn(async () => undefined),
|
||||
onSignIn: vi.fn(),
|
||||
workspaceRoot: "/projects/cline",
|
||||
workspaces: ["/projects/cline"],
|
||||
onRefreshWorkspaces: vi.fn(async () => undefined),
|
||||
onSwitchWorkspace: vi.fn(async () => true),
|
||||
onPickWorkspaceDirectory: vi.fn(async () => null),
|
||||
onSelectChat: vi.fn(async () => true),
|
||||
currentBranch: "main",
|
||||
onListGitBranches: vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
})),
|
||||
onSwitchGitBranch: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
act(() => root.render(<WelcomeWorkspaceControls {...props} />));
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("WelcomeWorkspaceControls cloud mode", () => {
|
||||
it("selects Cloud from the same workspace control row", async () => {
|
||||
const props = renderControls();
|
||||
await click(button("Cloud"));
|
||||
expect(props.onExecutionTargetChange).toHaveBeenCalledWith("cloud");
|
||||
});
|
||||
|
||||
it("hides the Local/Cloud selector when the feature flag is off", () => {
|
||||
renderControls({ cloudEnabled: false });
|
||||
const buttons = [...container.querySelectorAll("button")].map(
|
||||
(candidate) => candidate.textContent ?? "",
|
||||
);
|
||||
expect(buttons.some((text) => text.includes("Cloud"))).toBe(false);
|
||||
// Local workspace controls still render.
|
||||
expect(buttons.some((text) => text.includes("cline"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires sign in before choosing a cloud repository", async () => {
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
signedIn: false,
|
||||
});
|
||||
expect(container.textContent).toContain("Sign in to use Cloud");
|
||||
expect(container.textContent).not.toContain("Select repository");
|
||||
await click(button("Sign in to use Cloud"));
|
||||
expect(props.onSignIn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects a connected GitHub repository and its default branch", async () => {
|
||||
const onRepoUrlChange = vi.fn();
|
||||
const onCloudBranchChange = vi.fn();
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onRepoUrlChange,
|
||||
onCloudBranchChange,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(props.onListCloudRepositories).toHaveBeenCalledOnce();
|
||||
await click(button("cline/cline"));
|
||||
expect(onRepoUrlChange).toHaveBeenLastCalledWith(
|
||||
"https://github.com/cline/cline",
|
||||
);
|
||||
expect(onCloudBranchChange).toHaveBeenLastCalledWith("main");
|
||||
});
|
||||
|
||||
it("loads and selects a branch for the connected repository", async () => {
|
||||
const onCloudBranchChange = vi.fn();
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onCloudBranchChange,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "main",
|
||||
onCloudBranchChange,
|
||||
onListCloudRepositories: props.onListCloudRepositories,
|
||||
onListCloudBranches: props.onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("main").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(props.onListCloudBranches).toHaveBeenCalledWith(42);
|
||||
await click(button("feature/cloud"));
|
||||
expect(onCloudBranchChange).toHaveBeenLastCalledWith("feature/cloud");
|
||||
});
|
||||
|
||||
it("loads additional branch pages as the user scrolls", async () => {
|
||||
let intersectionCallback:
|
||||
| ((entries: IntersectionObserverEntry[]) => void)
|
||||
| undefined;
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
const onListCloudBranches = vi.fn(
|
||||
async (_repositoryId: number, options?: { cursor?: string }) =>
|
||||
options?.cursor
|
||||
? { available: true, branches: ["feature/cloud"], nextToken: "" }
|
||||
: { available: true, branches: ["main"], nextToken: "2" },
|
||||
);
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "main",
|
||||
onListCloudRepositories: props.onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42),
|
||||
);
|
||||
await click(button("main"));
|
||||
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
|
||||
await act(async () => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true } as IntersectionObserverEntry,
|
||||
]);
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42, { cursor: "2" }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("feature/cloud"),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers pagination when the search changes while a page fetch is in flight", async () => {
|
||||
let intersectionCallback:
|
||||
| ((entries: IntersectionObserverEntry[]) => void)
|
||||
| undefined;
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
let releaseHungPage:
|
||||
| ((result: {
|
||||
available: boolean;
|
||||
branches: string[];
|
||||
nextToken?: string;
|
||||
}) => void)
|
||||
| undefined;
|
||||
let cursorFetches = 0;
|
||||
const onListCloudBranches = vi.fn(
|
||||
async (
|
||||
_repositoryId: number,
|
||||
options?: { cursor?: string; query?: string },
|
||||
) => {
|
||||
if (options?.cursor) {
|
||||
cursorFetches += 1;
|
||||
if (cursorFetches === 1) {
|
||||
// First page fetch hangs until the test releases it.
|
||||
return new Promise<{
|
||||
available: boolean;
|
||||
branches: string[];
|
||||
nextToken?: string;
|
||||
}>((resolve) => {
|
||||
releaseHungPage = resolve;
|
||||
});
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
branches: ["feature/cloud"],
|
||||
nextToken: "",
|
||||
};
|
||||
}
|
||||
if (options?.query) {
|
||||
return {
|
||||
available: true,
|
||||
branches: ["feature/cloud"],
|
||||
nextToken: "",
|
||||
};
|
||||
}
|
||||
return { available: true, branches: ["main"], nextToken: "2" };
|
||||
},
|
||||
);
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "main",
|
||||
onListCloudRepositories: props.onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42),
|
||||
);
|
||||
await click(button("main"));
|
||||
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
|
||||
await act(async () => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true } as IntersectionObserverEntry,
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseHungPage).toBeDefined());
|
||||
|
||||
// Type a search character while the page fetch is still in flight; the
|
||||
// request key changes under it.
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search branches…"]',
|
||||
);
|
||||
expect(search).not.toBeNull();
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(search, "feature");
|
||||
search?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42, {
|
||||
query: "feature",
|
||||
}),
|
||||
);
|
||||
// The stale page fetch settles after the key changed; its results are
|
||||
// discarded but the loading flag must be released.
|
||||
await act(async () => {
|
||||
releaseHungPage?.({
|
||||
available: true,
|
||||
branches: ["stale/page"],
|
||||
nextToken: "3",
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).not.toContain("stale/page");
|
||||
|
||||
// Clear the search and scroll again: pagination must still work. Drop
|
||||
// the captured observer first: the effect only re-creates one after
|
||||
// the post-clear base list applied (nextToken set again), so waiting
|
||||
// for it guarantees the scroll uses fresh state instead of racing the
|
||||
// base fetch with a stale closure.
|
||||
intersectionCallback = undefined;
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(search, "");
|
||||
search?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await vi.waitFor(() => expect(intersectionCallback).toBeDefined());
|
||||
await act(async () => {
|
||||
intersectionCallback?.([
|
||||
{ isIntersecting: true } as IntersectionObserverEntry,
|
||||
]);
|
||||
});
|
||||
await vi.waitFor(() => expect(cursorFetches).toBe(2));
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("feature/cloud"),
|
||||
);
|
||||
});
|
||||
|
||||
it("searches branches through the server", async () => {
|
||||
const onListCloudBranches = vi.fn(
|
||||
async (_repositoryId: number, options?: { query?: string }) =>
|
||||
options?.query
|
||||
? { available: true, branches: ["feature/cloud"] }
|
||||
: { available: true, branches: ["main"] },
|
||||
);
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "main",
|
||||
onListCloudRepositories: props.onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42),
|
||||
);
|
||||
await click(button("main"));
|
||||
const search = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search branches…"]',
|
||||
);
|
||||
expect(search).not.toBeNull();
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(search, "feature");
|
||||
search?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42, {
|
||||
query: "feature",
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("feature/cloud"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses and labels the repository default when branch selection is unavailable", async () => {
|
||||
const onCloudBranchChange = vi.fn();
|
||||
const onListCloudBranches = vi.fn(async () => ({
|
||||
available: false,
|
||||
branches: [],
|
||||
}));
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onCloudBranchChange,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "main",
|
||||
onCloudBranchChange,
|
||||
onListCloudRepositories: props.onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(onListCloudBranches).toHaveBeenCalledWith(42);
|
||||
expect(container.textContent).toContain("main (default)");
|
||||
});
|
||||
});
|
||||
|
||||
const branchButton = button("main (default)");
|
||||
expect(branchButton.disabled).toBe(true);
|
||||
expect(branchButton.title).toBe(
|
||||
"Using the repository default branch: main",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Could not load branches.");
|
||||
});
|
||||
|
||||
it("uses a clear fallback label before default-branch metadata is deployed", async () => {
|
||||
const onListCloudRepositories = vi.fn(async () => ({
|
||||
connected: true,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [
|
||||
{
|
||||
id: 42,
|
||||
name: "cline",
|
||||
fullName: "cline/cline",
|
||||
url: "https://github.com/cline/cline",
|
||||
defaultBranch: "",
|
||||
},
|
||||
],
|
||||
}));
|
||||
const onListCloudBranches = vi.fn(async () => ({
|
||||
available: false,
|
||||
branches: [],
|
||||
}));
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await click(button("cline/cline"));
|
||||
renderControls({
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
cloudBranch: "",
|
||||
onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
onCloudBranchChange: props.onCloudBranchChange,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(button("Default branch").disabled).toBe(true);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Select branch… (default)");
|
||||
});
|
||||
|
||||
it("links to GitHub setup when no integration is connected", async () => {
|
||||
const props = renderControls({
|
||||
executionTarget: "cloud",
|
||||
onListCloudRepositories: vi.fn(async () => ({
|
||||
connected: false,
|
||||
connectUrl: "https://app.example/dashboard/integrations",
|
||||
repositories: [],
|
||||
})),
|
||||
});
|
||||
await act(async () => {
|
||||
button("Select repository").click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"Connect GitHub to select a repository.",
|
||||
);
|
||||
await click(button("Connect GitHub"));
|
||||
expect(props.onOpenExternalUrl).toHaveBeenCalledWith(
|
||||
"https://app.example/dashboard/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function clickButton(text: string): Promise<void> {
|
||||
const button = [
|
||||
...container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
@@ -61,7 +545,9 @@ async function clickButton(text: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function openWorkspaceMenu(): Promise<void> {
|
||||
await clickButton("project-1");
|
||||
await clickButton(
|
||||
container.textContent?.includes("project-1") ? "project-1" : "cline",
|
||||
);
|
||||
}
|
||||
|
||||
async function typeInSearch(value: string): Promise<void> {
|
||||
@@ -120,23 +606,13 @@ describe("WelcomeWorkspaceControls manual path entry", () => {
|
||||
// wipe the menu's typed path or a visible error while it is open.
|
||||
const onSwitchWorkspace = vi.fn(async () => false);
|
||||
const render = async () => {
|
||||
renderControls({
|
||||
onRefreshWorkspaces: vi.fn(async () => undefined),
|
||||
onSwitchWorkspace,
|
||||
workspaceRoot: "/projects/project-1",
|
||||
workspaces: ["/projects/project-1"],
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch="main"
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: "main",
|
||||
branches: ["main"],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
workspaceRoot="/projects/project-1"
|
||||
workspaces={["/projects/project-1"]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
@@ -173,26 +649,18 @@ describe("WelcomeWorkspaceControls manual path entry", () => {
|
||||
async function renderBranchChipControls(overrides: {
|
||||
currentBranch: string;
|
||||
}): Promise<void> {
|
||||
renderControls({
|
||||
cloudEnabled: false,
|
||||
currentBranch: overrides.currentBranch,
|
||||
onListGitBranches: vi.fn(async () => ({
|
||||
current: overrides.currentBranch,
|
||||
branches:
|
||||
overrides.currentBranch === "no-git" ? [] : [overrides.currentBranch],
|
||||
})),
|
||||
workspaceRoot: "/home/beatrix/recipes",
|
||||
workspaces: ["/home/beatrix/recipes"],
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<WelcomeWorkspaceControls
|
||||
currentBranch={overrides.currentBranch}
|
||||
onListGitBranches={vi.fn(async () => ({
|
||||
current: overrides.currentBranch,
|
||||
branches:
|
||||
overrides.currentBranch === "no-git"
|
||||
? []
|
||||
: [overrides.currentBranch],
|
||||
}))}
|
||||
onPickWorkspaceDirectory={vi.fn(async () => null)}
|
||||
onRefreshWorkspaces={vi.fn(async () => undefined)}
|
||||
onSelectChat={vi.fn(async () => true)}
|
||||
onSwitchGitBranch={vi.fn(async () => true)}
|
||||
onSwitchWorkspace={vi.fn(async () => true)}
|
||||
workspaceRoot="/home/beatrix/recipes"
|
||||
workspaces={["/home/beatrix/recipes"]}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
+626
-34
@@ -3,15 +3,37 @@
|
||||
import { isChatWorkspacePath } from "@cline/shared/browser";
|
||||
import {
|
||||
Check,
|
||||
Cloud,
|
||||
FilePlus2,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Github,
|
||||
HardDrive,
|
||||
LoaderCircle,
|
||||
LogIn,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
type CloudBranchListOptions,
|
||||
type CloudBranchListResult,
|
||||
type CloudRepositoryListResult,
|
||||
type CloudRepositoryOption,
|
||||
cloudRepositoryLabel,
|
||||
normalizeCloudRepositoryUrl,
|
||||
preferredCloudBranch,
|
||||
} from "@/lib/cloud-repositories";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
looksLikeFolderPath,
|
||||
@@ -43,6 +65,454 @@ const TRIGGER_CLASS =
|
||||
const PANEL_CLASS =
|
||||
"absolute left-0 top-full z-50 mt-2 w-72 rounded-lg border border-border bg-popover shadow-xl";
|
||||
|
||||
function ExecutionTargetPicker({
|
||||
executionTarget,
|
||||
onChange,
|
||||
}: {
|
||||
executionTarget: "local" | "cloud";
|
||||
onChange: (target: "local" | "cloud") => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="inline-flex shrink-0 items-center rounded-md border border-border/70 bg-background/80 p-0.5">
|
||||
<legend className="sr-only">Execution location</legend>
|
||||
{(["local", "cloud"] as const).map((target) => {
|
||||
const active = executionTarget === target;
|
||||
const Icon = target === "local" ? HardDrive : Cloud;
|
||||
return (
|
||||
<button
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-accent text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
key={target}
|
||||
onClick={() => onChange(target)}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{target === "local" ? "Local" : "Cloud"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudRepositoryPicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
repoUrl,
|
||||
onSelect,
|
||||
onRepositoriesLoaded,
|
||||
onListRepositories,
|
||||
onOpenExternalUrl,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
repoUrl: string;
|
||||
onSelect: (repository: CloudRepositoryOption) => void;
|
||||
onRepositoriesLoaded: (repositories: CloudRepositoryOption[]) => void;
|
||||
onListRepositories: () => Promise<CloudRepositoryListResult>;
|
||||
onOpenExternalUrl: (url: string) => Promise<void>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [result, setResult] = useState<CloudRepositoryListResult>();
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void reloadKey;
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
void onListRepositories()
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setResult(next);
|
||||
onRepositoriesLoaded(next.repositories);
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus("error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onListRepositories, onRepositoriesLoaded, open, reloadKey]);
|
||||
|
||||
const repositories = result?.repositories ?? [];
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredRepositories = repositories.filter((repository) =>
|
||||
`${repository.fullName} ${repository.name}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
|
||||
onClick={onToggle}
|
||||
title={repoUrl || "Select a connected GitHub repository"}
|
||||
type="button"
|
||||
>
|
||||
<Github
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="max-w-56 truncate">
|
||||
{repoUrl
|
||||
? cloudRepositoryLabel(repoUrl, "Cloud repo")
|
||||
: "Select repository…"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className={PANEL_CLASS}
|
||||
role="dialog"
|
||||
aria-label="Cloud repository"
|
||||
>
|
||||
{result?.connected !== false ? (
|
||||
<SearchInput
|
||||
onChange={setQuery}
|
||||
placeholder="Search repositories…"
|
||||
value={query}
|
||||
/>
|
||||
) : null}
|
||||
<div className="max-h-72 overflow-y-auto p-1.5">
|
||||
{status === "loading" ? (
|
||||
<PickerStatus icon="loading" message="Loading repositories…" />
|
||||
) : status === "error" ? (
|
||||
<PickerStatus message="Could not load repositories.">
|
||||
<Button
|
||||
onClick={() => setReloadKey((current) => current + 1)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</PickerStatus>
|
||||
) : result?.connected === false ? (
|
||||
<PickerStatus message="Connect GitHub to select a repository.">
|
||||
<Button
|
||||
onClick={() => void onOpenExternalUrl(result.connectUrl)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Connect GitHub
|
||||
</Button>
|
||||
</PickerStatus>
|
||||
) : filteredRepositories.length === 0 ? (
|
||||
<PickerStatus
|
||||
message={
|
||||
repositories.length === 0
|
||||
? "No connected repositories."
|
||||
: "No repositories found."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filteredRepositories.map((repository) => (
|
||||
<Button
|
||||
className="w-full justify-start text-xs"
|
||||
key={repository.id}
|
||||
onClick={() => {
|
||||
onSelect(repository);
|
||||
onClose();
|
||||
}}
|
||||
title={repository.fullName}
|
||||
variant="ghost"
|
||||
>
|
||||
<Github aria-hidden="true" className="size-3" />
|
||||
<span className="truncate">{repository.fullName}</span>
|
||||
{normalizeCloudRepositoryUrl(repoUrl) ===
|
||||
normalizeCloudRepositoryUrl(repository.url) ? (
|
||||
<Check aria-hidden="true" className="ml-auto size-3" />
|
||||
) : null}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudBranchPicker({
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
repositoryId,
|
||||
defaultBranch,
|
||||
branch,
|
||||
onBranchChange,
|
||||
onListBranches,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
repositoryId?: number;
|
||||
defaultBranch: string;
|
||||
branch: string;
|
||||
onBranchChange: (branch: string) => void;
|
||||
onListBranches: (
|
||||
repositoryId: number,
|
||||
options?: CloudBranchListOptions,
|
||||
) => Promise<CloudBranchListResult>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [nextToken, setNextToken] = useState("");
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [loadMoreError, setLoadMoreError] = useState(false);
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "error" | "unavailable"
|
||||
>("idle");
|
||||
const branchRef = useRef(branch);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const requestKeyRef = useRef("");
|
||||
branchRef.current = branch;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(
|
||||
() => setDebouncedQuery(query.trim()),
|
||||
250,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [query]);
|
||||
const searchPending = query.trim() !== debouncedQuery;
|
||||
|
||||
useEffect(() => {
|
||||
if (!repositoryId) return;
|
||||
const requestKey = `${repositoryId}:${debouncedQuery}:${reloadKey}`;
|
||||
requestKeyRef.current = requestKey;
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
setLoadMoreError(false);
|
||||
const request = debouncedQuery
|
||||
? onListBranches(repositoryId, { query: debouncedQuery })
|
||||
: onListBranches(repositoryId);
|
||||
void request
|
||||
.then((result) => {
|
||||
if (cancelled || requestKeyRef.current !== requestKey) return;
|
||||
if (!result.available) {
|
||||
setBranches([]);
|
||||
onBranchChange(defaultBranch);
|
||||
setStatus("unavailable");
|
||||
return;
|
||||
}
|
||||
setBranches(result.branches);
|
||||
setNextToken(result.nextToken ?? "");
|
||||
if (!branchRef.current) {
|
||||
onBranchChange(preferredCloudBranch(result.branches, defaultBranch));
|
||||
}
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && requestKeyRef.current === requestKey) {
|
||||
setStatus("error");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
debouncedQuery,
|
||||
defaultBranch,
|
||||
onBranchChange,
|
||||
onListBranches,
|
||||
reloadKey,
|
||||
repositoryId,
|
||||
]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!repositoryId || !nextToken || loadingMore) return;
|
||||
const requestKey = requestKeyRef.current;
|
||||
setLoadingMore(true);
|
||||
setLoadMoreError(false);
|
||||
try {
|
||||
const result = await onListBranches(repositoryId, {
|
||||
cursor: nextToken,
|
||||
query: debouncedQuery || undefined,
|
||||
});
|
||||
if (requestKeyRef.current !== requestKey) return;
|
||||
setBranches((current) => [...new Set([...current, ...result.branches])]);
|
||||
setNextToken(result.nextToken ?? "");
|
||||
} catch {
|
||||
if (requestKeyRef.current === requestKey) setLoadMoreError(true);
|
||||
} finally {
|
||||
// Reset unconditionally: only one page fetch can be in flight (the
|
||||
// loadingMore guard above), so this always refers to that fetch. A
|
||||
// key-guarded reset would leave loadingMore stuck true forever when
|
||||
// the search query changes mid-fetch, permanently killing
|
||||
// pagination for this picker.
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [debouncedQuery, loadingMore, nextToken, onListBranches, repositoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = listRef.current;
|
||||
const target = loadMoreRef.current;
|
||||
if (
|
||||
!open ||
|
||||
!root ||
|
||||
!target ||
|
||||
!nextToken ||
|
||||
loadingMore ||
|
||||
loadMoreError ||
|
||||
searchPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ root, rootMargin: "0px 0px 96px 0px" },
|
||||
);
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, loadMoreError, loadingMore, nextToken, open, searchPending]);
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(TRIGGER_CLASS, "min-w-0 max-w-full")}
|
||||
disabled={!repositoryId || status === "unavailable"}
|
||||
onClick={onToggle}
|
||||
title={
|
||||
status === "unavailable"
|
||||
? `Using the repository default branch${branch ? `: ${branch}` : ""}`
|
||||
: branch || "Select a branch"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="max-w-48 truncate">
|
||||
{status === "unavailable"
|
||||
? branch
|
||||
? `${branch} (default)`
|
||||
: "Default branch"
|
||||
: branch || "Select branch…"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && repositoryId && status !== "unavailable" ? (
|
||||
<div className={PANEL_CLASS} role="dialog" aria-label="Cloud branch">
|
||||
<SearchInput
|
||||
onChange={setQuery}
|
||||
placeholder="Search branches…"
|
||||
value={query}
|
||||
/>
|
||||
<div
|
||||
className="max-h-72 overflow-y-auto overscroll-contain p-1.5"
|
||||
ref={listRef}
|
||||
>
|
||||
{status === "loading" || searchPending ? (
|
||||
<PickerStatus
|
||||
icon="loading"
|
||||
message={query.trim() ? "Searching…" : "Loading branches…"}
|
||||
/>
|
||||
) : status === "error" ? (
|
||||
<PickerStatus message="Could not load branches.">
|
||||
<Button
|
||||
onClick={() => setReloadKey((current) => current + 1)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</PickerStatus>
|
||||
) : branches.length === 0 ? (
|
||||
<PickerStatus message="No branches found." />
|
||||
) : (
|
||||
branches.map((item) => (
|
||||
<Button
|
||||
className="w-full justify-start text-xs [content-visibility:auto]"
|
||||
key={item}
|
||||
onClick={() => {
|
||||
onBranchChange(item);
|
||||
onClose();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<GitBranch aria-hidden="true" className="size-3" />
|
||||
<span className="truncate">{item}</span>
|
||||
{branch === item ? (
|
||||
<Check aria-hidden="true" className="ml-auto size-3" />
|
||||
) : null}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
{status === "idle" && nextToken ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="px-3 py-2 text-center text-xs text-muted-foreground"
|
||||
ref={loadMoreRef}
|
||||
>
|
||||
{loadingMore ? "Loading more branches…" : null}
|
||||
</div>
|
||||
) : null}
|
||||
{loadMoreError ? (
|
||||
<Button
|
||||
aria-live="polite"
|
||||
className="w-full justify-start text-xs"
|
||||
onClick={() => void loadMore()}
|
||||
variant="ghost"
|
||||
>
|
||||
Could not load more branches — Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerStatus({
|
||||
children,
|
||||
icon,
|
||||
message,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
icon?: "loading";
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-h-20 flex-col items-center justify-center gap-2 px-3 py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{icon === "loading" ? (
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
<span>{message}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -55,13 +525,20 @@ function SearchInput({
|
||||
return (
|
||||
<div className="border-b border-border p-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
<Search className="size-3 shrink-0 text-muted-foreground" />
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<Input
|
||||
autoFocus
|
||||
aria-label={placeholder}
|
||||
autoComplete="off"
|
||||
className="h-auto flex-1 border-0 bg-transparent px-0 py-0 text-xs shadow-none focus-visible:ring-0"
|
||||
name={placeholder.toLowerCase().replaceAll(/[^a-z]+/g, "-")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
@@ -422,6 +899,20 @@ function BranchPicker({
|
||||
}
|
||||
|
||||
export function WelcomeWorkspaceControls({
|
||||
cloudEnabled,
|
||||
cloudControlsHidden = false,
|
||||
executionTarget,
|
||||
repoUrl,
|
||||
cloudBranch,
|
||||
signedIn,
|
||||
signingIn,
|
||||
onExecutionTargetChange,
|
||||
onCloudBranchChange,
|
||||
onListCloudRepositories,
|
||||
onListCloudBranches,
|
||||
onOpenExternalUrl,
|
||||
onRepoUrlChange,
|
||||
onSignIn,
|
||||
workspaceRoot,
|
||||
workspaces,
|
||||
onRefreshWorkspaces,
|
||||
@@ -432,6 +923,27 @@ export function WelcomeWorkspaceControls({
|
||||
onListGitBranches,
|
||||
onSwitchGitBranch,
|
||||
}: {
|
||||
cloudEnabled: boolean;
|
||||
/**
|
||||
* Hides the repo/branch pickers and sign-in button while the cloud
|
||||
* onboarding panel owns those calls-to-action.
|
||||
*/
|
||||
cloudControlsHidden?: boolean;
|
||||
executionTarget: "local" | "cloud";
|
||||
repoUrl: string;
|
||||
cloudBranch: string;
|
||||
onCloudBranchChange: (branch: string) => void;
|
||||
onListCloudRepositories: () => Promise<CloudRepositoryListResult>;
|
||||
onListCloudBranches: (
|
||||
repositoryId: number,
|
||||
options?: CloudBranchListOptions,
|
||||
) => Promise<CloudBranchListResult>;
|
||||
onOpenExternalUrl: (url: string) => Promise<void>;
|
||||
signedIn: boolean;
|
||||
signingIn: boolean;
|
||||
onExecutionTargetChange: (target: "local" | "cloud") => void;
|
||||
onRepoUrlChange: (repoUrl: string) => void;
|
||||
onSignIn: () => void | Promise<void>;
|
||||
workspaceRoot: string;
|
||||
workspaces: string[];
|
||||
onRefreshWorkspaces: () => Promise<void>;
|
||||
@@ -443,10 +955,26 @@ export function WelcomeWorkspaceControls({
|
||||
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
|
||||
onSwitchGitBranch: (branch: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
|
||||
const [openMenu, setOpenMenu] = useState<
|
||||
"workspace" | "branch" | "cloud-repository" | "cloud-branch" | null
|
||||
>(null);
|
||||
const [cloudRepositoryId, setCloudRepositoryId] = useState<number>();
|
||||
const [cloudDefaultBranch, setCloudDefaultBranch] = useState("");
|
||||
const isChatWorkspace =
|
||||
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const handleCloudRepositoriesLoaded = useCallback(
|
||||
(repositories: CloudRepositoryOption[]) => {
|
||||
const selected = repositories.find(
|
||||
(repository) =>
|
||||
normalizeCloudRepositoryUrl(repository.url) ===
|
||||
normalizeCloudRepositoryUrl(repoUrl),
|
||||
);
|
||||
setCloudRepositoryId(selected?.id);
|
||||
setCloudDefaultBranch(selected?.defaultBranch ?? "");
|
||||
},
|
||||
[repoUrl],
|
||||
);
|
||||
|
||||
// Close whichever menu is open when clicking outside the control row.
|
||||
useEffect(() => {
|
||||
@@ -464,39 +992,103 @@ export function WelcomeWorkspaceControls({
|
||||
}, [openMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2" ref={containerRef}>
|
||||
<WorkspacePicker
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onSelectChat={onSelectChat}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "workspace" ? null : "workspace",
|
||||
)
|
||||
}
|
||||
open={openMenu === "workspace"}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{/* Git is a developer affordance: a plain (non-git) folder gets no
|
||||
branch chrome at all instead of a confusing "No branch" chip.
|
||||
Pending discovery (null) is treated the same until it resolves. */}
|
||||
{!isChatWorkspace &&
|
||||
currentBranch !== null &&
|
||||
currentBranch !== "no-git" ? (
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) => (current === "branch" ? null : "branch"))
|
||||
}
|
||||
open={openMenu === "branch"}
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-center gap-2"
|
||||
ref={containerRef}
|
||||
>
|
||||
{cloudEnabled ? (
|
||||
<ExecutionTargetPicker
|
||||
executionTarget={executionTarget}
|
||||
onChange={(target) => {
|
||||
setOpenMenu(null);
|
||||
onExecutionTargetChange(target);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{cloudEnabled && executionTarget === "cloud" ? (
|
||||
cloudControlsHidden ? null : signedIn ? (
|
||||
<>
|
||||
<CloudRepositoryPicker
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListRepositories={onListCloudRepositories}
|
||||
onOpenExternalUrl={onOpenExternalUrl}
|
||||
onRepositoriesLoaded={handleCloudRepositoriesLoaded}
|
||||
onSelect={(repository) => {
|
||||
setCloudRepositoryId(repository.id);
|
||||
setCloudDefaultBranch(repository.defaultBranch);
|
||||
onRepoUrlChange(normalizeCloudRepositoryUrl(repository.url));
|
||||
onCloudBranchChange(repository.defaultBranch);
|
||||
}}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "cloud-repository" ? null : "cloud-repository",
|
||||
)
|
||||
}
|
||||
open={openMenu === "cloud-repository"}
|
||||
repoUrl={repoUrl}
|
||||
/>
|
||||
<CloudBranchPicker
|
||||
branch={cloudBranch}
|
||||
defaultBranch={cloudDefaultBranch}
|
||||
key={cloudRepositoryId ?? "no-repository"}
|
||||
onBranchChange={onCloudBranchChange}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListBranches={onListCloudBranches}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "cloud-branch" ? null : "cloud-branch",
|
||||
)
|
||||
}
|
||||
open={openMenu === "cloud-branch"}
|
||||
repositoryId={cloudRepositoryId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
disabled={signingIn}
|
||||
onClick={() => void onSignIn()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<LogIn className="size-3.5" />
|
||||
{signingIn ? "Waiting for browser..." : "Sign in to use Cloud"}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<WorkspacePicker
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
|
||||
onRefreshWorkspaces={onRefreshWorkspaces}
|
||||
onSelectChat={onSelectChat}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "workspace" ? null : "workspace",
|
||||
)
|
||||
}
|
||||
open={openMenu === "workspace"}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{!isChatWorkspace &&
|
||||
currentBranch !== null &&
|
||||
currentBranch !== "no-git" ? (
|
||||
<BranchPicker
|
||||
currentBranch={currentBranch}
|
||||
onClose={() => setOpenMenu(null)}
|
||||
onListGitBranches={onListGitBranches}
|
||||
onSwitchGitBranch={onSwitchGitBranch}
|
||||
onToggle={() =>
|
||||
setOpenMenu((current) =>
|
||||
current === "branch" ? null : "branch",
|
||||
)
|
||||
}
|
||||
open={openMenu === "branch"}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export function WorkspaceSelector({
|
||||
onPickWorkspaceDirectory,
|
||||
onCreateGitBranch,
|
||||
disabled = false,
|
||||
placement = "top",
|
||||
className,
|
||||
}: {
|
||||
/** Branch name, "no-git" for a non-repo folder, null while discovery is pending. */
|
||||
currentBranch: string | null;
|
||||
@@ -59,6 +61,8 @@ export function WorkspaceSelector({
|
||||
onPickWorkspaceDirectory?: (initialPath?: string) => Promise<string | null>;
|
||||
onCreateGitBranch?: (branchName: string) => Promise<boolean>;
|
||||
disabled?: boolean;
|
||||
placement?: "top" | "bottom";
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -250,7 +254,10 @@ export function WorkspaceSelector({
|
||||
? `Workspace ${workspaceName}, branch ${currentBranch}`
|
||||
: `Folder ${workspaceName}`
|
||||
}
|
||||
className="flex max-w-full min-w-0 items-center gap-1 h-auto px-1 py-0.5 hover:text-foreground transition-colors max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0 text-sm"
|
||||
className={cn(
|
||||
"flex h-auto max-w-full min-w-0 items-center gap-1 px-1 py-0.5 text-sm transition-colors hover:text-foreground max-[560px]:size-7 max-[560px]:justify-center max-[560px]:p-0",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || switching}
|
||||
id="git-branch-btn"
|
||||
onClick={() => {
|
||||
@@ -285,7 +292,7 @@ export function WorkspaceSelector({
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="top" sideOffset={6}>
|
||||
<TooltipContent align="end" side={placement} sideOffset={6}>
|
||||
{workspaceRoot || workspaceName}
|
||||
{hasGit ? ` / ${currentBranch}` : ""}
|
||||
</TooltipContent>
|
||||
@@ -307,7 +314,12 @@ export function WorkspaceSelector({
|
||||
setSearch("");
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-full right-0 z-50 mb-2 w-72 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 z-50 w-72 rounded-lg border border-border bg-popover shadow-xl",
|
||||
placement === "bottom" ? "top-full mt-2" : "bottom-full mb-2",
|
||||
)}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background px-2.5 py-1.5">
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
// @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 {
|
||||
GITHUB_INSTALL_POLL_INTERVAL_MS,
|
||||
GitHubConnectStep,
|
||||
} from "./onboarding-github-step";
|
||||
|
||||
const { invoke, openExternalUrl } = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl,
|
||||
}));
|
||||
|
||||
type IntegrationsMock = {
|
||||
list?: () => unknown;
|
||||
githubInstallUrl?: () => unknown;
|
||||
listGitHubRepositories?: () => unknown;
|
||||
};
|
||||
|
||||
function mockIntegrationsCommand(handlers: IntegrationsMock) {
|
||||
invoke.mockImplementation(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command !== "cline_integrations") {
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
}
|
||||
const operation = String(args?.operation);
|
||||
const handler = handlers[operation as keyof IntegrationsMock];
|
||||
if (!handler) {
|
||||
throw new Error(`unexpected operation: ${operation}`);
|
||||
}
|
||||
return handler();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("GitHubConnectStep", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invoke.mockReset();
|
||||
openExternalUrl.mockReset();
|
||||
openExternalUrl.mockResolvedValue(undefined);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === text,
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`button not found: ${text}`);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
async function render(onContinue = vi.fn()) {
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={onContinue} />);
|
||||
});
|
||||
return onContinue;
|
||||
}
|
||||
|
||||
it("continues silently when GitHub is already connected", async () => {
|
||||
mockIntegrationsCommand({ list: () => [{ provider: "github" }] });
|
||||
const onContinue = await render();
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues silently when the account is signed out", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => ({ signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" }),
|
||||
});
|
||||
const onContinue = await render();
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the connect card when GitHub is not connected", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = await render();
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
expect(container.textContent).toContain("Not connected");
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips without connecting", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = await render();
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the install URL, polls until connected, and lists repositories", async () => {
|
||||
let connected = false;
|
||||
mockIntegrationsCommand({
|
||||
list: () => (connected ? [{ provider: "github" }] : []),
|
||||
githubInstallUrl: () => ({
|
||||
url: "https://github.com/apps/cline/installations/new?state=abc",
|
||||
}),
|
||||
listGitHubRepositories: () => [
|
||||
{ id: 1, full_name: "cline/cline", private: false },
|
||||
{ id: 2, full_name: "cline/core-platform", private: true },
|
||||
],
|
||||
});
|
||||
const onContinue = await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(openExternalUrl).toHaveBeenCalledWith(
|
||||
"https://github.com/apps/cline/installations/new?state=abc",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Finish installing the Cline GitHub App in your browser",
|
||||
);
|
||||
|
||||
// First poll: still not installed.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Connected");
|
||||
|
||||
connected = true;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
expect(container.textContent).toContain("Connected");
|
||||
expect(container.textContent).toContain("Accessible repositories");
|
||||
expect(container.textContent).toContain("cline/cline");
|
||||
expect(container.textContent).toContain("cline/core-platform");
|
||||
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(onContinue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns to the connect state with an error when the install URL fails", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => {
|
||||
throw new Error("authentication required");
|
||||
},
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"Failed to start the GitHub connection",
|
||||
);
|
||||
expect(container.textContent).toContain("authentication required");
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("stops waiting when the browser round-trip is cancelled", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Finish installing");
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Cancel").click();
|
||||
});
|
||||
expect(container.textContent).not.toContain("Finish installing");
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
});
|
||||
|
||||
it("stops polling once cancelled instead of leaving the interval running", async () => {
|
||||
mockIntegrationsCommand({
|
||||
list: () => [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Cancel").click();
|
||||
});
|
||||
const callsAfterCancel = invoke.mock.calls.length;
|
||||
|
||||
// No further polls may fire after cancelling.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterCancel);
|
||||
});
|
||||
|
||||
it("stops polling and reports the signed-out session when the account expires", async () => {
|
||||
let signedOut = false;
|
||||
mockIntegrationsCommand({
|
||||
list: () =>
|
||||
signedOut ? { signedIn: false, code: "ACCOUNT_NOT_AUTHENTICATED" } : [],
|
||||
githubInstallUrl: () => ({ url: "https://github.com/install" }),
|
||||
});
|
||||
await render();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
buttonByText("Connect GitHub").click();
|
||||
});
|
||||
expect(container.textContent).toContain("Finish installing");
|
||||
|
||||
signedOut = true;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Back to the actionable connect state, not a permanent spinner.
|
||||
expect(container.textContent).not.toContain("Finish installing");
|
||||
expect(container.textContent).toContain("Your Cline account session ended");
|
||||
expect(buttonByText("Connect GitHub")).toBeDefined();
|
||||
|
||||
const callsAfterSignOut = invoke.mock.calls.length;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(GITHUB_INSTALL_POLL_INTERVAL_MS * 5);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterSignOut);
|
||||
});
|
||||
|
||||
it("checks integrations once even when the parent re-renders", async () => {
|
||||
mockIntegrationsCommand({ list: () => [] });
|
||||
const onContinue = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={onContinue} />);
|
||||
});
|
||||
const callsAfterMount = invoke.mock.calls.length;
|
||||
expect(callsAfterMount).toBe(1);
|
||||
|
||||
// A parent re-render passing a brand-new inline callback must not
|
||||
// re-trigger the initial check.
|
||||
await act(async () => {
|
||||
root.render(<GitHubConnectStep onContinue={() => onContinue()} />);
|
||||
});
|
||||
expect(invoke.mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
});
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { GitHubIcon } from "@cline/ui";
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type ClineGitHubRepository,
|
||||
fetchGitHubInstallUrl,
|
||||
findGitHubIntegration,
|
||||
listClineGitHubRepositories,
|
||||
listClineIntegrations,
|
||||
} from "@/lib/cline-integrations";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
|
||||
export const GITHUB_INSTALL_POLL_INTERVAL_MS = 3_000;
|
||||
|
||||
type GitHubStepPhase = "checking" | "connect" | "waiting" | "connected";
|
||||
|
||||
export function GitHubConnectStep({ onContinue }: { onContinue: () => void }) {
|
||||
const [phase, setPhase] = useState<GitHubStepPhase>("checking");
|
||||
const [connectError, setConnectError] = useState<string | null>(null);
|
||||
const [repos, setRepos] = useState<ClineGitHubRepository[] | null>(null);
|
||||
|
||||
const onContinueRef = useRef(onContinue);
|
||||
useEffect(() => {
|
||||
onContinueRef.current = onContinue;
|
||||
}, [onContinue]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listClineIntegrations();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result.status === "not-authenticated" ||
|
||||
findGitHubIntegration(result.integrations)
|
||||
) {
|
||||
onContinueRef.current();
|
||||
return;
|
||||
}
|
||||
setPhase("connect");
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPhase("connect");
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The install finishes in the external browser, which cannot navigate the
|
||||
// app back, so poll the integrations list until the installation lands.
|
||||
if (phase !== "waiting") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let inFlight = false;
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
const stop = () => {
|
||||
cancelled = true;
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const result = await listClineIntegrations();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (result.status === "not-authenticated") {
|
||||
// The account session ended mid-install. Nothing will ever
|
||||
// arrive, so stop polling instead of spinning forever.
|
||||
stop();
|
||||
setConnectError(
|
||||
"Your Cline account session ended. Sign in again to connect GitHub.",
|
||||
);
|
||||
setPhase("connect");
|
||||
return;
|
||||
}
|
||||
if (findGitHubIntegration(result.integrations)) {
|
||||
setPhase("connected");
|
||||
}
|
||||
} catch {
|
||||
// Transient failures keep polling; the user can cancel anytime.
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (inFlight) {
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
void poll();
|
||||
}, GITHUB_INSTALL_POLL_INTERVAL_MS);
|
||||
|
||||
// Covers unmount and every phase change, including the Cancel button.
|
||||
return stop;
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "connected") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
listClineGitHubRepositories()
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setRepos(result);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRepos([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setConnectError(null);
|
||||
setPhase("waiting");
|
||||
try {
|
||||
const url = await fetchGitHubInstallUrl();
|
||||
await openExternalUrl(url);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
setConnectError(`Failed to start the GitHub connection: ${reason}`);
|
||||
setPhase("connect");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (phase === "checking") {
|
||||
return (
|
||||
<output
|
||||
aria-label="Checking GitHub connection"
|
||||
className="flex items-center justify-center py-16"
|
||||
>
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
Connect GitHub
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Grant Cline access to your GitHub repositories to supercharge it with
|
||||
real-world context. You can always do this later from your dashboard.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-border/70 bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
|
||||
<GitHubIcon className="size-4" />
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-base font-semibold text-foreground">GitHub</p>
|
||||
{phase === "connected" ? (
|
||||
<Badge
|
||||
className="bg-primary/15 text-primary"
|
||||
variant="secondary"
|
||||
>
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not connected</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{phase === "connect" ? (
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => void connect()}
|
||||
type="button"
|
||||
>
|
||||
Connect GitHub
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{phase === "waiting" ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Finish installing the Cline GitHub App in your browser...
|
||||
</p>
|
||||
<button
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setPhase("connect")}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
{connectError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "connected" ? (
|
||||
<div className="mt-4 border-t border-border/70 pt-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Accessible repositories
|
||||
{repos ? (
|
||||
<span className="ml-2 font-normal normal-case">
|
||||
({repos.length})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{repos === null ? (
|
||||
<p className="mt-2 inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repositories...
|
||||
</p>
|
||||
) : repos.length > 0 ? (
|
||||
<ul className="mt-2 flex max-h-40 flex-col gap-1 overflow-y-auto pr-1">
|
||||
{repos.map((repo) => (
|
||||
<li
|
||||
className="flex items-center gap-2 text-sm text-foreground"
|
||||
key={repo.id ?? repo.full_name}
|
||||
>
|
||||
<Lock
|
||||
aria-hidden="true"
|
||||
className={
|
||||
repo.private
|
||||
? "size-3 shrink-0 text-muted-foreground"
|
||||
: "size-3 shrink-0 text-transparent"
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{repo.full_name ?? repo.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No repositories found. You may need to grant access in your
|
||||
GitHub App settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-center">
|
||||
{phase === "connected" ? (
|
||||
<Button
|
||||
className="h-11 w-full rounded-full text-base"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onContinue}
|
||||
type="button"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+37
@@ -195,6 +195,11 @@ describe("OnboardingView", () => {
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
// Connecting a Cline account routes through the GitHub integration step.
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
@@ -203,6 +208,30 @@ describe("OnboardingView", () => {
|
||||
).toBe("cline");
|
||||
});
|
||||
|
||||
it("skips the GitHub step silently when the integration is already connected", async () => {
|
||||
invoke.mockImplementation(async (command: string) => {
|
||||
if (command === "cline_account") {
|
||||
return { email: "dev@example.com", displayName: "Dev" };
|
||||
}
|
||||
if (command === "cline_integrations") {
|
||||
return [{ provider: "github" }];
|
||||
}
|
||||
if (command === "list_provider_catalog") {
|
||||
return { providers: [makeProvider()], settingsPath: "/tmp/p.json" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
await render();
|
||||
await act(async () => {
|
||||
buttonByText("Get started").click();
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText("Continue").click();
|
||||
});
|
||||
expect(container.textContent).not.toContain("Connect GitHub");
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
});
|
||||
|
||||
it("lets the user cancel a pending browser sign-in", async () => {
|
||||
await render();
|
||||
await act(async () => {
|
||||
@@ -281,6 +310,10 @@ describe("OnboardingView", () => {
|
||||
enabled: true,
|
||||
api_key: "cline_key_123",
|
||||
});
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(container.textContent).toContain("Your Cline account is connected");
|
||||
expect(
|
||||
@@ -374,6 +407,10 @@ describe("OnboardingView", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
expect(container.textContent).toContain("Connect GitHub");
|
||||
await act(async () => {
|
||||
buttonByText("Skip for now").click();
|
||||
});
|
||||
expect(container.textContent).toContain("You're all set");
|
||||
expect(
|
||||
parseModelSelectionStorage(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-github-step";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
@@ -40,7 +41,7 @@ import type { Provider } from "@/lib/provider-schema";
|
||||
|
||||
const CREATE_ACCOUNT_URL = "https://app.cline.bot";
|
||||
|
||||
export type OnboardingStep = "welcome" | "connect" | "done";
|
||||
export type OnboardingStep = "welcome" | "connect" | "github" | "done";
|
||||
|
||||
type OnboardingConnection =
|
||||
| { kind: "cline" }
|
||||
@@ -700,7 +701,8 @@ function DoneStep({
|
||||
|
||||
/**
|
||||
* Full-screen first-run experience: welcome, connect a model provider (Cline
|
||||
* account or bring-your-own API key), done. Rendered by the app shell while
|
||||
* account or bring-your-own API key), connect GitHub (Cline accounts only,
|
||||
* skipped when already connected), done. Rendered by the app shell while
|
||||
* onboarding has not been completed (see lib/onboarding.ts); `onComplete`
|
||||
* marks it completed and returns to the chat.
|
||||
*/
|
||||
@@ -726,10 +728,16 @@ export function OnboardingView({
|
||||
onBack={() => setStep("welcome")}
|
||||
onConnected={(nextConnection) => {
|
||||
setConnection(nextConnection);
|
||||
setStep("done");
|
||||
// The GitHub integration lives on the Cline account, so the
|
||||
// step only applies when one is connected.
|
||||
setStep(nextConnection.kind === "cline" ? "github" : "done");
|
||||
}}
|
||||
onSkip={onComplete}
|
||||
/>
|
||||
) : step === "github" ? (
|
||||
<OnboardingCard wide>
|
||||
<GitHubConnectStep onContinue={() => setStep("done")} />
|
||||
</OnboardingCard>
|
||||
) : (
|
||||
<DoneStep connection={connection} onFinish={onComplete} />
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,7 @@ const thread: SessionThread = {
|
||||
|
||||
const session: SessionHistoryItem = {
|
||||
sessionId: thread.id,
|
||||
environmentId: "local",
|
||||
status: "completed",
|
||||
provider: thread.provider,
|
||||
model: thread.model,
|
||||
@@ -165,6 +166,21 @@ describe("SessionsView table", () => {
|
||||
expect(container.querySelector('[aria-label="Favorited"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks cloud sessions and shows their repository", async () => {
|
||||
const cloudThread: SessionThread = {
|
||||
...thread,
|
||||
origin: "cloud",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
};
|
||||
const view = renderView({ threads: [cloudThread] });
|
||||
await view.render();
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label="Cloud session"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("https://github.com/cline/cline");
|
||||
});
|
||||
|
||||
it("opens a session on click but not while text is selected", async () => {
|
||||
const view = renderView({});
|
||||
await view.render();
|
||||
@@ -252,6 +268,43 @@ describe("SessionsView table", () => {
|
||||
|
||||
expect(view.loadAllSessions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("groups filter choices without prefixes and searches them", async () => {
|
||||
const view = renderView();
|
||||
await view.render();
|
||||
const filterButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Filter sessions"]',
|
||||
);
|
||||
await act(async () => {
|
||||
filterButton?.dispatchEvent(
|
||||
new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("Workspaces");
|
||||
expect(document.body.textContent).toContain("Providers");
|
||||
expect(document.body.textContent).not.toContain("provider:cline-pass");
|
||||
|
||||
const search = document.body.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Search session filters"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!search) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(search, "cline-pass");
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("cline-pass");
|
||||
expect(document.body.textContent).not.toContain("Workspaces");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionsView pagination", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
Cloud,
|
||||
Filter,
|
||||
Folder,
|
||||
GitFork,
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
sessionActivityTimestamp,
|
||||
type UseSessionHistoryResult,
|
||||
} from "@/hooks/use-session-history";
|
||||
import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories";
|
||||
import type { SessionHistoryItem } from "@/lib/session-history";
|
||||
import { sessionStatusColor, sessionStatusTone } from "@/lib/session-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -59,6 +61,19 @@ type SessionsViewProps = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const FILTER_CATEGORIES = [
|
||||
"Favorites",
|
||||
"Workspaces",
|
||||
"Statuses",
|
||||
"Providers",
|
||||
"Models",
|
||||
] as const;
|
||||
type FilterCategory = (typeof FILTER_CATEGORIES)[number];
|
||||
type SessionFilterDetail = {
|
||||
key: string;
|
||||
category: FilterCategory;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function modelLabel(thread: SessionThread): string {
|
||||
if (thread.provider && thread.model) {
|
||||
@@ -127,16 +142,42 @@ function tokensLabel(thread: SessionThread): string {
|
||||
function sessionFilterDetails(
|
||||
thread: SessionThread,
|
||||
session?: SessionHistoryItem,
|
||||
): string[] {
|
||||
): SessionFilterDetail[] {
|
||||
const workspacePath = session?.workspaceRoot || session?.cwd || "";
|
||||
const workspace = workspacePath ? basenamePath(workspacePath) : "";
|
||||
return [
|
||||
thread.pinned ? "favorite:yes" : undefined,
|
||||
workspace ? `workspace:${workspace}` : undefined,
|
||||
thread.status ? `status:${thread.status}` : undefined,
|
||||
thread.provider ? `provider:${thread.provider}` : undefined,
|
||||
thread.model ? `model:${thread.model}` : undefined,
|
||||
].filter((detail): detail is string => Boolean(detail));
|
||||
thread.pinned
|
||||
? { key: "favorite:yes", category: "Favorites", label: "Favorites" }
|
||||
: undefined,
|
||||
workspace
|
||||
? {
|
||||
key: `workspace:${workspace}`,
|
||||
category: "Workspaces",
|
||||
label: workspace,
|
||||
}
|
||||
: undefined,
|
||||
thread.status
|
||||
? {
|
||||
key: `status:${thread.status}`,
|
||||
category: "Statuses",
|
||||
label: thread.status,
|
||||
}
|
||||
: undefined,
|
||||
thread.provider
|
||||
? {
|
||||
key: `provider:${thread.provider}`,
|
||||
category: "Providers",
|
||||
label: thread.provider,
|
||||
}
|
||||
: undefined,
|
||||
thread.model
|
||||
? {
|
||||
key: `model:${thread.model}`,
|
||||
category: "Models",
|
||||
label: thread.model,
|
||||
}
|
||||
: undefined,
|
||||
].filter((detail): detail is SessionFilterDetail => Boolean(detail));
|
||||
}
|
||||
|
||||
function sortTimestamp(session?: SessionHistoryItem) {
|
||||
@@ -149,6 +190,7 @@ function sortTimestamp(session?: SessionHistoryItem) {
|
||||
|
||||
export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
|
||||
const [sortDirection, setSortDirection] = useState<"newest" | "oldest">(
|
||||
"newest",
|
||||
@@ -175,17 +217,31 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
requiresCompleteHistory,
|
||||
]);
|
||||
|
||||
const filterOptions = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
history.threads.flatMap((thread) =>
|
||||
sessionFilterDetails(thread, history.sessionById.get(thread.id)),
|
||||
),
|
||||
),
|
||||
).sort((a, b) => a.localeCompare(b)),
|
||||
[history.sessionById, history.threads],
|
||||
);
|
||||
const filterOptions = useMemo(() => {
|
||||
const options = new Map<string, SessionFilterDetail>();
|
||||
for (const thread of history.threads) {
|
||||
for (const detail of sessionFilterDetails(
|
||||
thread,
|
||||
history.sessionById.get(thread.id),
|
||||
)) {
|
||||
options.set(detail.key, detail);
|
||||
}
|
||||
}
|
||||
return [...options.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [history.sessionById, history.threads]);
|
||||
const groupedFilterOptions = useMemo(() => {
|
||||
const normalizedQuery = filterQuery.trim().toLowerCase();
|
||||
return FILTER_CATEGORIES.map((category) => ({
|
||||
category,
|
||||
options: filterOptions.filter(
|
||||
(option) =>
|
||||
option.category === category &&
|
||||
(!normalizedQuery ||
|
||||
option.label.toLowerCase().includes(normalizedQuery) ||
|
||||
category.toLowerCase().includes(normalizedQuery)),
|
||||
),
|
||||
})).filter((group) => group.options.length > 0);
|
||||
}, [filterOptions, filterQuery]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -194,7 +250,8 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
const session = history.sessionById.get(thread.id);
|
||||
const details = sessionFilterDetails(thread, session);
|
||||
const matchesFilters =
|
||||
selected.size === 0 || details.some((detail) => selected.has(detail));
|
||||
selected.size === 0 ||
|
||||
details.some((detail) => selected.has(detail.key));
|
||||
if (!matchesFilters) {
|
||||
return false;
|
||||
}
|
||||
@@ -206,6 +263,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
thread.codebase,
|
||||
thread.provider,
|
||||
thread.model,
|
||||
thread.repoUrl,
|
||||
session?.workspaceRoot,
|
||||
session?.cwd,
|
||||
]
|
||||
@@ -357,6 +415,7 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFilterQuery("");
|
||||
// Filter choices are derived from the loaded rows, so
|
||||
// complete the history as soon as the user opens this
|
||||
// menu. This keeps both the options and their results
|
||||
@@ -378,9 +437,20 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<Filter className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-72 w-72">
|
||||
<DropdownMenuContent align="end" className="max-h-96 w-72">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Filter sessions</DropdownMenuLabel>
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
aria-label="Search session filters"
|
||||
autoFocus
|
||||
className="h-8"
|
||||
onChange={(event) => setFilterQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder="Search filters…"
|
||||
value={filterQuery}
|
||||
/>
|
||||
</div>
|
||||
{sessionFilters.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setSessionFilters([])}>
|
||||
@@ -389,23 +459,31 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{filterOptions.length === 0 ? (
|
||||
{groupedFilterOptions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
No filters available
|
||||
{filterQuery
|
||||
? "No matching filters"
|
||||
: "No filters available"}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filterOptions.map((detail) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sessionFilters.includes(detail)}
|
||||
key={detail}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleFilter(detail, checked === true)
|
||||
}
|
||||
>
|
||||
<span className="truncate" title={detail}>
|
||||
{detail}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
groupedFilterOptions.map((group, groupIndex) => (
|
||||
<div key={group.category}>
|
||||
{groupIndex > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuLabel>{group.category}</DropdownMenuLabel>
|
||||
{group.options.map((detail) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sessionFilters.includes(detail.key)}
|
||||
key={detail.key}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleFilter(detail.key, checked === true)
|
||||
}
|
||||
>
|
||||
<span className="truncate" title={detail.label}>
|
||||
{detail.label}
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
@@ -568,6 +646,12 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
tone={sessionStatusTone(thread.status)}
|
||||
/>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.origin === "cloud" ? (
|
||||
<Cloud
|
||||
aria-label="Cloud session"
|
||||
className="size-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
{thread.pinned ? (
|
||||
<Star
|
||||
aria-label="Favorited"
|
||||
@@ -576,9 +660,20 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
<span className="truncate" title={workspace}>
|
||||
{workspace ? basenamePath(workspace) : "No workspace"}
|
||||
{thread.origin === "cloud" ? (
|
||||
<Cloud className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Folder className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="truncate"
|
||||
title={thread.repoUrl || workspace}
|
||||
>
|
||||
{thread.origin === "cloud"
|
||||
? thread.repoUrl || "Cloud repository"
|
||||
: workspace
|
||||
? basenamePath(workspace)
|
||||
: "No workspace"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
@@ -615,34 +710,46 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void history.setThreadPinned(
|
||||
thread.id,
|
||||
!thread.pinned,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
{thread.origin !== "cloud" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
void history.setThreadPinned(
|
||||
thread.id,
|
||||
!thread.pinned,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"size-4",
|
||||
thread.pinned && "fill-current",
|
||||
)}
|
||||
/>
|
||||
{thread.pinned ? "Unfavorite" : "Favorite"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{/* Cloud sessions support rename (PATCH title), matching
|
||||
the sidebar and chat header affordances. Provisioning
|
||||
placeholders have no server session to rename yet. */}
|
||||
{!isCloudProvisioningSessionId(thread.id) ? (
|
||||
<DropdownMenuItem onClick={() => startRename(thread)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{thread.origin !== "cloud" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void history.forkThread(thread.id)}
|
||||
>
|
||||
<GitFork className="size-4" />
|
||||
Fork
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
// Provisioning placeholders have no server session
|
||||
// to delete yet; the sidecar rejects the request.
|
||||
disabled={isCloudProvisioningSessionId(thread.id)}
|
||||
onClick={() => setDeleteCandidate(thread)}
|
||||
variant="destructive"
|
||||
>
|
||||
@@ -748,8 +855,9 @@ export function SessionsView({ activeSessionId, history }: SessionsViewProps) {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes "{deleteCandidate?.title ?? "this session"}" from
|
||||
local history.
|
||||
{deleteCandidate?.origin === "cloud"
|
||||
? `This deletes "${deleteCandidate?.title ?? "this session"}" and its cloud workspace.`
|
||||
: `This removes "${deleteCandidate?.title ?? "this session"}" from local history.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
+450
-24
@@ -9,38 +9,464 @@ import {
|
||||
ProviderListContent,
|
||||
} from "./provider-list-view";
|
||||
|
||||
const provider: Provider = {
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
models: 2,
|
||||
color: "#000",
|
||||
letter: "OL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{ id: "alpha", name: "Alpha" },
|
||||
{ id: "beta", name: "Beta" },
|
||||
],
|
||||
};
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
models: 1,
|
||||
color: "#000000",
|
||||
letter: "EL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "scribe_v2",
|
||||
name: "Scribe v2",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "eleven_turbo_v2_5",
|
||||
name: "Eleven Turbo v2.5",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["audio"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "groq",
|
||||
name: "Groq",
|
||||
models: 3,
|
||||
color: "#000000",
|
||||
letter: "GR",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "whisper-large-v3",
|
||||
name: "Whisper Large v3",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "whisper-large-v3-turbo",
|
||||
name: "Whisper Large v3 Turbo",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "llama-chat",
|
||||
name: "Llama Chat",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Google Gemini",
|
||||
models: 1,
|
||||
color: "#000000",
|
||||
letter: "GG",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "gemini-3.5-live-translate-preview",
|
||||
name: "Gemini 3.5 Live Translate Preview",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["audio", "text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProviderDetailContent models", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ProviderListContent voice input settings", () => {
|
||||
it("shows model and mode provider sections with a scrollable model list", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={vi.fn()}
|
||||
onVoiceOutputChange={vi.fn()}
|
||||
providers={providers}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Model Providers");
|
||||
expect(container.textContent).toContain("Mode Providers");
|
||||
expect(container.textContent).toContain("Scroll to view more providers");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Model provider list"]')?.className,
|
||||
).toContain("h-[80vh]");
|
||||
expect(
|
||||
container.querySelector('[data-slot="mode-providers"]')?.className,
|
||||
).toContain("h-[80vh]");
|
||||
expect(
|
||||
container.querySelector('[aria-label="Voice input provider"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Search model providers"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Search providers"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[aria-label="Voice input provider"]')
|
||||
?.closest("[class*='col-start-2']"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Toggle model providers"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[aria-label="Toggle mode providers"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
[...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Modes",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
it("hides mode providers while a model provider is selected", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={vi.fn()}
|
||||
onVoiceOutputChange={vi.fn()}
|
||||
providers={providers}
|
||||
selectedProviderId="openai"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Mode Providers");
|
||||
expect(
|
||||
[...container.querySelectorAll("button")].find(
|
||||
(button) => button.textContent === "Modes",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("focuses the always-visible provider search with Cmd+F or Ctrl+F", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={vi.fn()}
|
||||
onVoiceOutputChange={vi.fn()}
|
||||
providers={providers}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const cmdFind = new KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => window.dispatchEvent(cmdFind));
|
||||
const searchInput = container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Search model providers"]',
|
||||
);
|
||||
expect(cmdFind.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
|
||||
searchInput?.blur();
|
||||
const ctrlFind = new KeyboardEvent("keydown", {
|
||||
key: "F",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => window.dispatchEvent(ctrlFind));
|
||||
expect(ctrlFind.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
});
|
||||
|
||||
it("lets the user choose and clear the voice provider and model", async () => {
|
||||
const onVoiceInputChange = vi.fn();
|
||||
let selection: VoiceInputModeSettings | undefined = {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "scribe_v2",
|
||||
};
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={onVoiceInputChange}
|
||||
onVoiceOutputChange={vi.fn()}
|
||||
providers={providers}
|
||||
voiceInput={selection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const providerSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
);
|
||||
const modelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
);
|
||||
expect(providerSelect?.value).toBe("elevenlabs");
|
||||
expect(modelSelect?.value).toBe("scribe_v2");
|
||||
|
||||
await act(async () => {
|
||||
if (!providerSelect) return;
|
||||
providerSelect.value = "groq";
|
||||
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
});
|
||||
|
||||
selection = {
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3",
|
||||
};
|
||||
await render();
|
||||
const groqModelSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input model"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!groqModelSelect) return;
|
||||
groqModelSelect.value = "whisper-large-v3-turbo";
|
||||
groqModelSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "groq",
|
||||
modelId: "whisper-large-v3-turbo",
|
||||
});
|
||||
|
||||
const groqProviderSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice input provider"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!groqProviderSelect) return;
|
||||
groqProviderSelect.value = "";
|
||||
groqProviderSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceInputChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("lets the user configure a text-to-audio model and provider voice", async () => {
|
||||
const onVoiceOutputChange = vi.fn();
|
||||
let selection: VoiceOutputModeSettings | undefined;
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={vi.fn()}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={vi.fn()}
|
||||
onVoiceOutputChange={onVoiceOutputChange}
|
||||
providers={providers}
|
||||
voiceOutput={selection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const providerSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Voice output provider"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!providerSelect) return;
|
||||
providerSelect.value = "elevenlabs";
|
||||
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceOutputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "elevenlabs",
|
||||
modelId: "eleven_turbo_v2_5",
|
||||
});
|
||||
|
||||
selection = {
|
||||
providerId: "elevenlabs",
|
||||
modelId: "eleven_turbo_v2_5",
|
||||
};
|
||||
await render();
|
||||
const voiceInput = container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Voice output voice"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!voiceInput) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setValue?.call(voiceInput, "voice-123");
|
||||
voiceInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
voiceInput.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(onVoiceOutputChange).toHaveBeenLastCalledWith({
|
||||
providerId: "elevenlabs",
|
||||
modelId: "eleven_turbo_v2_5",
|
||||
voice: "voice-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the user configure a realtime audio model without hard-coding a voice", async () => {
|
||||
const onRealtimeVoiceChange = vi.fn();
|
||||
let selection: RealtimeVoiceModeSettings | undefined;
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderListContent
|
||||
onAddProvider={vi.fn()}
|
||||
onConfigure={vi.fn()}
|
||||
onRealtimeVoiceChange={onRealtimeVoiceChange}
|
||||
onToggle={vi.fn()}
|
||||
onVoiceInputChange={vi.fn()}
|
||||
onVoiceOutputChange={vi.fn()}
|
||||
providers={providers}
|
||||
realtimeVoice={selection}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await render();
|
||||
const providerSelect = container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Realtime voice provider"]',
|
||||
);
|
||||
await act(async () => {
|
||||
if (!providerSelect) return;
|
||||
providerSelect.value = "gemini";
|
||||
providerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onRealtimeVoiceChange).toHaveBeenLastCalledWith({
|
||||
providerId: "gemini",
|
||||
modelId: "gemini-3.5-live-translate-preview",
|
||||
});
|
||||
|
||||
selection = {
|
||||
providerId: "gemini",
|
||||
modelId: "gemini-3.5-live-translate-preview",
|
||||
};
|
||||
await render();
|
||||
expect(
|
||||
container.querySelector<HTMLSelectElement>(
|
||||
'[aria-label="Realtime voice model"]',
|
||||
)?.value,
|
||||
).toBe("gemini-3.5-live-translate-preview");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
'[aria-label="Realtime voice name"]',
|
||||
)?.placeholder,
|
||||
).toBe("Provider default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderDetailContent model capabilities", () => {
|
||||
it("shows image, audio, and reasoning icons beside supported models", async () => {
|
||||
const provider: Provider = {
|
||||
id: "capability-provider",
|
||||
name: "Capability Provider",
|
||||
models: 3,
|
||||
color: "#000000",
|
||||
letter: "CP",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{
|
||||
id: "audio-input",
|
||||
name: "Audio Input",
|
||||
inputModalities: ["audio"],
|
||||
outputModalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "audio-output",
|
||||
name: "Audio Output",
|
||||
inputModalities: ["text"],
|
||||
outputModalities: ["audio"],
|
||||
},
|
||||
{
|
||||
id: "reasoning-vision",
|
||||
name: "Reasoning Vision",
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
onBack={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
provider={provider}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelectorAll(
|
||||
'[role="img"][aria-label="Audio support"] .lucide-mic',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[role="img"][aria-label="Image support"] .lucide-image',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[role="img"][aria-label="Reasoning support"] .lucide-brain',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("persists favorites, sorts them first, and adds models", async () => {
|
||||
window.localStorage.clear();
|
||||
const onUpdateModels = vi.fn();
|
||||
const provider: Provider = {
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
models: 2,
|
||||
color: "#000",
|
||||
letter: "OL",
|
||||
enabled: true,
|
||||
modelList: [
|
||||
{ id: "alpha", name: "Alpha" },
|
||||
{ id: "beta", name: "Beta" },
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProviderDetailContent
|
||||
|
||||
+457
-151
@@ -20,7 +20,7 @@ import {
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -28,7 +28,10 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import { getProviderApiKeyUrl } from "@/lib/provider-key-urls";
|
||||
import {
|
||||
hasRealtimeVoiceTransport,
|
||||
isDedicatedTranscriptionModel,
|
||||
isRealtimeVoiceModel,
|
||||
isSpeechGenerationModel,
|
||||
supportsAudio,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type {
|
||||
@@ -36,7 +39,9 @@ import type {
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderSettingsUpdate,
|
||||
RealtimeVoiceModeSettings,
|
||||
VoiceInputSelection,
|
||||
VoiceOutputModeSettings,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -132,23 +137,41 @@ export function ProviderListContent({
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
onVoiceInputChange,
|
||||
onVoiceOutputChange = () => undefined,
|
||||
onRealtimeVoiceChange = () => undefined,
|
||||
selectedProviderId,
|
||||
variant = "page",
|
||||
voiceInput,
|
||||
voiceInputSaving = false,
|
||||
voiceOutput,
|
||||
voiceOutputSaving = false,
|
||||
realtimeVoice,
|
||||
realtimeVoiceSaving = false,
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
onVoiceInputChange: (selection: VoiceInputSelection | undefined) => void;
|
||||
onVoiceOutputChange?: (
|
||||
selection: VoiceOutputModeSettings | undefined,
|
||||
) => void;
|
||||
onRealtimeVoiceChange?: (
|
||||
selection: RealtimeVoiceModeSettings | undefined,
|
||||
) => void;
|
||||
selectedProviderId?: string | null;
|
||||
variant?: "page" | "panel";
|
||||
voiceInput?: VoiceInputSelection;
|
||||
voiceInputSaving?: boolean;
|
||||
voiceOutput?: VoiceOutputModeSettings;
|
||||
voiceOutputSaving?: boolean;
|
||||
realtimeVoice?: RealtimeVoiceModeSettings;
|
||||
realtimeVoiceSaving?: boolean;
|
||||
}) {
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const providerSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const voiceOutputInputId = useId();
|
||||
const realtimeVoiceInputId = useId();
|
||||
const enabledProviderCount = providers.filter(
|
||||
(provider) => provider.enabled,
|
||||
).length;
|
||||
@@ -159,6 +182,7 @@ export function ProviderListContent({
|
||||
)
|
||||
: providers;
|
||||
const isPanel = variant === "panel";
|
||||
const showModeProviders = !selectedProviderId;
|
||||
const voiceProviders = providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.map((provider) => ({
|
||||
@@ -170,19 +194,80 @@ export function ProviderListContent({
|
||||
(entry) => entry.provider.id === voiceInput?.providerId,
|
||||
);
|
||||
const selectedVoiceModels = selectedVoiceProvider?.models ?? [];
|
||||
const speechProviders = providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.map((provider) => ({
|
||||
provider,
|
||||
models: (provider.modelList ?? []).filter(isSpeechGenerationModel),
|
||||
}))
|
||||
.filter((entry) => entry.models.length > 0);
|
||||
const selectedSpeechProvider = speechProviders.find(
|
||||
(entry) => entry.provider.id === voiceOutput?.providerId,
|
||||
);
|
||||
const selectedSpeechModels = selectedSpeechProvider?.models ?? [];
|
||||
const realtimeProviders = providers
|
||||
.filter(
|
||||
(provider) => provider.enabled && hasRealtimeVoiceTransport(provider.id),
|
||||
)
|
||||
.map((provider) => ({
|
||||
provider,
|
||||
models: (provider.modelList ?? []).filter(isRealtimeVoiceModel),
|
||||
}))
|
||||
.filter((entry) => entry.models.length > 0);
|
||||
const selectedRealtimeProvider = realtimeProviders.find(
|
||||
(entry) => entry.provider.id === realtimeVoice?.providerId,
|
||||
);
|
||||
const selectedRealtimeModels = selectedRealtimeProvider?.models ?? [];
|
||||
const [voiceDraft, setVoiceDraft] = useState(voiceOutput?.voice ?? "");
|
||||
const [realtimeVoiceDraft, setRealtimeVoiceDraft] = useState(
|
||||
realtimeVoice?.voice ?? "",
|
||||
);
|
||||
useEffect(
|
||||
() => setVoiceDraft(voiceOutput?.voice ?? ""),
|
||||
[voiceOutput?.voice],
|
||||
);
|
||||
useEffect(
|
||||
() => setRealtimeVoiceDraft(realtimeVoice?.voice ?? ""),
|
||||
[realtimeVoice?.voice],
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleFindShortcut = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key.toLowerCase() !== "f" ||
|
||||
(!event.metaKey && !event.ctrlKey) ||
|
||||
event.altKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
providerSearchInputRef.current?.focus();
|
||||
providerSearchInputRef.current?.select();
|
||||
};
|
||||
window.addEventListener("keydown", handleFindShortcut);
|
||||
return () => window.removeEventListener("keydown", handleFindShortcut);
|
||||
}, []);
|
||||
const defaultVoiceForProvider = (providerId: string): string | undefined => {
|
||||
if (providerId === "gemini") return "Kore";
|
||||
if (providerId === "elevenlabs") return undefined;
|
||||
return "alloy";
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
"grid gap-x-8 py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
showModeProviders
|
||||
? "grid-cols-2 max-[1000px]:grid-cols-1"
|
||||
: "grid-cols-1",
|
||||
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-2xl",
|
||||
"order-1 mb-4 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -200,16 +285,6 @@ export function ProviderListContent({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
|
||||
<Button
|
||||
aria-label="Search providers"
|
||||
className="size-8 rounded-md"
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={providerSearchOpen ? "default" : "secondary"}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
|
||||
onClick={onAddProvider}
|
||||
@@ -221,153 +296,384 @@ export function ProviderListContent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-7 border-y py-4",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h2 className="text-[17px] font-semibold text-foreground">
|
||||
Voice input
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Choose the configured audio-to-text model used by the microphone
|
||||
in chat. Streaming models show text live; other models transcribe
|
||||
after recording stops.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
aria-label="Voice input provider"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={voiceInputSaving}
|
||||
onChange={(event) => {
|
||||
const providerId = event.target.value;
|
||||
if (!providerId) {
|
||||
onVoiceInputChange(undefined);
|
||||
return;
|
||||
}
|
||||
const entry = voiceProviders.find(
|
||||
(candidate) => candidate.provider.id === providerId,
|
||||
);
|
||||
const modelId = entry?.models[0]?.id;
|
||||
if (modelId) {
|
||||
onVoiceInputChange({ providerId, modelId });
|
||||
}
|
||||
}}
|
||||
value={selectedVoiceProvider?.provider.id ?? ""}
|
||||
>
|
||||
<option value="">Not configured</option>
|
||||
{voiceProviders.map(({ provider }) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<select
|
||||
aria-label="Voice input model"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
disabled={!selectedVoiceProvider || voiceInputSaving}
|
||||
onChange={(event) => {
|
||||
if (!selectedVoiceProvider || !event.target.value) return;
|
||||
onVoiceInputChange({
|
||||
providerId: selectedVoiceProvider.provider.id,
|
||||
modelId: event.target.value,
|
||||
});
|
||||
}}
|
||||
value={voiceInput?.modelId ?? ""}
|
||||
>
|
||||
{selectedVoiceModels.length === 0 ? (
|
||||
<option value="">Enable an audio provider first</option>
|
||||
) : null}
|
||||
{selectedVoiceModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
{model.operationModes?.includes("streaming")
|
||||
? " (Live)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{showModeProviders ? (
|
||||
<div
|
||||
className={cn(
|
||||
"order-3 col-start-2 row-start-1 row-span-3 h-[80vh] overflow-y-auto border-y py-4 max-[1000px]:col-start-1 max-[1000px]:row-start-4 max-[1000px]:row-span-1 max-[1000px]:mt-8",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
data-slot="mode-providers"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[24px] font-semibold leading-[1.15] text-foreground">
|
||||
Mode Providers
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configure providers used by voice modes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{providerSearchOpen ? (
|
||||
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-2xl")}>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
autoFocus
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
value={providerSearch}
|
||||
/>
|
||||
<div className="mb-3">
|
||||
<h2 className="text-[17px] font-semibold text-foreground">
|
||||
Voice input
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Choose the audio-to-text model used by the microphone in chat.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
aria-label="Voice input provider"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={voiceInputSaving}
|
||||
onChange={(event) => {
|
||||
const providerId = event.target.value;
|
||||
if (!providerId) return onVoiceInputChange(undefined);
|
||||
const modelId = voiceProviders.find(
|
||||
(entry) => entry.provider.id === providerId,
|
||||
)?.models[0]?.id;
|
||||
if (modelId) onVoiceInputChange({ providerId, modelId });
|
||||
}}
|
||||
value={selectedVoiceProvider?.provider.id ?? ""}
|
||||
>
|
||||
<option value="">Not configured</option>
|
||||
{voiceProviders.map(({ provider }) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<select
|
||||
aria-label="Voice input model"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
|
||||
disabled={!selectedVoiceProvider || voiceInputSaving}
|
||||
onChange={(event) =>
|
||||
selectedVoiceProvider &&
|
||||
event.target.value &&
|
||||
onVoiceInputChange({
|
||||
providerId: selectedVoiceProvider.provider.id,
|
||||
modelId: event.target.value,
|
||||
})
|
||||
}
|
||||
value={voiceInput?.modelId ?? ""}
|
||||
>
|
||||
{selectedVoiceModels.length === 0 ? (
|
||||
<option value="">Enable an audio provider first</option>
|
||||
) : null}
|
||||
{selectedVoiceModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
{model.operationModes?.includes("streaming")
|
||||
? " (Live)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-[17px] font-semibold text-foreground">
|
||||
Voice playback
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Choose a text-to-audio model and provider voice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
aria-label="Voice output provider"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
disabled={voiceOutputSaving}
|
||||
onChange={(event) => {
|
||||
const providerId = event.target.value;
|
||||
if (!providerId) return onVoiceOutputChange(undefined);
|
||||
const modelId = speechProviders.find(
|
||||
(entry) => entry.provider.id === providerId,
|
||||
)?.models[0]?.id;
|
||||
if (modelId) {
|
||||
const voice = defaultVoiceForProvider(providerId);
|
||||
onVoiceOutputChange({
|
||||
providerId,
|
||||
modelId,
|
||||
...(voice ? { voice } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={selectedSpeechProvider?.provider.id ?? ""}
|
||||
>
|
||||
<option value="">Not configured</option>
|
||||
{speechProviders.map(({ provider }) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<select
|
||||
aria-label="Voice output model"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
|
||||
disabled={!selectedSpeechProvider || voiceOutputSaving}
|
||||
onChange={(event) =>
|
||||
selectedSpeechProvider &&
|
||||
event.target.value &&
|
||||
onVoiceOutputChange({
|
||||
providerId: selectedSpeechProvider.provider.id,
|
||||
modelId: event.target.value,
|
||||
...(voiceOutput?.voice
|
||||
? { voice: voiceOutput.voice }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
value={voiceOutput?.modelId ?? ""}
|
||||
>
|
||||
{selectedSpeechModels.length === 0 ? (
|
||||
<option value="">
|
||||
Enable a text-to-audio provider first
|
||||
</option>
|
||||
) : null}
|
||||
{selectedSpeechModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
className="space-y-1.5 text-sm text-muted-foreground"
|
||||
htmlFor={voiceOutputInputId}
|
||||
>
|
||||
<span>Voice</span>
|
||||
<Input
|
||||
aria-label="Voice output voice"
|
||||
disabled={!selectedSpeechProvider || voiceOutputSaving}
|
||||
id={voiceOutputInputId}
|
||||
onBlur={() => {
|
||||
if (
|
||||
!selectedSpeechProvider ||
|
||||
!voiceOutput ||
|
||||
voiceDraft.trim() === (voiceOutput.voice ?? "")
|
||||
)
|
||||
return;
|
||||
onVoiceOutputChange({
|
||||
providerId: selectedSpeechProvider.provider.id,
|
||||
modelId: voiceOutput.modelId,
|
||||
...(voiceDraft.trim()
|
||||
? { voice: voiceDraft.trim() }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
onChange={(event) => setVoiceDraft(event.target.value)}
|
||||
placeholder={
|
||||
selectedSpeechProvider?.provider.id === "elevenlabs"
|
||||
? "ElevenLabs voice ID"
|
||||
: selectedSpeechProvider?.provider.id === "gemini"
|
||||
? "Kore"
|
||||
: "alloy"
|
||||
}
|
||||
value={voiceDraft}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
<div className="mb-3">
|
||||
<h2 className="text-[17px] font-semibold text-foreground">
|
||||
Realtime voice
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
Choose an audio-in/audio-out model for low-latency
|
||||
conversation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 max-[720px]:grid-cols-1">
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
aria-label="Realtime voice provider"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||
disabled={realtimeVoiceSaving}
|
||||
onChange={(event) => {
|
||||
const providerId = event.target.value;
|
||||
if (!providerId) return onRealtimeVoiceChange(undefined);
|
||||
const modelId = realtimeProviders.find(
|
||||
(entry) => entry.provider.id === providerId,
|
||||
)?.models[0]?.id;
|
||||
if (modelId)
|
||||
onRealtimeVoiceChange({ providerId, modelId });
|
||||
}}
|
||||
value={selectedRealtimeProvider?.provider.id ?? ""}
|
||||
>
|
||||
<option value="">Not configured</option>
|
||||
{realtimeProviders.map(({ provider }) => (
|
||||
<option key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<select
|
||||
aria-label="Realtime voice model"
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground disabled:opacity-50"
|
||||
disabled={!selectedRealtimeProvider || realtimeVoiceSaving}
|
||||
onChange={(event) =>
|
||||
selectedRealtimeProvider &&
|
||||
event.target.value &&
|
||||
onRealtimeVoiceChange({
|
||||
providerId: selectedRealtimeProvider.provider.id,
|
||||
modelId: event.target.value,
|
||||
...(realtimeVoice?.voice
|
||||
? { voice: realtimeVoice.voice }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
value={realtimeVoice?.modelId ?? ""}
|
||||
>
|
||||
{selectedRealtimeModels.length === 0 ? (
|
||||
<option value="">
|
||||
Enable OpenAI, Gemini, or Vercel AI Gateway first
|
||||
</option>
|
||||
) : null}
|
||||
{selectedRealtimeModels.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
className="space-y-1.5 text-sm text-muted-foreground"
|
||||
htmlFor={realtimeVoiceInputId}
|
||||
>
|
||||
<span>Voice</span>
|
||||
<Input
|
||||
aria-label="Realtime voice name"
|
||||
disabled={!selectedRealtimeProvider || realtimeVoiceSaving}
|
||||
id={realtimeVoiceInputId}
|
||||
onBlur={() => {
|
||||
if (
|
||||
!selectedRealtimeProvider ||
|
||||
!realtimeVoice ||
|
||||
realtimeVoiceDraft.trim() ===
|
||||
(realtimeVoice.voice ?? "")
|
||||
)
|
||||
return;
|
||||
onRealtimeVoiceChange({
|
||||
providerId: selectedRealtimeProvider.provider.id,
|
||||
modelId: realtimeVoice.modelId,
|
||||
...(realtimeVoiceDraft.trim()
|
||||
? { voice: realtimeVoiceDraft.trim() }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
onChange={(event) =>
|
||||
setRealtimeVoiceDraft(event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
selectedRealtimeProvider
|
||||
? "Provider default"
|
||||
: "Select a provider first"
|
||||
}
|
||||
value={realtimeVoiceDraft}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
isPanel ? "max-w-none" : "max-w-2xl",
|
||||
"order-2 col-start-1 row-start-2 mb-4",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-base text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 hover:bg-surface-hover-lighter",
|
||||
selectedProviderId === prov.id && "bg-surface-hover",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<p className="truncate text-lg font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="shrink-0 truncate font-mono text-xs text-muted-foreground">
|
||||
{prov.id}
|
||||
</p>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
ref={providerSearchInputRef}
|
||||
value={providerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"order-2 col-start-1 row-start-3 overflow-hidden rounded-md border shadow-inner",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<ScrollArea aria-label="Model provider list" className="h-[80vh]">
|
||||
<div>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-surface-hover-lighter",
|
||||
selectedProviderId === prov.id && "bg-surface-hover",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<p className="truncate text-[17px] font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="shrink-0 truncate font-mono text-xs text-muted-foreground">
|
||||
{prov.id}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
<div className="border-t bg-muted/60 px-3 py-1.5 text-center text-xs text-muted-foreground">
|
||||
Scroll to view more providers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
+178
@@ -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(
|
||||
"Connect from the environment selector beside the workspace picker.",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"v0 requires key-based or agent authentication; it cannot show an interactive password prompt.",
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
CloudCog,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
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 {
|
||||
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
|
||||
disabled={isBusy}
|
||||
onClick={startNewProfile}
|
||||
variant="outline"
|
||||
>
|
||||
<Plus />
|
||||
New host
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh remote environments"
|
||||
disabled={isLoading || isBusy}
|
||||
onClick={() => void loadProfiles()}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className={cn(isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
description="Save SSH hosts and verify access. Connect from the environment selector beside the workspace picker."
|
||||
icon={CloudCog}
|
||||
title="Remote environments"
|
||||
/>
|
||||
|
||||
{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-fit gap-4 py-5">
|
||||
<CardHeader className="px-5">
|
||||
<CardTitle>SSH hosts</CardTitle>
|
||||
<CardDescription>
|
||||
{profiles.length === 1
|
||||
? "1 configured environment"
|
||||
: `${profiles.length} configured environments`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 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">
|
||||
<CardTitle>
|
||||
{selectedProfile
|
||||
? `Edit ${selectedProfile.name}`
|
||||
: "Add SSH host"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
OpenSSH config aliases work in the host field. Explicit values
|
||||
here override matching SSH config values. v0 requires key-based or
|
||||
agent authentication; it cannot show an interactive password
|
||||
prompt.
|
||||
</CardDescription>
|
||||
</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>
|
||||
{hasSavedDestination ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new host to change the SSH host, user, or port.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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 ?? ""}
|
||||
/>
|
||||
</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">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Environment status</p>
|
||||
</div>
|
||||
{draft.id === activeProfileId ? (
|
||||
<Badge>Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-x-5 gap-y-2 max-[620px]:grid-cols-1">
|
||||
<StatusBadge
|
||||
label="Connection"
|
||||
value={selectedRuntime.connection}
|
||||
/>
|
||||
<StatusBadge label="SSH test" value={selectedRuntime.test} />
|
||||
<StatusBadge
|
||||
label="Cline bootstrap"
|
||||
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 flex-wrap items-center justify-between gap-3 border-t pt-5">
|
||||
<div>
|
||||
{draft.id ? (
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => setDeleteTarget(draft)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => void saveProfile()}
|
||||
variant="outline"
|
||||
>
|
||||
{busyAction?.action === "save" ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : null}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={() => void testProfile()}
|
||||
variant="outline"
|
||||
>
|
||||
{busyAction?.action === "test" ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : null}
|
||||
Test connection
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -22,19 +22,28 @@ import {
|
||||
readStoredAppIcon,
|
||||
setStoredAppIcon,
|
||||
} from "@/lib/app-icon";
|
||||
import {
|
||||
type AvatarOption,
|
||||
getSelectedAvatar,
|
||||
listAvatars,
|
||||
selectAvatar,
|
||||
setAvatarEnabled,
|
||||
} from "@/lib/avatar";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { resetOnboarding } from "@/lib/onboarding";
|
||||
import {
|
||||
invalidateProviderCatalogCache,
|
||||
notifyVoiceInputSettingsChanged,
|
||||
notifyModeSettingsChanged,
|
||||
publishProviderModels,
|
||||
} from "@/lib/provider-model-catalog";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderCatalogResponse,
|
||||
ProviderMode,
|
||||
ProviderModelsResponse,
|
||||
ProviderModeSettingsMap,
|
||||
ProviderModesSettings,
|
||||
ProviderSettingsUpdate,
|
||||
VoiceInputSelection,
|
||||
} from "@/lib/provider-schema";
|
||||
import {
|
||||
type HubAccent,
|
||||
@@ -56,6 +65,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";
|
||||
@@ -80,7 +90,22 @@ let providerCatalogCache: {
|
||||
providers: Provider[];
|
||||
fetchedAt: number;
|
||||
} | null = null;
|
||||
let voiceInputCache: VoiceInputSelection | undefined;
|
||||
let modeSettingsCache: ProviderModesSettings = {};
|
||||
|
||||
function removeProviderModes(
|
||||
modes: ProviderModesSettings,
|
||||
providerId: string,
|
||||
): { modes: ProviderModesSettings; removed: ProviderMode[] } {
|
||||
const next = { ...modes };
|
||||
const removed: ProviderMode[] = [];
|
||||
for (const mode of Object.keys(next) as ProviderMode[]) {
|
||||
if (next[mode]?.providerId === providerId) {
|
||||
delete next[mode];
|
||||
removed.push(mode);
|
||||
}
|
||||
}
|
||||
return { modes: next, removed };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Component
|
||||
@@ -118,10 +143,11 @@ export function SettingsView({
|
||||
null,
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
const [voiceInput, setVoiceInput] = useState<VoiceInputSelection | undefined>(
|
||||
() => voiceInputCache,
|
||||
);
|
||||
const [voiceInputSaving, setVoiceInputSaving] = useState(false);
|
||||
const [modeSettings, setModeSettings] =
|
||||
useState<ProviderModesSettings>(modeSettingsCache);
|
||||
const [savingModes, setSavingModes] = useState<
|
||||
Partial<Record<ProviderMode, boolean>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "Models") {
|
||||
@@ -154,7 +180,7 @@ export function SettingsView({
|
||||
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
|
||||
) {
|
||||
setProviders(providerCatalogCache.providers);
|
||||
setVoiceInput(voiceInputCache);
|
||||
setModeSettings(modeSettingsCache);
|
||||
setProvidersLoading(false);
|
||||
setProviderCatalogError(null);
|
||||
return;
|
||||
@@ -167,8 +193,11 @@ export function SettingsView({
|
||||
"list_provider_catalog",
|
||||
);
|
||||
setProvidersWithCache(payload.providers);
|
||||
voiceInputCache = payload.voiceInput;
|
||||
setVoiceInput(payload.voiceInput);
|
||||
const modes =
|
||||
payload.modes ??
|
||||
(payload.voiceInput ? { voiceInput: payload.voiceInput } : {});
|
||||
modeSettingsCache = modes;
|
||||
setModeSettings(modes);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProviderCatalogError(message);
|
||||
@@ -230,14 +259,17 @@ export function SettingsView({
|
||||
return p;
|
||||
}
|
||||
const nextEnabled = !p.enabled;
|
||||
const clearsVoiceInput =
|
||||
!nextEnabled && voiceInput?.providerId === id;
|
||||
const updatedModes = nextEnabled
|
||||
? { modes: modeSettings, removed: [] }
|
||||
: removeProviderModes(modeSettings, id);
|
||||
void persistProviderSettings(id, { enabled: nextEnabled }).then(
|
||||
(saved) => {
|
||||
if (saved && clearsVoiceInput) {
|
||||
voiceInputCache = undefined;
|
||||
setVoiceInput(undefined);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
if (saved && updatedModes.removed.length > 0) {
|
||||
modeSettingsCache = updatedModes.modes;
|
||||
setModeSettings(updatedModes.modes);
|
||||
for (const mode of updatedModes.removed) {
|
||||
notifyModeSettingsChanged(mode);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -245,27 +277,31 @@ export function SettingsView({
|
||||
}),
|
||||
);
|
||||
},
|
||||
[persistProviderSettings, setProvidersWithCache, voiceInput],
|
||||
[persistProviderSettings, setProvidersWithCache, modeSettings],
|
||||
);
|
||||
|
||||
const updateVoiceInput = useCallback(
|
||||
async (selection: VoiceInputSelection | undefined) => {
|
||||
setVoiceInputSaving(true);
|
||||
const updateModeSettings = useCallback(
|
||||
async <Mode extends ProviderMode>(
|
||||
mode: Mode,
|
||||
settings: ProviderModeSettingsMap[Mode] | undefined,
|
||||
) => {
|
||||
setSavingModes((current) => ({ ...current, [mode]: true }));
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
voiceInput?: VoiceInputSelection;
|
||||
}>("save_voice_input_settings", {
|
||||
provider: selection?.providerId,
|
||||
model: selection?.modelId,
|
||||
modes: ProviderModesSettings;
|
||||
}>("save_mode_settings", {
|
||||
mode,
|
||||
settings,
|
||||
});
|
||||
voiceInputCache = result.voiceInput;
|
||||
setVoiceInput(result.voiceInput);
|
||||
notifyVoiceInputSettingsChanged();
|
||||
const modes = result.modes ?? {};
|
||||
modeSettingsCache = modes;
|
||||
setModeSettings(modes);
|
||||
notifyModeSettingsChanged(mode);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to save voice input settings: ${message}`);
|
||||
window.alert(`Failed to save ${mode} settings: ${message}`);
|
||||
} finally {
|
||||
setVoiceInputSaving(false);
|
||||
setSavingModes((current) => ({ ...current, [mode]: false }));
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -462,12 +498,24 @@ export function SettingsView({
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
onRealtimeVoiceChange={(settings) =>
|
||||
void updateModeSettings("realtimeVoice", settings)
|
||||
}
|
||||
onVoiceInputChange={(settings) =>
|
||||
void updateModeSettings("voiceInput", settings)
|
||||
}
|
||||
onVoiceOutputChange={(settings) =>
|
||||
void updateModeSettings("voiceOutput", settings)
|
||||
}
|
||||
providers={providers}
|
||||
realtimeVoice={modeSettings.realtimeVoice}
|
||||
realtimeVoiceSaving={savingModes.realtimeVoice}
|
||||
selectedProviderId={selectedProvider.id}
|
||||
variant="panel"
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
voiceInput={modeSettings.voiceInput}
|
||||
voiceInputSaving={savingModes.voiceInput}
|
||||
voiceOutput={modeSettings.voiceOutput}
|
||||
voiceOutputSaving={savingModes.voiceOutput}
|
||||
/>
|
||||
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
|
||||
<ProviderDetailContent
|
||||
@@ -495,10 +543,22 @@ export function SettingsView({
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
onVoiceInputChange={(selection) => void updateVoiceInput(selection)}
|
||||
onRealtimeVoiceChange={(settings) =>
|
||||
void updateModeSettings("realtimeVoice", settings)
|
||||
}
|
||||
onVoiceInputChange={(settings) =>
|
||||
void updateModeSettings("voiceInput", settings)
|
||||
}
|
||||
onVoiceOutputChange={(settings) =>
|
||||
void updateModeSettings("voiceOutput", settings)
|
||||
}
|
||||
providers={providers}
|
||||
voiceInput={voiceInput}
|
||||
voiceInputSaving={voiceInputSaving}
|
||||
realtimeVoice={modeSettings.realtimeVoice}
|
||||
realtimeVoiceSaving={savingModes.realtimeVoice}
|
||||
voiceInput={modeSettings.voiceInput}
|
||||
voiceInputSaving={savingModes.voiceInput}
|
||||
voiceOutput={modeSettings.voiceOutput}
|
||||
voiceOutputSaving={savingModes.voiceOutput}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -523,6 +583,8 @@ export function SettingsView({
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent onOpenSession={onOpenSession} />
|
||||
) : activeNav === "Remote" ? (
|
||||
<RemoteEnvironmentsContent />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
@@ -576,6 +638,12 @@ function GeneralSettingsContent() {
|
||||
});
|
||||
const [appIconError, setAppIconError] = useState<string | null>(null);
|
||||
const appIconRequestRef = useRef(0);
|
||||
const [avatars, setAvatars] = useState<AvatarOption[]>([]);
|
||||
const [selectedAvatarId, setSelectedAvatarId] = useState("cline-bot");
|
||||
const [avatarEnabled, setAvatarEnabledState] = useState(true);
|
||||
const [avatarLoading, setAvatarLoading] = useState(true);
|
||||
const [avatarSaving, setAvatarSaving] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
@@ -588,8 +656,37 @@ function GeneralSettingsContent() {
|
||||
const [webSearchLoading, setWebSearchLoading] = useState(true);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [webSearchError, setWebSearchError] = useState<string | null>(null);
|
||||
const [cloudSessionsEnabled, setCloudSessionsEnabled] = useState(false);
|
||||
const [cloudSessionsLoading, setCloudSessionsLoading] = useState(true);
|
||||
const [cloudSessionsSaving, setCloudSessionsSaving] = useState(false);
|
||||
const [cloudSessionsError, setCloudSessionsError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
// The gate the composer actually uses. It can diverge from the stored
|
||||
// setting when the CLINE_CODE_CLOUD_AGENTS env override is set; without
|
||||
// surfacing that, the toggle silently appears to do nothing.
|
||||
const [cloudSessionsEffective, setCloudSessionsEffective] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
// Hard-wired on for the preview. If rollout control is ever needed,
|
||||
// gate this on a PostHog flag exposed through the sidecar's
|
||||
// get_feature_flags command (the sidecar's remote-flag evaluation
|
||||
// plumbing was removed with the old rollout gate; see the git history
|
||||
// of sidecar/feature-flags.ts for the hardened version).
|
||||
const cloudSessionsSettingVisible = true;
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null);
|
||||
|
||||
const refreshCloudSessionsEffective = useCallback(async () => {
|
||||
try {
|
||||
const flags = await desktopClient.invoke<{ cloudAgents?: boolean }>(
|
||||
"get_feature_flags",
|
||||
);
|
||||
setCloudSessionsEffective(Boolean(flags.cloudAgents));
|
||||
} catch {
|
||||
setCloudSessionsEffective(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => subscribeToAppFontSize(setFontSize), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -621,24 +718,50 @@ function GeneralSettingsContent() {
|
||||
setAutoUpdateError(null);
|
||||
setWebSearchLoading(true);
|
||||
setWebSearchError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
setWebSearchError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
setWebSearchLoading(false);
|
||||
}
|
||||
}, []);
|
||||
setCloudSessionsLoading(true);
|
||||
setCloudSessionsError(null);
|
||||
// Independent backends: load them concurrently so one slow call cannot
|
||||
// hold the other's toggle in its loading state.
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
setWebSearchError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
setWebSearchLoading(false);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
const desktopSettings = await desktopClient.invoke<{
|
||||
cloudSessionsEnabled: boolean;
|
||||
}>("get_desktop_settings");
|
||||
setCloudSessionsEnabled(
|
||||
Boolean(desktopSettings.cloudSessionsEnabled),
|
||||
);
|
||||
} catch (error) {
|
||||
setCloudSessionsError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setCloudSessionsLoading(false);
|
||||
}
|
||||
})(),
|
||||
refreshCloudSessionsEffective(),
|
||||
]);
|
||||
}, [refreshCloudSessionsEffective]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
@@ -647,6 +770,35 @@ function GeneralSettingsContent() {
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadGlobalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([listAvatars(), getSelectedAvatar()])
|
||||
.then(([avatars, selected]) => {
|
||||
if (cancelled) return;
|
||||
setAvatars(Array.isArray(avatars) ? avatars : []);
|
||||
if (selected && typeof selected.id === "string") {
|
||||
setSelectedAvatarId(selected.id);
|
||||
}
|
||||
if (selected && typeof selected.enabled === "boolean") {
|
||||
setAvatarEnabledState(selected.enabled);
|
||||
}
|
||||
setAvatarError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setAvatarError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAvatarLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateTelemetryOptOut = async (nextValue: boolean) => {
|
||||
const previousValue = telemetryOptOut;
|
||||
setTelemetryOptOut(nextValue);
|
||||
@@ -707,6 +859,26 @@ function GeneralSettingsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateCloudSessionsEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = cloudSessionsEnabled;
|
||||
setCloudSessionsEnabled(nextValue);
|
||||
setCloudSessionsSaving(true);
|
||||
setCloudSessionsError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<{
|
||||
cloudSessionsEnabled: boolean;
|
||||
}>("set_cloud_sessions_enabled", { cloud_sessions_enabled: nextValue });
|
||||
setCloudSessionsEnabled(Boolean(settings.cloudSessionsEnabled));
|
||||
await refreshCloudSessionsEffective();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCloudSessionsEnabled(previousValue);
|
||||
setCloudSessionsError(message);
|
||||
} finally {
|
||||
setCloudSessionsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
@@ -747,6 +919,36 @@ function GeneralSettingsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateAvatar = async (nextId: string) => {
|
||||
const previousId = selectedAvatarId;
|
||||
setSelectedAvatarId(nextId);
|
||||
setAvatarSaving(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
await selectAvatar(nextId);
|
||||
} catch (error) {
|
||||
setSelectedAvatarId(previousId);
|
||||
setAvatarError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setAvatarSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAvatarEnabled = async (enabled: boolean) => {
|
||||
const previousEnabled = avatarEnabled;
|
||||
setAvatarEnabledState(enabled);
|
||||
setAvatarSaving(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
await setAvatarEnabled(enabled);
|
||||
} catch (error) {
|
||||
setAvatarEnabledState(previousEnabled);
|
||||
setAvatarError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setAvatarSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// resetOnboarding dispatches ONBOARDING_RESET_EVENT, which the app shell
|
||||
// listens for to re-enter the first-run flow immediately.
|
||||
const replayOnboarding = () => {
|
||||
@@ -896,6 +1098,51 @@ function GeneralSettingsContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Show desktop avatar
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display the selected avatar as a desktop overlay.
|
||||
</p>
|
||||
{avatarError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update desktop avatar: {avatarError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Show desktop avatar"
|
||||
checked={avatarEnabled}
|
||||
disabled={avatarLoading || avatarSaving}
|
||||
onCheckedChange={(checked) => void updateAvatarEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-20 items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Desktop avatar
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose Cline Bot, Mom, or a v2 avatar installed under
|
||||
{" ~/.cline/avatars/<avatar-name>"}.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Desktop avatar"
|
||||
className="h-9 min-w-44 rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
disabled={avatarLoading || avatarSaving}
|
||||
onChange={(event) => void updateAvatar(event.target.value)}
|
||||
value={selectedAvatarId}
|
||||
>
|
||||
{avatars.map((avatar) => (
|
||||
<option key={avatar.id} value={avatar.id}>
|
||||
{avatar.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -941,6 +1188,46 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
{cloudSessionsSettingVisible ? (
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
Cloud sessions
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-primary">
|
||||
Preview
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run Cline on your GitHub repositories in secure cloud sandboxes.
|
||||
Adds a Cloud option to the new-session composer. Requires a
|
||||
Cline account with GitHub connected.
|
||||
</p>
|
||||
{cloudSessionsError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update cloud sessions setting: {cloudSessionsError}
|
||||
</p>
|
||||
) : null}
|
||||
{cloudSessionsEffective !== null &&
|
||||
!cloudSessionsLoading &&
|
||||
cloudSessionsEffective !== cloudSessionsEnabled ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Cloud sessions are currently{" "}
|
||||
{cloudSessionsEffective ? "enabled" : "disabled"} by the
|
||||
CLINE_CODE_CLOUD_AGENTS environment override, which takes
|
||||
precedence over this setting.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Cloud sessions"
|
||||
checked={cloudSessionsEnabled}
|
||||
disabled={cloudSessionsLoading || cloudSessionsSaving}
|
||||
onCheckedChange={(checked) =>
|
||||
void updateCloudSessionsEnabled(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">Telemetry</p>
|
||||
|
||||
@@ -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.";
|
||||
@@ -19,8 +22,11 @@ export const OAUTH_MANAGED_PROVIDERS = new Set([
|
||||
|
||||
export const DEFAULT_CHAT_CONFIG: ChatSessionConfig = {
|
||||
sessionId: undefined,
|
||||
executionTarget: "local",
|
||||
repoUrl: undefined,
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
environmentId: LOCAL_WORKSPACE_ENVIRONMENT_ID,
|
||||
provider: "cline",
|
||||
model: CLINE_DEFAULT_MODEL_ID,
|
||||
apiKey: process.env.CLINE_API_KEY || "",
|
||||
@@ -35,9 +41,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] ??
|
||||
@@ -55,6 +61,7 @@ export function getInitialChatConfig(): ChatSessionConfig {
|
||||
|
||||
return {
|
||||
...DEFAULT_CHAT_CONFIG,
|
||||
environmentId,
|
||||
provider,
|
||||
model,
|
||||
workspaceRoot: workspaceSelection.lastWorkspace,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import { resolveCredentialError } from "./helpers";
|
||||
|
||||
const CLOUD_CONFIG: ChatSessionConfig = {
|
||||
executionTarget: "cloud",
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
apiKey: "",
|
||||
workspaceRoot: "",
|
||||
cwd: "",
|
||||
repoUrl: "https://github.com/cline/cline",
|
||||
} as ChatSessionConfig;
|
||||
|
||||
describe("resolveCredentialError (cloud)", () => {
|
||||
it("accepts a valid HTTPS GitHub URL for a new session", () => {
|
||||
expect(resolveCredentialError(CLOUD_CONFIG)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a partial URL even though the picker never confirmed it", () => {
|
||||
const error = resolveCredentialError({
|
||||
...CLOUD_CONFIG,
|
||||
repoUrl: "https://exa",
|
||||
});
|
||||
expect(error).toMatch(/valid HTTPS GitHub repository URL/);
|
||||
});
|
||||
|
||||
it("rejects SSH and non-GitHub URLs", () => {
|
||||
for (const repoUrl of [
|
||||
"git@github.com:cline/cline.git",
|
||||
"https://gitlab.com/cline/cline",
|
||||
"http://github.com/cline/cline",
|
||||
]) {
|
||||
expect(resolveCredentialError({ ...CLOUD_CONFIG, repoUrl })).toMatch(
|
||||
/valid HTTPS GitHub repository URL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require a repo URL when sending into an existing session", () => {
|
||||
expect(
|
||||
resolveCredentialError(
|
||||
{ ...CLOUD_CONFIG, repoUrl: "" },
|
||||
{ hasActiveSession: true },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still requires the Cline provider for existing sessions", () => {
|
||||
expect(
|
||||
resolveCredentialError(
|
||||
{ ...CLOUD_CONFIG, provider: "anthropic" },
|
||||
{ hasActiveSession: true },
|
||||
),
|
||||
).toMatch(/Cline provider/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user