diff --git a/.cline/skills/publish-cli/SKILL.md b/.cline/skills/publish-cli/SKILL.md
index 2b9c9fe3d3..b1587c13d9 100644
--- a/.cline/skills/publish-cli/SKILL.md
+++ b/.cline/skills/publish-cli/SKILL.md
@@ -25,7 +25,8 @@ The skill should guide the user through one release preparation flow, then offer
- Local publish helper: `bun release cli`.
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
-- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
+- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag. It is manual dispatch only; there is no cron.
+- Every CI publish (CLI main and nightly, SDK, UI) waits for a reviewer to approve the run in the `PublishNPM` GitHub environment, and the npm trusted publishers require that environment. The run sits in `waiting` until approved; that is not a hang. Hand the user the run URL so they can click "Review deployments"; never approve it via the API.
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
- Always ask before pushing commits or tags.
diff --git a/.cline/skills/publish-ui/SKILL.md b/.cline/skills/publish-ui/SKILL.md
index adfa41f393..4e7e6b7d21 100644
--- a/.cline/skills/publish-ui/SKILL.md
+++ b/.cline/skills/publish-ui/SKILL.md
@@ -22,8 +22,8 @@ Release `@cline/ui` independently from the Cline SDK runtime packages.
- The workflow runs only by manual dispatch. Every release attempt runs the UI
quality checks before publishing and requires `confirm_publish=publish` from
`main`.
-- The publish job and npm trust relationship use the protected `Publish`
- environment.
+- The publish job and npm trust relationship use the protected `PublishNPM`
+ environment; the run waits for a reviewer to approve it before publishing.
- Every npm publication needs a new semver version; npm versions are immutable.
- Always ask before pushing commits, triggering the publish workflow, changing
npm trust settings, or running a local publish command.
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index aad3c0955a..4756fd563b 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,2 @@
-/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
-/README.md @saoudrizwan @juanpflores
+/.github/ @saoudrizwan
+/README.md @saoudrizwan
diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml
index a89ee2775f..4c7339a9ad 100644
--- a/.github/workflows/cli-publish.yml
+++ b/.github/workflows/cli-publish.yml
@@ -1,8 +1,8 @@
name: cli-publish
+# Manual dispatch only, nightly included. Every publish waits for approval on
+# the PublishNPM environment, so a cron run would just sit waiting every day.
on:
- schedule:
- - cron: "0 12 * * *"
workflow_dispatch:
inputs:
publish_target:
@@ -48,6 +48,11 @@ jobs:
github.event.inputs.publish_target == 'main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
+ # npm trusted publishing for every CLI package requires this environment,
+ # so a publish only happens after a PublishNPM reviewer approves the run.
+ # The environment's deployment-branch policy (main only) is the enforced
+ # branch gate; the `if` above is advisory.
+ environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -305,13 +310,13 @@ jobs:
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
- (
- github.event_name == 'schedule' ||
- (
- github.event_name == 'workflow_dispatch' &&
- github.event.inputs.publish_target == 'nightly'
- )
- )
+ github.event_name == 'workflow_dispatch' &&
+ github.event.inputs.publish_target == 'nightly'
+ # npm trusted publishing for every CLI package requires this environment,
+ # so a publish only happens after a PublishNPM reviewer approves the run.
+ # The environment's deployment-branch policy (main only) is the enforced
+ # branch gate; the `if` above is advisory.
+ environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -323,7 +328,7 @@ jobs:
- name: Check for recent commits
id: check_commits
env:
- FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
+ FORCE_PUBLISH: ${{ github.event.inputs.force_nightly_publish == 'true' }}
run: |
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_nightly_publish enabled, proceeding with publish"
diff --git a/.github/workflows/desktop-test.yml b/.github/workflows/desktop-test.yml
index 788c07ff63..0b08ced8ab 100644
--- a/.github/workflows/desktop-test.yml
+++ b/.github/workflows/desktop-test.yml
@@ -86,12 +86,12 @@ jobs:
shell: pwsh
run: |
$zip = Join-Path $env:RUNNER_TEMP 'nsis.zip'
- Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip' -OutFile $zip
- if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D') {
+ Invoke-WebRequest 'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.13/nsis-3.13.zip' -OutFile $zip
+ if ((Get-FileHash $zip -Algorithm SHA1).Hash -ne 'DB14F8AF2EA346B786C6AE19343F61AAF5E09B8E') {
throw 'NSIS distribution checksum mismatch'
}
Expand-Archive $zip -DestinationPath $env:RUNNER_TEMP
- "MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.11/makensis.exe" >> $env:GITHUB_ENV
+ "MAKENSIS_PATH=$env:RUNNER_TEMP/nsis-3.13/makensis.exe" >> $env:GITHUB_ENV
- name: Test install and uninstall process cleanup
run: bun run test:windows-installer
diff --git a/.github/workflows/sdk-publish.yml b/.github/workflows/sdk-publish.yml
index 31e2877069..13c1e70a0c 100644
--- a/.github/workflows/sdk-publish.yml
+++ b/.github/workflows/sdk-publish.yml
@@ -1,5 +1,7 @@
name: sdk-publish
+# Manual dispatch only. Every publish (nightly included) waits for approval on
+# the PublishNPM environment, so a cron run would just sit waiting every day.
on:
workflow_dispatch:
inputs:
@@ -20,9 +22,6 @@ on:
description: 'Required when channel=latest. Type "publish" to confirm release publish.'
required: false
type: string
- schedule:
- # Run nightly at 2:00 AM UTC
- - cron: "0 2 * * *"
defaults:
run:
@@ -51,6 +50,9 @@ jobs:
!endsWith(github.actor, '[bot]')
)
)
+ # npm trusted publishing for the SDK packages requires this environment,
+ # so a publish only happens after a PublishNPM reviewer approves the run.
+ environment: PublishNPM
runs-on: ubuntu-latest
steps:
@@ -60,15 +62,8 @@ jobs:
- name: Determine publish channel
id: channel
env:
- EVENT_NAME: ${{ github.event_name }}
INPUT_CHANNEL: ${{ inputs.channel }}
- run: |
- # Default to nightly for scheduled runs
- if [ "$EVENT_NAME" = "schedule" ]; then
- echo "channel=nightly" >> $GITHUB_OUTPUT
- else
- echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
- fi
+ run: echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
- name: Check for recent commits
id: check_commits
diff --git a/.github/workflows/ui-publish.yml b/.github/workflows/ui-publish.yml
index 8d4b3cadc6..17dc7cf1ba 100644
--- a/.github/workflows/ui-publish.yml
+++ b/.github/workflows/ui-publish.yml
@@ -105,6 +105,9 @@ jobs:
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
needs: quality
+ # npm trusted publishing for @cline/ui requires this environment, so a
+ # publish only happens after a PublishNPM reviewer approves the run.
+ environment: PublishNPM
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/README.md b/README.md
index b3ebd4840b..1b870599c7 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Cline
-The open source coding agent in your IDE, terminal, & desktop.
+The open source coding agent in your IDE, terminal, & desktop!
diff --git a/apps/cli/DISTRIBUTION.md b/apps/cli/DISTRIBUTION.md
index 3d698c59f8..d28e23c009 100644
--- a/apps/cli/DISTRIBUTION.md
+++ b/apps/cli/DISTRIBUTION.md
@@ -129,10 +129,11 @@ The GitHub workflow at `.github/workflows/cli-publish.yml` automates publishing:
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
-- Nightly releases run on a schedule or manually with `publish_target=nightly`.
+- Nightly releases are manual only, with `publish_target=nightly`. There is no schedule.
+- Every run, main or nightly, waits for a reviewer to approve it in the `PublishNPM` GitHub environment.
- Nightly releases publish `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skip if there were no commits in the last 24 hours unless forced.
-CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package before relying on the workflow.
+CI publishing uses npm trusted publishing. Configure npm trusted publishers for the `cline` wrapper package and every platform package, with the environment set to `PublishNPM`, before relying on the workflow.
## How It Works Under the Hood
diff --git a/apps/cli/src/acp/acpAgent.ts b/apps/cli/src/acp/acpAgent.ts
index 6f041e17a2..1469f2c9cc 100644
--- a/apps/cli/src/acp/acpAgent.ts
+++ b/apps/cli/src/acp/acpAgent.ts
@@ -14,6 +14,7 @@ import type {
PromptRequest,
PromptResponse,
SessionConfigOption,
+ SessionConfigSelectOption,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
@@ -47,11 +48,8 @@ import {
authenticateAcpProvider,
isAcpAuthMethodId,
} from "./auth";
-import {
- AUTO_APPROVE_CONFIG_ID,
- buildAutoApproveConfigOption,
- parseAutoApproveValue,
-} from "./auto-approve";
+import { AUTO_APPROVE_CONFIG_ID, parseAutoApproveValue } from "./auto-approve";
+import { buildConfigOptions } from "./config-options";
import {
buildOrganizationConfigOption,
fetchClineOrganizations,
@@ -236,13 +234,15 @@ export class AcpAgent implements Agent {
availableModels,
currentModelId: defaultModelId,
},
- configOptions: [
- await buildProviderConfigOption(providerId),
- buildModelConfigOption(defaultModelId, providerModels),
- buildModeConfigOption(defaultMode),
- buildAutoApproveConfigOption(this.defaultAutoApproveTools),
- ...(organizationOption ? [organizationOption] : []),
- ],
+ configOptions: buildConfigOptions({
+ currentProviderId: providerId,
+ providerOptions: await getProviderOptions(),
+ currentModelId: defaultModelId,
+ providerModels,
+ currentMode: defaultMode,
+ autoApproveTools: this.defaultAutoApproveTools,
+ organizationOption,
+ }),
};
}
@@ -562,13 +562,13 @@ export class AcpAgent implements Agent {
);
}
- const configOptions = await buildAllConfigOptions(session);
const organizationOption = await this.getOrganizationConfigOption(
session.currentProviderId,
);
- if (organizationOption) {
- configOptions.push(organizationOption);
- }
+ const configOptions = await buildAllConfigOptions(
+ session,
+ organizationOption,
+ );
sendConfigOptionUpdate(this.conn, params.sessionId, configOptions);
return { configOptions };
}
@@ -857,10 +857,8 @@ function toAcpPromptError(error: Error): RequestError {
: RequestError.internalError({ message }, message);
}
-async function buildProviderConfigOption(
- currentProviderId: string,
-): Promise {
- const options = await Promise.all(
+async function getProviderOptions(): Promise {
+ return Promise.all(
ACP_AUTH_METHODS.map(async (m) => {
const provider = await Llms.getProvider(m.id);
return {
@@ -869,75 +867,28 @@ async function buildProviderConfigOption(
};
}),
);
- return {
- type: "select",
- id: "provider",
- name: "Provider",
- description: "The authentication provider to use",
- category: "model",
- currentValue: currentProviderId,
- options,
- };
-}
-
-function buildModelConfigOption(
- currentModelId: string,
- providerModels: Record,
-): SessionConfigOption {
- return {
- type: "select",
- id: "model",
- name: "Model",
- category: "model",
- currentValue: currentModelId,
- options: Object.entries(providerModels).map(([modelId, info]) => ({
- value: modelId,
- name: info.name ?? modelId,
- description: info.description,
- })),
- };
-}
-
-function buildModeConfigOption(currentMode: string): SessionConfigOption {
- return {
- type: "select",
- id: "mode",
- name: "Session Mode",
- description: "Controls whether the agent can modify files",
- category: "mode",
- currentValue: currentMode,
- options: [
- {
- value: "plan",
- name: "Plan",
- description:
- "Explore the codebase and plan changes without modifying files",
- },
- {
- value: "act",
- name: "Act",
- description: "Make changes to the codebase",
- },
- ],
- };
}
async function buildAllConfigOptions(
session: SessionState,
+ organizationOption?: SessionConfigOption,
): Promise {
- const [providerOption, providerModels] = await Promise.all([
- buildProviderConfigOption(session.currentProviderId),
+ const [providerOptions, providerModels] = await Promise.all([
+ getProviderOptions(),
Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
),
]);
- return [
- providerOption,
- buildModelConfigOption(session.currentModelId, providerModels),
- buildModeConfigOption(session.currentMode),
- buildAutoApproveConfigOption(session.autoApproveTools),
- ];
+ return buildConfigOptions({
+ currentProviderId: session.currentProviderId,
+ providerOptions,
+ currentModelId: session.currentModelId,
+ providerModels,
+ currentMode: session.currentMode,
+ autoApproveTools: session.autoApproveTools,
+ organizationOption,
+ });
}
function extractTextFromContentBlocks(blocks: ContentBlock[]): string {
diff --git a/apps/cli/src/acp/config-options.test.ts b/apps/cli/src/acp/config-options.test.ts
new file mode 100644
index 0000000000..0d3ebe794e
--- /dev/null
+++ b/apps/cli/src/acp/config-options.test.ts
@@ -0,0 +1,110 @@
+import type { SessionConfigOption } from "@agentclientprotocol/sdk";
+import { describe, expect, it } from "vitest";
+import {
+ buildConfigOptions,
+ buildProviderConfigOption,
+ MODEL_CONFIG_CATEGORY,
+ PROVIDER_CONFIG_CATEGORY,
+} from "./config-options";
+
+// ACP reserves every category name that does not start with `_` for the spec.
+const ACP_RESERVED_CATEGORIES = [
+ "mode",
+ "model",
+ "model_config",
+ "thought_level",
+];
+
+const providerModels = {
+ "anthropic/claude-sonnet-5.5": { name: "Claude Sonnet 5.5" },
+ "openrouter/free": { name: "Free" },
+};
+
+const providerOptions = [
+ { value: "cline", name: "Cline" },
+ { value: "openrouter", name: "OpenRouter" },
+];
+
+const organizationOption: SessionConfigOption = {
+ type: "select",
+ id: "organization",
+ name: "Account",
+ category: "_account",
+ currentValue: "personal",
+ options: [{ value: "personal", name: "Personal" }],
+};
+
+describe("buildProviderConfigOption", () => {
+ it("builds the provider selector from the current provider", () => {
+ const option = buildProviderConfigOption("cline", providerOptions);
+
+ expect(option).toMatchObject({
+ type: "select",
+ id: "provider",
+ currentValue: "cline",
+ options: providerOptions,
+ });
+ });
+
+ it("uses a custom category instead of a spec-reserved one", () => {
+ const option = buildProviderConfigOption("cline", providerOptions);
+
+ expect(option.category).toBe(PROVIDER_CONFIG_CATEGORY);
+ expect(ACP_RESERVED_CATEGORIES).not.toContain(option.category);
+ expect(option.category).toMatch(/^_/);
+ });
+});
+
+describe.each([
+ false,
+ true,
+])("ACP config options (organization: %s)", (includeOrganization) => {
+ const options = buildConfigOptions({
+ currentProviderId: "cline",
+ providerOptions,
+ currentModelId: "anthropic/claude-sonnet-5.5",
+ providerModels,
+ currentMode: "act",
+ autoApproveTools: false,
+ organizationOption: includeOrganization ? organizationOption : undefined,
+ });
+
+ it("tags only the model selector with the model category", () => {
+ expect(
+ options
+ .filter((option) => option.category === MODEL_CONFIG_CATEGORY)
+ .map((option) => option.id),
+ ).toEqual(["model"]);
+ });
+
+ it("uses reserved or custom category names for every option", () => {
+ for (const { category } of options) {
+ expect(
+ category === undefined ||
+ category === null ||
+ ACP_RESERVED_CATEGORIES.includes(category) ||
+ category.startsWith("_"),
+ ).toBe(true);
+ }
+ });
+
+ it("does not repeat reserved categories in the assembled list", () => {
+ const categories = options
+ .map((option) => option.category)
+ .filter(
+ (category) => category && ACP_RESERVED_CATEGORIES.includes(category),
+ );
+
+ expect(new Set(categories).size).toBe(categories.length);
+ });
+
+ it("preserves the option order", () => {
+ expect(options.map((option) => option.id)).toEqual([
+ "provider",
+ "model",
+ "mode",
+ "auto_approve",
+ ...(includeOrganization ? ["organization"] : []),
+ ]);
+ });
+});
diff --git a/apps/cli/src/acp/config-options.ts b/apps/cli/src/acp/config-options.ts
new file mode 100644
index 0000000000..f44eba30f7
--- /dev/null
+++ b/apps/cli/src/acp/config-options.ts
@@ -0,0 +1,95 @@
+import type {
+ SessionConfigOption,
+ SessionConfigSelectOption,
+} from "@agentclientprotocol/sdk";
+import { buildAutoApproveConfigOption } from "./auto-approve";
+
+// Clients break category ties by array order, so the provider must not claim
+// the model selector's category when it comes first.
+export const PROVIDER_CONFIG_CATEGORY = "_provider";
+export const MODEL_CONFIG_CATEGORY = "model";
+
+interface ConfigOptionsInput {
+ currentProviderId: string;
+ providerOptions: SessionConfigSelectOption[];
+ currentModelId: string;
+ providerModels: Record;
+ currentMode: string;
+ autoApproveTools: boolean;
+ organizationOption?: SessionConfigOption;
+}
+
+export function buildProviderConfigOption(
+ currentProviderId: string,
+ options: SessionConfigSelectOption[],
+): SessionConfigOption {
+ return {
+ type: "select",
+ id: "provider",
+ name: "Provider",
+ description: "The authentication provider to use",
+ category: PROVIDER_CONFIG_CATEGORY,
+ currentValue: currentProviderId,
+ options,
+ };
+}
+
+export function buildModelConfigOption(
+ currentModelId: string,
+ providerModels: Record,
+): SessionConfigOption {
+ return {
+ type: "select",
+ id: "model",
+ name: "Model",
+ category: MODEL_CONFIG_CATEGORY,
+ currentValue: currentModelId,
+ options: Object.entries(providerModels).map(([modelId, info]) => ({
+ value: modelId,
+ name: info.name ?? modelId,
+ description: info.description,
+ })),
+ };
+}
+
+function buildModeConfigOption(currentMode: string): SessionConfigOption {
+ return {
+ type: "select",
+ id: "mode",
+ name: "Session Mode",
+ description: "Controls whether the agent can modify files",
+ category: "mode",
+ currentValue: currentMode,
+ options: [
+ {
+ value: "plan",
+ name: "Plan",
+ description:
+ "Explore the codebase and plan changes without modifying files",
+ },
+ {
+ value: "act",
+ name: "Act",
+ description: "Make changes to the codebase",
+ },
+ ],
+ };
+}
+
+export function buildConfigOptions({
+ currentProviderId,
+ providerOptions,
+ currentModelId,
+ providerModels,
+ currentMode,
+ autoApproveTools,
+ organizationOption,
+}: ConfigOptionsInput): SessionConfigOption[] {
+ return [
+ buildProviderConfigOption(currentProviderId, providerOptions),
+ buildModelConfigOption(currentModelId, providerModels),
+ buildModeConfigOption(currentMode),
+ buildAutoApproveConfigOption(autoApproveTools),
+ ...(organizationOption ? [organizationOption] : []),
+ ];
+}
diff --git a/apps/cli/src/acp/organizations.test.ts b/apps/cli/src/acp/organizations.test.ts
index 41ca89ab7f..40c648cc40 100644
--- a/apps/cli/src/acp/organizations.test.ts
+++ b/apps/cli/src/acp/organizations.test.ts
@@ -28,6 +28,7 @@ describe("buildOrganizationConfigOption", () => {
activeOrganizationId: "org-2",
});
expect(option.id).toBe("organization");
+ expect(option.category).toBe("_account");
if (option.type !== "select") {
throw new Error(`expected a select option, got ${option.type}`);
}
diff --git a/apps/cli/src/acp/organizations.ts b/apps/cli/src/acp/organizations.ts
index 3b8a1e48bc..68b3d7d4ff 100644
--- a/apps/cli/src/acp/organizations.ts
+++ b/apps/cli/src/acp/organizations.ts
@@ -94,7 +94,7 @@ export function buildOrganizationConfigOption(
name: "Account",
description:
"The Cline account usage is billed to — your personal account or an organization",
- category: "account",
+ category: "_account",
currentValue: state.activeOrganizationId ?? PERSONAL_ACCOUNT_VALUE,
options: [
{ value: PERSONAL_ACCOUNT_VALUE, name: "Personal" },
diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json
index a1fe57f1ef..c557f63bca 100644
--- a/apps/examples/desktop-app/package.json
+++ b/apps/examples/desktop-app/package.json
@@ -32,7 +32,7 @@
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/messages webview/components/ui/markdown.test.tsx webview/components/ui/markdown.interactions.test.tsx --config vitest.config.ts",
"pretest:settings-ui": "bun run build:ui",
"test:settings-ui": "vitest run webview/components/views/settings webview/components/ui/switch-integration.test.tsx webview/components/quit-hold-handler.test.tsx webview/lib/keybindings.test.ts --config vitest.config.ts",
- "test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
+ "test:sidecar": "vitest run sidecar scripts/telemetry-define-args.test.ts webview/hooks/chat-session/helpers.test.ts webview/hooks/use-chat-session.test.tsx webview/hooks/use-session-history.test.tsx webview/hooks/use-prompt-draft.test.tsx webview/lib/changelog.test.ts webview/lib/cloud-repositories.test.ts webview/lib/cloud-session-error.test.ts webview/lib/model-selection.test.ts webview/lib/local-environment-selection.test.ts webview/lib/image-attachments.test.ts webview/lib/session-status.test.ts webview/lib/whats-new.test.ts webview/lib/workspace-paths.test.ts webview/components/agent-sidebar.test.tsx webview/components/hub-startup-screen.test.tsx webview/hooks/use-hub-startup.test.tsx webview/hooks/use-remote-environment-bootstrap.test.tsx webview/components/views/chat/chat-input-bar.test.tsx webview/components/views/chat/chat-messages.test.tsx webview/components/views/chat/environment-selector.test.tsx webview/components/views/chat/welcome-chat.test.tsx webview/components/views/chat/welcome-workspace-controls.test.tsx webview/components/views/sessions/sessions-view.test.tsx webview/components/views/settings/settings-view.test.tsx --config vitest.config.ts",
"test:sidecar-startup": "bun test scripts/desktop-startup.test.ts",
"test:desktop-client": "vitest run webview/lib/desktop-client.test.ts --config vitest.config.ts",
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
@@ -110,7 +110,7 @@
"devDependencies": {
"@cline/sdk": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
- "@tauri-apps/cli": "^2.0.0",
+ "@tauri-apps/cli": "^2.12.3",
"@types/bun": "^1.4.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
diff --git a/apps/examples/desktop-app/scripts/desktop-startup.test.ts b/apps/examples/desktop-app/scripts/desktop-startup.test.ts
index b7d5567260..106a059ca7 100644
--- a/apps/examples/desktop-app/scripts/desktop-startup.test.ts
+++ b/apps/examples/desktop-app/scripts/desktop-startup.test.ts
@@ -145,6 +145,29 @@ function processCommandLine(pid: number): string | undefined {
return ps.status === 0 ? ps.stdout.trim() : undefined;
}
+function waitForHubStartup(wsEndpoint: string): Promise {
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(wsEndpoint);
+ const timer = setTimeout(() => {
+ socket.close();
+ reject(new Error("Hub startup did not settle within 30 seconds"));
+ }, 30_000);
+ socket.onmessage = (event) => {
+ const message = JSON.parse(String(event.data));
+ const state = message.event?.payload?.state;
+ if (message.event?.name === "hub_status" && state !== "starting") {
+ clearTimeout(timer);
+ socket.close();
+ resolve(state);
+ }
+ };
+ socket.onerror = () => {
+ clearTimeout(timer);
+ reject(new Error(`Could not connect to ${wsEndpoint}`));
+ };
+ });
+}
+
async function runStartupScenario(
extraEnv: Record,
options: { hubAlreadyRunning: boolean; discoveryContents?: string },
@@ -231,12 +254,16 @@ async function runStartupScenario(
},
);
let endpoint: string | undefined;
+ let wsEndpoint: string | undefined;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const line of readFileSync(stdoutPath, "utf8").split("\n")) {
try {
const message = JSON.parse(line);
- if (message.type === "ready") endpoint = message.endpoint;
+ if (message.type === "ready") {
+ endpoint = message.endpoint;
+ wsEndpoint = message.wsEndpoint;
+ }
} catch {
/* Ignore other output and incomplete lines. */
}
@@ -253,6 +280,11 @@ async function runStartupScenario(
});
expect(health.ok).toBe(true);
expect(await health.json()).toMatchObject({ ok: true, pid: child.pid });
+ // The backend publishes its endpoint before starting the Hub and reports
+ // Hub startup over the transport, replaying the current state on connect.
+ expect(await waitForHubStartup(String(wsEndpoint)), stderrPath).toBe(
+ "ready",
+ );
const hub = JSON.parse(readFileSync(discoveryPath, "utf8"));
hubPid = hub.pid;
if (existingHub) {
diff --git a/apps/examples/desktop-app/scripts/windows-installer.test.ts b/apps/examples/desktop-app/scripts/windows-installer.test.ts
index 8842d43397..43f88e6c16 100644
--- a/apps/examples/desktop-app/scripts/windows-installer.test.ts
+++ b/apps/examples/desktop-app/scripts/windows-installer.test.ts
@@ -28,8 +28,8 @@ const lockfile = path.resolve(import.meta.dir, "../../../../bun.lock");
// of the locked @tauri-apps/cli), and desktop-test.yml downloads that same
// archive by hand. Pin both ends here so a Tauri CLI bump, or an edit to the
// workflow's download, fails this suite until the pair is re-checked together.
-const TAURI_CLI_VERSION = "2.11.4";
-const NSIS_VERSION = "v3.11";
+const TAURI_CLI_VERSION = "2.12.3";
+const NSIS_VERSION = "v3.13";
const children: ChildProcess[] = [];
let root: string;
let fixture: string;
diff --git a/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts b/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts
new file mode 100644
index 0000000000..f3382eeec1
--- /dev/null
+++ b/apps/examples/desktop-app/sidecar/commands-hub-startup.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it, vi } from "vitest";
+import { handleCommand } from "./commands";
+import { HubStartup } from "./hub-startup";
+import type { SidecarContext, SidecarWebSocketClient } from "./types";
+
+function contextWith(hubStartup: HubStartup): SidecarContext {
+ return {
+ localWorkspaceRoot: "/workspace",
+ wsClients: new Set(),
+ runtimeBindings: new Map(),
+ hubBuildMismatch: null,
+ hubStartup,
+ logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
+ } as unknown as SidecarContext;
+}
+
+describe("commands during Hub startup", () => {
+ it("answers retry without waiting for the Hub", async () => {
+ const startup = new HubStartup(
+ () => new Promise(() => {}),
+ vi.fn(),
+ vi.fn(),
+ );
+ startup.start();
+ const ctx = contextWith(startup);
+ await expect(handleCommand(ctx, "retry_hub")).resolves.toEqual({
+ state: "starting",
+ });
+ });
+
+ it("holds Hub-backed commands until startup settles", async () => {
+ let finishSetup!: () => void;
+ const startup = new HubStartup(
+ () =>
+ new Promise((resolve) => {
+ finishSetup = resolve;
+ }),
+ vi.fn(),
+ vi.fn(),
+ );
+ startup.start();
+ let settled = false;
+ const pending = handleCommand(
+ contextWith(startup),
+ "no_such_command",
+ ).finally(() => {
+ settled = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(settled).toBe(false);
+ finishSetup();
+ await expect(pending).rejects.toThrow();
+ expect(settled).toBe(true);
+ });
+
+ it("drops a waiting command whose connection closed during startup", async () => {
+ let finishSetup!: () => void;
+ const startup = new HubStartup(
+ () =>
+ new Promise((resolve) => {
+ finishSetup = resolve;
+ }),
+ vi.fn(),
+ vi.fn(),
+ );
+ startup.start();
+ const ctx = contextWith(startup);
+ const connection = {} as SidecarWebSocketClient;
+ ctx.wsClients.add(connection);
+ const pending = handleCommand(
+ ctx,
+ "run_provider_oauth_login",
+ { providerId: "cline" },
+ { connection },
+ );
+ ctx.wsClients.delete(connection);
+ finishSetup();
+ await expect(pending).rejects.toThrow(
+ "The connection closed while Cline Hub was starting.",
+ );
+ });
+});
diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts
index dbab76fa74..78809c6a06 100644
--- a/apps/examples/desktop-app/sidecar/commands.ts
+++ b/apps/examples/desktop-app/sidecar/commands.ts
@@ -133,6 +133,7 @@ import {
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { getHubStatus, restartHub } from "./hub-admin";
+import { isHubIndependentCommand } from "./hub-startup";
import {
readKeybindings,
resolveKeybindingsPath,
@@ -1882,6 +1883,20 @@ export async function handleCommand(
args?: Record,
options?: { connection?: SidecarWebSocketClient },
): Promise {
+ if (command === "retry_hub") {
+ ctx.hubStartup?.start();
+ return ctx.hubStartup?.status ?? null;
+ }
+ // The transport is published before the Hub is ready. Hold Hub-backed
+ // commands until startup settles, as they waited for the transport before.
+ if (ctx.hubStartup && !isHubIndependentCommand(command)) {
+ await ctx.hubStartup.waitUntilSettled();
+ // Its close cleanup already ran, so nothing could cancel work (such as a
+ // browser sign-in) started for a connection that left while waiting.
+ if (options?.connection && !ctx.wsClients.has(options.connection)) {
+ throw new Error("The connection closed while Cline Hub was starting.");
+ }
+ }
const explicitEnvironment = requestedEnvironmentId(args);
if (explicitEnvironment) {
ctx = getEnvironmentContext(ctx, explicitEnvironment);
diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts
index 4e7d9d6e4a..ba3d0bafe9 100644
--- a/apps/examples/desktop-app/sidecar/context.test.ts
+++ b/apps/examples/desktop-app/sidecar/context.test.ts
@@ -158,6 +158,23 @@ describe("Code sidecar runtime capabilities", () => {
);
});
+ it("reuses the installed session manager when a retried startup only failed its readiness sync", async () => {
+ const { createSidecarContext, initializeSessionManager } = await import(
+ "./context"
+ );
+ const ctx = createSidecarContext("/workspace/project");
+ updateCapabilitiesMock.mockRejectedValueOnce(new Error("hub busy"));
+
+ await expect(initializeSessionManager(ctx)).rejects.toThrow("hub busy");
+ const installed = ctx.runtimeBindings.get("local");
+ expect(installed).toBeDefined();
+
+ await initializeSessionManager(ctx);
+ expect(createCoreMock).toHaveBeenCalledTimes(1);
+ expect(ctx.runtimeBindings.get("local")).toBe(installed);
+ expect(updateCapabilitiesMock).toHaveBeenCalledTimes(2);
+ });
+
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts
index e872b7c0dd..ddbcc6bfd6 100644
--- a/apps/examples/desktop-app/sidecar/context.ts
+++ b/apps/examples/desktop-app/sidecar/context.ts
@@ -1296,6 +1296,12 @@ async function handleHubApprovalRequest(
export async function initializeSessionManager(
ctx: SidecarContext,
): Promise {
+ // A retried startup must not replace (and leak) a manager that an earlier
+ // attempt installed before its final readiness sync failed.
+ if (ctx.runtimeBindings.has(LOCAL_ENVIRONMENT_ID)) {
+ await syncSidecarApprovalReadiness(ctx);
+ return;
+ }
setHomeDirIfUnset(homedir());
const sessionManager = await ClineCore.create({
clientName: "cline-code",
diff --git a/apps/examples/desktop-app/sidecar/hub-startup.test.ts b/apps/examples/desktop-app/sidecar/hub-startup.test.ts
new file mode 100644
index 0000000000..74f3e9bcf9
--- /dev/null
+++ b/apps/examples/desktop-app/sidecar/hub-startup.test.ts
@@ -0,0 +1,100 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { HubStartup, type HubStartupStatus } from "./hub-startup";
+
+function deferred() {
+ let resolve!: () => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("HubStartup", () => {
+ it("reports ready when setup succeeds and releases waiting commands", async () => {
+ const setup = deferred();
+ const statuses: HubStartupStatus[] = [];
+ const startup = new HubStartup(
+ () => setup.promise,
+ (status) => statuses.push(status),
+ vi.fn(),
+ );
+ startup.start();
+ const waiting = startup.waitUntilSettled();
+ setup.resolve();
+ await waiting;
+ expect(statuses.map((status) => status.state)).toEqual([
+ "starting",
+ "ready",
+ ]);
+ });
+
+ it("reports a fixed message and hands the raw error to the failure hook", async () => {
+ const error = new Error(
+ "hub exploded at ws://127.0.0.1:25463?token=secret",
+ );
+ const onFailure = vi.fn();
+ const startup = new HubStartup(
+ () => Promise.reject(error),
+ vi.fn(),
+ onFailure,
+ );
+ startup.start();
+ await startup.waitUntilSettled();
+ expect(onFailure).toHaveBeenCalledWith(error);
+ expect(startup.status.state).toBe("failed");
+ expect(startup.status.error).not.toContain("secret");
+ });
+
+ it("fails after the timeout but still becomes ready if the slow setup finishes", async () => {
+ vi.useFakeTimers();
+ const setup = deferred();
+ const onFailure = vi.fn();
+ const startup = new HubStartup(
+ () => setup.promise,
+ vi.fn(),
+ onFailure,
+ 1_000,
+ );
+ startup.start();
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(startup.status).toEqual({
+ state: "failed",
+ error: "Cline Hub did not respond within 30 seconds.",
+ });
+ expect(onFailure).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining("1000ms") }),
+ );
+ setup.resolve();
+ await vi.runAllTimersAsync();
+ expect(startup.status.state).toBe("ready");
+ });
+
+ it("reuses a pending attempt on retry and starts a new one after it fails", async () => {
+ vi.useFakeTimers();
+ const first = deferred();
+ const run = vi
+ .fn<() => Promise>()
+ .mockReturnValueOnce(first.promise)
+ .mockResolvedValueOnce(undefined);
+ const startup = new HubStartup(run, vi.fn(), vi.fn(), 1_000);
+ startup.start();
+ await vi.advanceTimersByTimeAsync(1_000);
+ startup.start();
+ expect(startup.status.state).toBe("starting");
+ expect(run).toHaveBeenCalledTimes(1);
+
+ first.reject(new Error("still broken"));
+ await vi.runAllTimersAsync();
+ expect(startup.status.state).toBe("failed");
+ startup.start();
+ await vi.runAllTimersAsync();
+ expect(run).toHaveBeenCalledTimes(2);
+ expect(startup.status.state).toBe("ready");
+ });
+});
diff --git a/apps/examples/desktop-app/sidecar/hub-startup.ts b/apps/examples/desktop-app/sidecar/hub-startup.ts
new file mode 100644
index 0000000000..c2c2334caa
--- /dev/null
+++ b/apps/examples/desktop-app/sidecar/hub-startup.ts
@@ -0,0 +1,84 @@
+import type { DesktopHubStatus } from "../webview/lib/desktop-transport";
+
+export type HubStartupStatus = DesktopHubStatus;
+
+const HUB_STARTUP_TIMEOUT_MS = 30_000;
+
+/** Commands the startup screen needs before the Hub is ready. */
+const HUB_INDEPENDENT_COMMANDS = new Set(["retry_hub", "export_diagnostics"]);
+
+export function isHubIndependentCommand(command: string): boolean {
+ return HUB_INDEPENDENT_COMMANDS.has(command);
+}
+
+/**
+ * Runs local Hub setup after the sidecar is already reachable, so the webview
+ * can show progress, a failure, Retry, and diagnostics export instead of
+ * waiting on a backend that never comes up.
+ *
+ * Only one setup attempt runs at a time. A timeout reports failure but lets
+ * the attempt keep going: if a slow Hub answers later, startup still
+ * completes. Retry while an attempt is pending waits on that same attempt.
+ */
+export class HubStartup {
+ status: HubStartupStatus = { state: "starting" };
+ private attempt?: Promise;
+ private timer?: ReturnType;
+ private waiters: Array<() => void> = [];
+
+ constructor(
+ private readonly run: () => Promise,
+ private readonly onChange: (status: HubStartupStatus) => void,
+ private readonly onFailure: (error: unknown) => void,
+ private readonly timeoutMs = HUB_STARTUP_TIMEOUT_MS,
+ ) {}
+
+ start(): void {
+ if (this.status.state === "ready") return;
+ this.setStatus({ state: "starting" });
+ clearTimeout(this.timer);
+ this.timer = setTimeout(() => {
+ if (this.status.state === "starting") {
+ this.onFailure(
+ new Error(
+ `Local Hub startup did not finish within ${this.timeoutMs}ms; still waiting in the background`,
+ ),
+ );
+ this.setStatus({
+ state: "failed",
+ error: "Cline Hub did not respond within 30 seconds.",
+ });
+ }
+ }, this.timeoutMs);
+ if (this.attempt) return;
+ this.attempt = this.run()
+ .then(
+ () => this.setStatus({ state: "ready" }),
+ (error) => {
+ this.onFailure(error);
+ this.setStatus({
+ state: "failed",
+ error: "Cline couldn't start or connect to its Hub.",
+ });
+ },
+ )
+ .finally(() => {
+ this.attempt = undefined;
+ });
+ }
+
+ /** Resolves once startup is no longer in progress (ready or failed). */
+ waitUntilSettled(): Promise {
+ if (this.status.state !== "starting") return Promise.resolve();
+ return new Promise((resolve) => this.waiters.push(resolve));
+ }
+
+ private setStatus(status: HubStartupStatus): void {
+ this.status = status;
+ if (status.state !== "starting") {
+ clearTimeout(this.timer);
+ for (const resolve of this.waiters.splice(0)) resolve();
+ }
+ this.onChange(status);
+ }
+}
diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts
index 5fbb67d9da..4ff4b9a993 100644
--- a/apps/examples/desktop-app/sidecar/index.ts
+++ b/apps/examples/desktop-app/sidecar/index.ts
@@ -25,6 +25,7 @@ import {
disposeSidecarContext,
initializeSessionManager,
} from "./context";
+import { HubStartup } from "./hub-startup";
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
@@ -90,23 +91,43 @@ async function main(desktopCliPath?: string) {
}
prewarmWorkspaceMetadata(workspaceRoot);
- observability.logger.log(
- "Login shell PATH resolution",
- await shellPathPromise,
- );
- // The packaged app hands Hub startup to its bundled CLI: it reuses a
- // compatible healthy Hub or starts one, and the session manager attaches.
- // Afterwards the PATH above is set, so a new Hub inherits it. On failure
- // the session manager still starts the Hub through the same CLI launcher.
- if (desktopCliPath) {
- try {
- const hub = await ensureHubWithDesktopCli(desktopCliPath, workspaceRoot);
- observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
- } catch (error) {
- observability.logger.error?.("Cline CLI hub ensure failed", { error });
+
+ const setUpLocalHub = async (): Promise => {
+ observability.logger.log(
+ "Login shell PATH resolution",
+ await shellPathPromise,
+ );
+ // The packaged app hands Hub startup to its bundled CLI: it reuses a
+ // compatible healthy Hub or starts one, and the session manager attaches.
+ // Afterwards the PATH above is set, so a new Hub inherits it. On failure
+ // the session manager still starts the Hub through the same CLI launcher.
+ if (desktopCliPath) {
+ try {
+ const hub = await ensureHubWithDesktopCli(
+ desktopCliPath,
+ workspaceRoot,
+ );
+ observability.logger.log("Hub ready via Cline CLI", { url: hub.url });
+ } catch (error) {
+ observability.logger.error?.("Cline CLI hub ensure failed", { error });
+ }
}
- }
- await initializeSessionManager(ctx);
+ await initializeSessionManager(ctx);
+ checkStartupHubBuildMismatch();
+ };
+ ctx.hubStartup = new HubStartup(
+ setUpLocalHub,
+ (status) => broadcastEvent(ctx, "hub_status", status),
+ (error) => {
+ observability.logger.error?.("Local Hub startup failed", { error });
+ captureSdkError(observability.telemetry, {
+ component: "desktop",
+ operation: "sidecar.hub_startup",
+ error,
+ handled: true,
+ });
+ },
+ );
let shuttingDown = false;
let handlingFatalError = false;
@@ -184,16 +205,35 @@ async function main(desktopCliPath?: string) {
broadcastEvent(ctx, "hub_build_mismatch", mismatch);
},
});
+ // A wildcard bind isn't a dialable address; advertise loopback instead.
+ const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
+ const endpoint = `http://${dialHost}:${port}`;
+ const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
+ wsEndpoint.searchParams.set("approval_token", approvalToken);
+ process.stdout.write(
+ `${JSON.stringify({
+ type: "ready",
+ endpoint,
+ wsEndpoint: wsEndpoint.toString(),
+ pid: process.pid,
+ mode: SIDECAR_MODE,
+ })}\n`,
+ );
+ // The webview is reachable now, so it can show Hub startup progress and
+ // failures instead of commands timing out against an unpublished backend.
+ ctx.hubStartup.start();
+
// The watcher's first check only runs after its interval, but a mismatch
// that already exists at startup - an older Hub this app attached to
// because it is still serving other clients' sessions - must prompt
- // before the user starts working, not half a minute in. Session-manager
- // init has already settled the hub state, so check once right away. The
- // broadcast reaches webviews that are already connected; the replay in
+ // before the user starts working, not half a minute in. Hub setup has
+ // settled the hub state by the time this runs. The broadcast reaches
+ // webviews that are already connected; the replay in
// createWebSocketHandler covers ones that connect later. Skipped when
// CLINE_HUB_PORT pins an explicit endpoint, matching the watcher: such
// hosts keep protocol-only compatibility and must not show update prompts.
- if (!process.env.CLINE_HUB_PORT?.trim()) {
+ function checkStartupHubBuildMismatch(): void {
+ if (process.env.CLINE_HUB_PORT?.trim()) return;
void checkManagedHubBuildMismatch()
.then((mismatch) => {
if (!mismatch || ctx.hubBuildMismatch) {
@@ -212,21 +252,6 @@ async function main(desktopCliPath?: string) {
})
.catch(() => undefined);
}
-
- // A wildcard bind isn't a dialable address; advertise loopback instead.
- const dialHost = SIDECAR_HOST === "0.0.0.0" ? "127.0.0.1" : SIDECAR_HOST;
- const endpoint = `http://${dialHost}:${port}`;
- const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`);
- wsEndpoint.searchParams.set("approval_token", approvalToken);
- process.stdout.write(
- `${JSON.stringify({
- type: "ready",
- endpoint,
- wsEndpoint: wsEndpoint.toString(),
- pid: process.pid,
- mode: SIDECAR_MODE,
- })}\n`,
- );
}
/**
diff --git a/apps/examples/desktop-app/sidecar/server.ts b/apps/examples/desktop-app/sidecar/server.ts
index 882ae10c2f..9330a601b3 100644
--- a/apps/examples/desktop-app/sidecar/server.ts
+++ b/apps/examples/desktop-app/sidecar/server.ts
@@ -364,6 +364,9 @@ export function createWebSocketHandler(ctx: SidecarContext) {
pid: process.pid,
mode: SIDECAR_MODE,
});
+ if (ctx.hubStartup) {
+ ws.send(encodeSidecarEvent("hub_status", ctx.hubStartup.status));
+ }
// Replay a pending mismatch so webviews that connect (or reload)
// after detection still prompt the user to update and restart.
if (ctx.hubBuildMismatch) {
diff --git a/apps/examples/desktop-app/sidecar/types.ts b/apps/examples/desktop-app/sidecar/types.ts
index 604777a56c..fcc35aaa60 100644
--- a/apps/examples/desktop-app/sidecar/types.ts
+++ b/apps/examples/desktop-app/sidecar/types.ts
@@ -11,6 +11,7 @@ import type {
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
import type { UserContext } from "@cline/shared";
+import type { HubStartup } from "./hub-startup";
export const LOCAL_ENVIRONMENT_ID = "local";
@@ -166,6 +167,8 @@ export type SidecarContext = {
* replayed to webviews that connect after the event fired.
*/
hubBuildMismatch: ManagedHubBuildMismatchEvent | null;
+ /** Local Hub setup, which runs after the transport is published. */
+ hubStartup?: HubStartup;
};
export type BunRuntimeApi = {
serve: (options: unknown) => { port: number; stop?: () => void };
diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx
index 1188191292..c38af72768 100644
--- a/apps/examples/desktop-app/webview/app/page.tsx
+++ b/apps/examples/desktop-app/webview/app/page.tsx
@@ -8,6 +8,7 @@ import { AttachmentDropZone } from "@cline/ui";
import { Loader2, LoaderCircle } from "lucide-react";
import dynamic from "next/dynamic";
import {
+ Activity,
useCallback,
useEffect,
useMemo,
@@ -17,6 +18,7 @@ import {
} from "react";
import { AgentHeader } from "@/components/agent-header";
import { AgentSidebar } from "@/components/agent-sidebar";
+import { HubStartupScreen } from "@/components/hub-startup-screen";
import { HubUpdateRequiredDialog } from "@/components/hub-update-required-dialog";
import { QuitHoldHandler } from "@/components/quit-hold-handler";
import { SessionCommandBar } from "@/components/session-command-bar";
@@ -55,11 +57,12 @@ import {
import { AccountProvider, useAccount } from "@/contexts/account-context";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
-import type { ProcessContext } from "@/hooks/chat-session/types";
import { checkForUpdateAndNotify, useAppUpdate } from "@/hooks/use-app-update";
import { useChatSession } from "@/hooks/use-chat-session";
+import { useHubStartup } from "@/hooks/use-hub-startup";
import { usePendingAttachments } from "@/hooks/use-pending-attachments";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
+import { useRemoteEnvironmentBootstrap } from "@/hooks/use-remote-environment-bootstrap";
import { useSessionAgents } from "@/hooks/use-session-agents";
import { useSessionHistory } from "@/hooks/use-session-history";
import { toast } from "@/hooks/use-toast";
@@ -147,10 +150,7 @@ import {
type WorkIn,
writeWorkInToWindow,
} from "@/lib/work-in-selection";
-import {
- type RemoteWorkspaceEnvironment,
- remoteWorkspaceEnvironmentFromContext,
-} from "@/lib/workspace-environment";
+import type { RemoteWorkspaceEnvironment } from "@/lib/workspace-environment";
import {
filterWorkspacePaths,
isTaskWorktreePath,
@@ -305,6 +305,32 @@ function toThreadTitle(options: { title?: string; prompt?: string }): string {
}
export default function Home() {
+ const startup = useHubStartup();
+ const hubReady =
+ startup.transport === "connected" && startup.hub.state === "ready";
+ // Mount the app once the Hub is first ready, then hide it with Activity
+ // whenever the Hub is not ready. Hidden Activity content keeps its state
+ // (open conversations) but unmounts effects, so its dialogs, including
+ // portaled ones, release focus traps and body pointer-events.
+ const [loaded, setLoaded] = useState(false);
+ useEffect(() => {
+ if (hubReady) setLoaded(true);
+ }, [hubReady]);
+ return (
+ <>
+ {loaded ? (
+
+
+
+ ) : null}
+ {hubReady ? null : }
+ {/* Outside the gate: the startup mismatch check can fire before App mounts. */}
+
+ >
+ );
+}
+
+function App() {
const [initialThreadId] = useState(makeThreadId);
// Outlive keyed chat panes without re-rendering the app on every keystroke.
const { current: promptDrafts } = useRef(new Map());
@@ -411,32 +437,27 @@ export default function Home() {
void syncDesktopWindowTitle();
}, []);
- useEffect(() => {
- let cancelled = false;
- const revision = environmentSelectionRevision.current;
- desktopClient
- .invoke("get_process_context")
- .then((context) => {
- if (!cancelled && revision === environmentSelectionRevision.current) {
- const remoteEnvironment =
- remoteWorkspaceEnvironmentFromContext(context);
- setActiveRemoteEnvironment(remoteEnvironment);
- if (remoteEnvironment) {
- dispatchApp({
- type: "select-environment-draft",
- threadId: makeThreadId(),
- environmentId: remoteEnvironment.id,
- });
- }
+ useRemoteEnvironmentBootstrap(
+ environmentSelectionRevision,
+ useCallback(
+ (
+ remoteEnvironment: RemoteWorkspaceEnvironment | null,
+ isFirstLoad: boolean,
+ ) => {
+ setActiveRemoteEnvironment(remoteEnvironment);
+ // Later loads follow a reconnect; a new draft would replace the
+ // conversation that is still open.
+ if (isFirstLoad && remoteEnvironment) {
+ dispatchApp({
+ type: "select-environment-draft",
+ threadId: makeThreadId(),
+ environmentId: remoteEnvironment.id,
+ });
}
- })
- .catch(() => {
- // The chat bootstrap reports backend availability separately.
- });
- return () => {
- cancelled = true;
- };
- }, []);
+ },
+ [],
+ ),
+ );
useEffect(() => {
if (view !== "chat") return;
@@ -1071,7 +1092,6 @@ export default function Home() {
onOpenChange={setExportDiagnosticsOpen}
open={exportDiagnosticsOpen}
/>
-
{whatsNew ? (
{
expect(button?.getAttribute("aria-label")).toBe("Record speech");
});
+ it("returns to inactive after an Activity hides it mid-recording", async () => {
+ const cancel = vi.fn();
+ const onActiveChange = vi.fn();
+ const render = (mode: "visible" | "hidden") =>
+ act(async () => {
+ root.render(
+
+ ({
+ done: new Promise(() => {}),
+ stop: vi.fn(),
+ cancel,
+ })}
+ recordingMode="streaming"
+ />
+ ,
+ );
+ });
+ await render("visible");
+ const button = container.querySelector(
+ '[aria-label="Record speech"]',
+ );
+ await act(async () => {
+ button?.click();
+ await Promise.resolve();
+ });
+ expect(button?.getAttribute("aria-label")).toBe("Stop recording");
+
+ await render("hidden");
+ await render("visible");
+
+ expect(cancel).toHaveBeenCalledOnce();
+ expect(onActiveChange).toHaveBeenLastCalledWith(false);
+ expect(button?.disabled).toBe(false);
+ expect(button?.getAttribute("aria-label")).toBe("Record speech");
+ });
+
it("returns to inactive when MediaRecorder rejects stop", async () => {
const stopError = new DOMException("Already stopped", "InvalidStateError");
const onActiveChange = vi.fn();
diff --git a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx
index 6f0080a754..bed2a61ea5 100644
--- a/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx
+++ b/apps/examples/desktop-app/webview/components/ai-elements/speech-input.tsx
@@ -297,6 +297,10 @@ export function SpeechInput({
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop();
}
+ // A hidden Activity keeps this state; the cancelled recording can no
+ // longer reset it, which would leave the composer read-only.
+ setIsListening(false);
+ setIsProcessing(false);
};
}, []);
diff --git a/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx b/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx
new file mode 100644
index 0000000000..b3678a9652
--- /dev/null
+++ b/apps/examples/desktop-app/webview/components/hub-startup-screen.test.tsx
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, expect, it, vi } from "vitest";
+import { HubStartupScreen } from "./hub-startup-screen";
+
+const invoke = vi.hoisted(() => vi.fn());
+vi.mock("@/lib/desktop-client", () => ({
+ desktopClient: { invoke },
+ isTauriAvailable: () => false,
+}));
+
+const container = document.createElement("div");
+document.body.append(container);
+let root = createRoot(container);
+(
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+afterEach(() => {
+ act(() => root.unmount());
+ root = createRoot(container);
+ vi.useRealTimers();
+ vi.clearAllMocks();
+});
+
+function buttons(): string[] {
+ return [...container.querySelectorAll("button")].map(
+ (button) => button.textContent ?? "",
+ );
+}
+
+it("shows only a spinner while the Hub is starting", () => {
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.textContent).toContain("Starting Cline…");
+ expect(buttons()).toEqual([]);
+ // The window stays draggable (and closable on frameless Windows) without the app.
+ expect(
+ container.querySelector('[data-slot="window-title-bar"]'),
+ ).not.toBeNull();
+});
+
+it("offers Retry and diagnostics export when the Hub fails", async () => {
+ invoke.mockResolvedValue({ path: "/tmp/cline-diagnostics.txt" });
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.textContent).toContain("Cline couldn't connect to its Hub");
+ expect(buttons()).toEqual(["Retry", "Export diagnostics"]);
+
+ const [retry, exportButton] = container.querySelectorAll("button");
+ act(() => retry.click());
+ expect(invoke).toHaveBeenCalledWith("retry_hub");
+ await act(async () => exportButton.click());
+ expect(invoke).toHaveBeenCalledWith("export_diagnostics");
+ expect(container.textContent).toContain(
+ "Diagnostics saved to /tmp/cline-diagnostics.txt",
+ );
+});
+
+it("calls out an unreachable desktop backend after 30 seconds", () => {
+ vi.useFakeTimers();
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.textContent).toContain("Starting Cline…");
+ act(() => vi.advanceTimersByTime(30_000));
+ expect(container.textContent).toContain(
+ "Cline's desktop backend isn't responding",
+ );
+ expect(buttons()).toEqual(["Reload"]);
+});
diff --git a/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx b/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx
new file mode 100644
index 0000000000..eb80a5dbc3
--- /dev/null
+++ b/apps/examples/desktop-app/webview/components/hub-startup-screen.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { type ReactNode, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ WindowTitleBar,
+ WindowTitleBarProvider,
+} from "@/components/window-title-bar";
+import type { useHubStartup } from "@/hooks/use-hub-startup";
+import { desktopClient } from "@/lib/desktop-client";
+
+const BACKEND_UNRESPONSIVE_MS = 30_000;
+
+/**
+ * Shown instead of the app while local Hub startup is in progress or has failed, so the
+ * user gets Retry and a diagnostics export instead of Hub errors from
+ * whichever feature they tried first.
+ */
+export function HubStartupScreen({
+ transport,
+ hub,
+}: ReturnType) {
+ const connected = transport === "connected";
+ const [backendUnresponsive, setBackendUnresponsive] = useState(false);
+ const [exportStatus, setExportStatus] = useState(null);
+
+ // The client keeps reconnecting on its own; only call it out once it has
+ // been unreachable for a while.
+ useEffect(() => {
+ setBackendUnresponsive(false);
+ if (connected) return;
+ const timer = setTimeout(
+ () => setBackendUnresponsive(true),
+ BACKEND_UNRESPONSIVE_MS,
+ );
+ return () => clearTimeout(timer);
+ }, [connected]);
+
+ const exportDiagnostics = async () => {
+ setExportStatus("Exporting diagnostics…");
+ try {
+ const result = await desktopClient.invoke<{ path: string }>(
+ "export_diagnostics",
+ );
+ setExportStatus(`Diagnostics saved to ${result.path}`);
+ } catch (error) {
+ setExportStatus(error instanceof Error ? error.message : String(error));
+ }
+ };
+
+ let content: ReactNode;
+ if (!connected && backendUnresponsive) {
+ content = (
+ <>
+