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 = ( + <> +

+ Cline's desktop backend isn't responding +

+

+ Cline keeps trying in the background. Reload to try again now. +

+ + + ); + } else if (connected && hub.state === "failed") { + content = ( + <> +

+ Cline couldn't connect to its Hub +

+

+ {hub.error} Retry, or export diagnostics and share the file with the + Cline team if this keeps happening. +

+
+ + +
+ {exportStatus ? ( +

+ {exportStatus} +

+ ) : null} + + ); + } else { + content = ( + <> +