diff --git a/.cline/skills/publish-desktop/SKILL.md b/.cline/skills/publish-desktop/SKILL.md index d35982bacd..76c7f4f802 100644 --- a/.cline/skills/publish-desktop/SKILL.md +++ b/.cline/skills/publish-desktop/SKILL.md @@ -1,11 +1,11 @@ --- name: publish-desktop -description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed. +description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed. --- # Desktop App Release -Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow. +Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow. > Working directory: run every command below from the repository root. @@ -14,8 +14,8 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG ## Release contract - Two channels, one workflow (`channel` input on `desktop-publish.yml`): - - **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code". - - **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`. + - **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline". + - **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`. - Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.) - Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`). - Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta. diff --git a/.github/workflows/cli-publish.yml b/.github/workflows/cli-publish.yml index 024910db07..6230d6e1e2 100644 --- a/.github/workflows/cli-publish.yml +++ b/.github/workflows/cli-publish.yml @@ -206,6 +206,8 @@ jobs: - name: Get Changelog Entry id: changelog + env: + RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }} run: | # Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md) @@ -213,6 +215,32 @@ jobs: echo "$CONTENT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT + # Slack section blocks reject text longer than 3000 characters, and the + # Slack action logs that rejection WITHOUT failing the step - so an + # over-long changelog silently drops the release announcement while the + # run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack + # and link out to the full notes. The GitHub release body stays whole. + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + echo "slack_content<> $GITHUB_OUTPUT + echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT + echo "SLACK_EOF" >> $GITHUB_OUTPUT + - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: @@ -248,7 +276,7 @@ jobs: - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 54f0bb5742..e04c10449a 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -111,7 +111,7 @@ jobs: fi ANCESTOR_REF=main FEED=desktop-latest - PRODUCT="Cline Code" + PRODUCT="Cline" ;; beta) if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then @@ -120,7 +120,7 @@ jobs: fi ANCESTOR_REF=desktop-experimental FEED=desktop-beta - PRODUCT="Cline Code Beta" + PRODUCT="Cline Beta" ;; *) echo "unknown channel: ${CHANNEL}" @@ -435,7 +435,7 @@ jobs: OUT="dist/publish" mkdir -p "$OUT" - # "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta + # "Cline" -> Cline, "Cline Beta" -> Cline-Beta PREFIX="${PRODUCT// /-}" DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit) @@ -506,6 +506,33 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md" + # Slack section blocks reject text longer than 3000 characters, and the + # Slack action logs that rejection WITHOUT failing the step - so an + # over-long changelog silently drops the release announcement while the + # run stays green. Post a trimmed copy to Slack and link out to the full + # notes. The GitHub release body and updater manifest stay whole. + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}" + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + echo "slack_content<> $GITHUB_OUTPUT + echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT + echo "SLACK_EOF" >> $GITHUB_OUTPUT + - name: Generate updater manifest env: VERSION: ${{ needs.validate.outputs.version }} @@ -580,14 +607,14 @@ jobs: if ! gh release view "$FEED" >/dev/null 2>&1; then if [ "$CHANNEL" = "beta" ]; then gh release create "$FEED" \ - --title "Cline Code desktop beta (auto-update feed)" \ + --title "Cline desktop beta (auto-update feed)" \ --notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \ --latest=false \ --prerelease \ --target "$(git rev-parse HEAD)" else gh release create "$FEED" \ - --title "Cline Code desktop (auto-update feed)" \ + --title "Cline desktop (auto-update feed)" \ --notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \ --latest=false \ --target "$(git rev-parse HEAD)" @@ -601,7 +628,7 @@ jobs: TAG: ${{ needs.validate.outputs.tag }} FEED: ${{ needs.validate.outputs.feed }} run: | - echo "Published Cline Code desktop v${VERSION}" + echo "Published Cline desktop v${VERSION}" echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json" @@ -612,16 +639,16 @@ jobs: token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }} payload: | channel: "C0APVKGGZFC" - text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}" + text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}" blocks: - type: "section" text: type: "mrkdwn" - text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}" + text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}" - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/.github/workflows/ext-vscode-ab-package.yml b/.github/workflows/ext-vscode-ab-package.yml index 3da6cb887c..b5a45ce137 100644 --- a/.github/workflows/ext-vscode-ab-package.yml +++ b/.github/workflows/ext-vscode-ab-package.yml @@ -150,14 +150,12 @@ jobs: id: rev run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Deliberately no dependency cache here: publish workflows do clean + # installs and should not restore actions caches. - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 - cache: "npm" - cache-dependency-path: | - apps/vscode/package-lock.json - apps/vscode/webview-ui/package-lock.json - name: Install extension dependencies working-directory: ${{ github.workspace }} @@ -492,6 +490,35 @@ jobs: echo "CHANGELOG_EOF" } >> "$GITHUB_OUTPUT" + # Slack section blocks reject text longer than 3000 characters, and + # the Slack action logs that rejection WITHOUT failing the step - so + # an over-long changelog silently drops the release announcement + # while the run stays green. Post a trimmed copy to Slack and link + # out to the full notes. The GitHub release body stays whole. + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}" + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + { + echo "slack_content<> "$GITHUB_OUTPUT" + - name: Resolve previous release tag id: prev_tag if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} @@ -547,7 +574,7 @@ jobs: - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/.github/workflows/ext-vscode-publish-legacy.yml b/.github/workflows/ext-vscode-publish-legacy.yml index f2c37229a8..a60850f86b 100644 --- a/.github/workflows/ext-vscode-publish-legacy.yml +++ b/.github/workflows/ext-vscode-publish-legacy.yml @@ -58,14 +58,12 @@ jobs: with: ref: legacy-extension + # Deliberately no dependency cache here: publish workflows do clean + # installs and should not restore actions caches. - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22 - cache: 'npm' - cache-dependency-path: | - apps/vscode/package-lock.json - apps/vscode/webview-ui/package-lock.json - name: Install extension dependencies working-directory: ${{ github.workspace }} @@ -266,6 +264,33 @@ jobs: echo "$CONTENT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT + # Slack section blocks reject text longer than 3000 characters, and + # the Slack action logs that rejection WITHOUT failing the step - so + # an over-long changelog silently drops the release announcement + # while the run stays green. Post a trimmed copy to Slack and link + # out to the full notes. The GitHub release body stays whole. + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}" + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + echo "slack_content<> $GITHUB_OUTPUT + echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT + echo "SLACK_EOF" >> $GITHUB_OUTPUT + - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: @@ -295,7 +320,7 @@ jobs: - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/.github/workflows/ext-vscode-publish-stable.yml b/.github/workflows/ext-vscode-publish-stable.yml index 0dc22b19e8..40fe64f212 100644 --- a/.github/workflows/ext-vscode-publish-stable.yml +++ b/.github/workflows/ext-vscode-publish-stable.yml @@ -234,6 +234,33 @@ jobs: echo "$CONTENT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT + # Slack section blocks reject text longer than 3000 characters, and + # the Slack action logs that rejection WITHOUT failing the step - so + # an over-long changelog silently drops the release announcement + # while the run stays green. Post a trimmed copy to Slack and link + # out to the full notes. The GitHub release body stays whole. + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}" + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + echo "slack_content<> $GITHUB_OUTPUT + echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT + echo "SLACK_EOF" >> $GITHUB_OUTPUT + - name: Package and Publish Extension env: VSCE_PAT: ${{ secrets.VSCE_PAT }} @@ -303,7 +330,7 @@ jobs: - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/.github/workflows/ext-vscode-test-e2e.yml b/.github/workflows/ext-vscode-test-e2e.yml index 971afe8eed..d47157c6ce 100644 --- a/.github/workflows/ext-vscode-test-e2e.yml +++ b/.github/workflows/ext-vscode-test-e2e.yml @@ -80,8 +80,9 @@ jobs: include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }} runs-on: ${{ matrix.runner }}-latest timeout-minutes: 20 + # Nothing in this job uses OIDC, so it does not need an id-token + # permission. permissions: - id-token: write contents: read defaults: run: @@ -93,6 +94,9 @@ jobs: with: bun-version: 1.3.14 + # Cache keys below are exact-match only (no restore-keys prefix + # fallbacks); a miss just means a cold install, which is acceptable. + # Cache Bun's global install cache - keyed on the authoritative root bun.lock. - name: Cache Bun install cache uses: actions/cache@v4 @@ -100,8 +104,6 @@ jobs: with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- # Cache VS Code installation - name: Cache VS Code @@ -110,8 +112,6 @@ jobs: with: path: apps/vscode/.vscode-test key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }} - restore-keys: | - vscode-${{ runner.os }}-stable- # Cache Playwright browsers - name: Cache Playwright browsers @@ -123,8 +123,6 @@ jobs: ~/Library/Caches/ms-playwright ~/AppData/Local/ms-playwright key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }} - restore-keys: | - playwright-browsers-${{ runner.os }}- # Single root install resolves the entire bun workspace at once (replaces # the per-package `npm ci` steps for apps/vscode + webview-ui). diff --git a/.github/workflows/sdk-publish.yml b/.github/workflows/sdk-publish.yml index e7376da554..314770d137 100644 --- a/.github/workflows/sdk-publish.yml +++ b/.github/workflows/sdk-publish.yml @@ -282,6 +282,33 @@ jobs: echo "$CONTENT" >> $GITHUB_OUTPUT echo "${DELIMITER}" >> $GITHUB_OUTPUT + # Slack section blocks reject text longer than 3000 characters, and the + # Slack action logs that rejection WITHOUT failing the step - so an + # over-long changelog silently drops the release announcement while the + # run stays green. Post a trimmed copy to Slack and link out to the full + # notes. The GitHub release body stays whole. + RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}" + SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c ' + import os + content = os.environ["CONTENT"] + more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"] + if len(content) <= 3000: + print(content, end="") + else: + budget = 3000 - len(more) + kept, used = [], 0 + for line in content.splitlines(keepends=True): + if used + len(line) > budget: + break + kept.append(line) + used += len(line) + body = "".join(kept).rstrip() if kept else content[:budget].rstrip() + print(body + more, end="") + ') + echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT + echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT + echo "${DELIMITER}" >> $GITHUB_OUTPUT + - name: Create GitHub Release if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest' uses: softprops/action-gh-release@v1 @@ -333,7 +360,7 @@ jobs: - type: "section" text: type: "mrkdwn" - text: ${{ toJSON(steps.changelog.outputs.content) }} + text: ${{ toJSON(steps.changelog.outputs.slack_content) }} - type: "context" elements: - type: "mrkdwn" diff --git a/apps/cli/src/commands/schedule.test.ts b/apps/cli/src/commands/schedule.test.ts index 3a1db71525..db1fed0804 100644 --- a/apps/cli/src/commands/schedule.test.ts +++ b/apps/cli/src/commands/schedule.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createScheduleCommand } from "./schedule"; -const mockSendHubCommand = vi.hoisted(() => vi.fn()); +const mockHubClientCommand = vi.hoisted(() => vi.fn()); +const mockNodeHubClientCtor = vi.hoisted(() => vi.fn()); const mockEnsureCliHubServer = vi.hoisted(() => vi.fn()); const mockProviderSettings = vi.hoisted(() => ({ lastUsed: undefined as { provider?: string; model?: string } | undefined, @@ -16,7 +17,17 @@ vi.mock("@cline/core", async () => { await vi.importActual("@cline/core"); return { ...actual, - sendHubCommand: mockSendHubCommand, + NodeHubClient: class { + command = mockHubClientCommand; + + constructor(options: Record) { + mockNodeHubClientCtor(options); + } + + async connect(): Promise {} + + close(): void {} + }, ProviderSettingsManager: class { getLastUsedProviderSettings() { return mockProviderSettings.lastUsed; @@ -74,7 +85,7 @@ describe("runScheduleCommand list output", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedules: [] }, }); @@ -96,18 +107,21 @@ describe("runScheduleCommand list output", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(["No schedules found."]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.list", - payload: { - limit: 100, - enabled: undefined, - tags: undefined, - }, - }, + // Schedule commands are workspace-scoped: the hub client must register + // with a workspace context (and the hub auth token) before commanding. + expect(mockNodeHubClientCtor).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:25463/hub", + workspaceRoot: process.cwd(), + cwd: process.cwd(), + authToken: "test-token", + }), ); + expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", { + limit: 100, + enabled: undefined, + tags: undefined, + }); }); it("keeps JSON list output unchanged when --json is provided", async () => { @@ -115,7 +129,7 @@ describe("runScheduleCommand list output", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedules: [] }, }); @@ -137,7 +151,7 @@ describe("runScheduleCommand list output", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(["[]"]); - expect(mockSendHubCommand).toHaveBeenCalled(); + expect(mockHubClientCommand).toHaveBeenCalled(); }); }); @@ -157,7 +171,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -189,15 +203,19 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, + expect(mockNodeHubClientCtor).toHaveBeenCalledWith( expect.objectContaining({ - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), + url: "ws://127.0.0.1:25463/hub", + workspaceRoot: "/tmp/workspace", + cwd: "/tmp/workspace", + authToken: "test-token", + }), + ); + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + provider: "anthropic", + model: "claude-sonnet-4-6", }), ); }); @@ -215,7 +233,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -246,14 +264,11 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", expect.objectContaining({ - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), + provider: "anthropic", + model: "claude-sonnet-4-6", }), ); }); @@ -292,7 +307,7 @@ describe("runScheduleCommand create", () => { expect(errors).toEqual([ 'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.', ]); - expect(mockSendHubCommand).not.toHaveBeenCalled(); + expect(mockHubClientCommand).not.toHaveBeenCalled(); }); it("maps --delivery-bot to delivery.userName", async () => { @@ -300,7 +315,7 @@ describe("runScheduleCommand create", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_delivery" } }, }); @@ -339,21 +354,17 @@ describe("runScheduleCommand create", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - metadata: { - delivery: { - adapter: "telegram", - threadId: "telegram:123456789", - userName: "my_bot", - }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + metadata: { + delivery: { + adapter: "telegram", + threadId: "telegram:123456789", + userName: "my_bot", }, - }), - }, + }, + }), ); }); }); @@ -370,7 +381,7 @@ describe("runScheduleCommand import", () => { url: "ws://127.0.0.1:25463/hub", authToken: "test-token", }); - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: { scheduleId: "sched_123" } }, }); @@ -411,16 +422,12 @@ describe("runScheduleCommand import", () => { expect(code).toBe(0); expect(errors).toEqual([]); expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.create", - payload: expect.objectContaining({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }), - }, + expect(mockHubClientCommand).toHaveBeenCalledWith( + "schedule.create", + expect.objectContaining({ + provider: "anthropic", + model: "claude-sonnet-4-6", + }), ); }); }); @@ -444,7 +451,7 @@ describe("runScheduleCommand export", () => { prompt: "review status", workspaceRoot: "/tmp/workspace", }; - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: scheduleRecord }, }); @@ -484,14 +491,9 @@ describe("runScheduleCommand export", () => { const written = await readFile(targetPath, "utf8"); expect(written).toBe(JSON.stringify(scheduleRecord, null, 2)); - expect(mockSendHubCommand).toHaveBeenCalledWith( - { host: "127.0.0.1", port: 25463, pathname: "/hub" }, - { - clientId: "cline-schedule", - command: "schedule.get", - payload: { scheduleId: "sched_abc" }, - }, - ); + expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", { + scheduleId: "sched_abc", + }); } finally { await rm(targetPath, { force: true }); } @@ -507,7 +509,7 @@ describe("runScheduleCommand export", () => { name: "Weekly Sync", cronPattern: "0 9 * * 1", }; - mockSendHubCommand.mockResolvedValue({ + mockHubClientCommand.mockResolvedValue({ ok: true, payload: { schedule: scheduleRecord }, }); diff --git a/apps/cli/src/commands/schedule/client.ts b/apps/cli/src/commands/schedule/client.ts index 23da54e897..b45e857981 100644 --- a/apps/cli/src/commands/schedule/client.ts +++ b/apps/cli/src/commands/schedule/client.ts @@ -2,7 +2,7 @@ import { createLocalHubScheduleRuntimeHandlers, HubScheduleCommandService, HubScheduleService, - sendHubCommand, + NodeHubClient, } from "@cline/core"; import { ensureCliHubServer, @@ -11,28 +11,51 @@ import { import type { CommandIo } from "./types"; export class HubScheduleClient { + private hub: Promise | undefined; + constructor( - private readonly endpoint: { - host?: string; - port?: number; - pathname?: string; - }, + private readonly url: string, + private readonly workspaceRoot: string, + private readonly authToken?: string, ) {} - close(): void {} + close(): void { + const hub = this.hub; + this.hub = undefined; + void hub?.then((client) => client.close()).catch(() => undefined); + } + + // Schedule commands are authorized against the workspace bound to the + // connection's client registration, so all commands must share one + // registered connection instead of fire-and-forget envelopes. + private connectedHub(): Promise { + this.hub ??= (async () => { + const client = new NodeHubClient({ + url: this.url, + clientType: "cli-schedule", + displayName: "Cline CLI scheduler", + workspaceRoot: this.workspaceRoot, + cwd: this.workspaceRoot, + authToken: this.authToken, + }); + try { + await client.connect(); + } catch (error) { + client.close(); + this.hub = undefined; + throw error; + } + return client; + })(); + return this.hub; + } private async command( command: string, payload?: Record, ): Promise> { - const reply = await sendHubCommand(this.endpoint, { - clientId: "cline-schedule", - command: command as never, - payload, - }); - if (!reply.ok) { - throw new Error(reply.error?.message ?? `hub command failed: ${command}`); - } + const client = await this.connectedHub(); + const reply = await client.command(command as never, payload); return (reply.payload ?? {}) as Record; } @@ -97,6 +120,7 @@ export class LocalScheduleClient { runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), }); private readonly commands = new HubScheduleCommandService(this.service); + constructor(private readonly workspaceRoot: string) {} close(): void { void this.service.dispose(); @@ -106,12 +130,21 @@ export class LocalScheduleClient { command: string, payload?: Record, ): Promise> { - const reply = await this.commands.handleCommand({ - version: "v1", - clientId: "cline-schedule-local", - command: command as never, - payload, - }); + const reply = await this.commands.handleCommand( + { + version: "v1", + clientId: "cline-schedule-local", + command: command as never, + payload, + }, + { + clientId: "cline-schedule-local", + workspaceContext: { + workspaceRoot: this.workspaceRoot, + cwd: this.workspaceRoot, + }, + }, + ); if (!reply.ok) { throw new Error(reply.error?.message ?? `hub command failed: ${command}`); } @@ -185,24 +218,27 @@ export async function ensureSchedulerHub( if (!address?.trim()) { return { ok: true, - client: new LocalScheduleClient() as unknown as HubScheduleClient, + client: new LocalScheduleClient( + workspaceRoot, + ) as unknown as HubScheduleClient, }; } try { const requestedEndpoint = parseHubEndpointOverride(address); - const { url: hubUrl } = await ensureCliHubServer( + const { url: hubUrl, authToken } = await ensureCliHubServer( workspaceRoot, requestedEndpoint, ); - const endpoint = parseHubEndpointOverride(hubUrl); return { ok: true, - client: new HubScheduleClient(endpoint), + client: new HubScheduleClient(hubUrl, workspaceRoot, authToken), }; } catch (_error) { return { ok: true, - client: new LocalScheduleClient() as unknown as HubScheduleClient, + client: new LocalScheduleClient( + workspaceRoot, + ) as unknown as HubScheduleClient, }; } } diff --git a/apps/cline-hub/src/server/desktop-commands.ts b/apps/cline-hub/src/server/desktop-commands.ts index 47702d350c..d277e0d241 100644 --- a/apps/cline-hub/src/server/desktop-commands.ts +++ b/apps/cline-hub/src/server/desktop-commands.ts @@ -272,7 +272,7 @@ export async function handleDesktopCommand( return path; } if (ROUTINE_SCHEDULE_COMMANDS.has(command)) { - return await handleRoutineScheduleCommand(command, args); + return await handleRoutineScheduleCommand(command, args, workspaceRoot); } if (command === "get_process_context") { return { workspaceRoot, cwd: workspaceRoot }; diff --git a/apps/cline-hub/src/server/schedules.ts b/apps/cline-hub/src/server/schedules.ts index b32ec1bec9..03c4d9ca7b 100644 --- a/apps/cline-hub/src/server/schedules.ts +++ b/apps/cline-hub/src/server/schedules.ts @@ -27,13 +27,20 @@ function getCommands(): HubScheduleCommandService { async function clientCommand( hubCommand: string, payload?: Record, + workspaceRoot = process.cwd(), ): Promise> { - const reply = await getCommands().handleCommand({ - version: "v1", - clientId: "cline-hub-schedules", - command: hubCommand as never, - payload, - }); + const reply = await getCommands().handleCommand( + { + version: "v1", + clientId: "cline-hub-schedules", + command: hubCommand as never, + payload, + }, + { + clientId: "cline-hub-schedules", + workspaceContext: { workspaceRoot, cwd: workspaceRoot }, + }, + ); if (!reply.ok) { throw new Error( reply.error?.message ?? `hub command failed: ${hubCommand}`, @@ -70,14 +77,17 @@ function asTrimmedStringArray(value: unknown): string[] | undefined { export async function handleRoutineScheduleCommand( command: string, args?: Record, + workspaceRoot = process.cwd(), ): Promise { + const commandHub = (hubCommand: string, payload?: Record) => + clientCommand(hubCommand, payload, workspaceRoot); if (command === "list_routine_schedules") { const [schedules, activeExecutions, upcomingRuns] = await Promise.all([ - clientCommand("schedule.list", { + commandHub("schedule.list", { limit: toPositiveInt(args?.limit) ?? 200, }), - clientCommand("schedule.active"), - clientCommand("schedule.upcoming", { limit: 30 }), + commandHub("schedule.active"), + commandHub("schedule.upcoming", { limit: 30 }), ]); const scheduleRows = Array.isArray(schedules.schedules) ? schedules.schedules @@ -88,7 +98,7 @@ export async function handleRoutineScheduleCommand( (schedule as Record).scheduleId, ); if (!scheduleId) return undefined; - const reply = await clientCommand("schedule.list_executions", { + const reply = await commandHub("schedule.list_executions", { scheduleId, limit: 1, }); @@ -115,7 +125,7 @@ export async function handleRoutineScheduleCommand( "createSchedule requires name, timing, prompt, and workspace_root", ); } - const created = await clientCommand("schedule.create", { + const created = await commandHub("schedule.create", { name, ...timing, prompt, @@ -149,7 +159,7 @@ export async function handleRoutineScheduleCommand( "updateSchedule requires schedule_id, name, timing, prompt, and workspace_root", ); } - const reply = await clientCommand("schedule.update", { + const reply = await commandHub("schedule.update", { scheduleId, name, ...timing, @@ -180,25 +190,25 @@ export async function handleRoutineScheduleCommand( return { schedule: reply.schedule ?? null }; } if (command === "pause_routine_schedule") { - const reply = await clientCommand("schedule.disable", { scheduleId }); + const reply = await commandHub("schedule.disable", { scheduleId }); return { schedule: reply.schedule ?? null }; } if (command === "resume_routine_schedule") { - const reply = await clientCommand("schedule.enable", { scheduleId }); + const reply = await commandHub("schedule.enable", { scheduleId }); return { schedule: reply.schedule ?? null }; } if (command === "trigger_routine_schedule") { - const existing = await clientCommand("schedule.get", { scheduleId }); + const existing = await commandHub("schedule.get", { scheduleId }); if (!existing.schedule) throw new Error(`schedule not found: ${scheduleId}`); - const reply = await clientCommand("schedule.trigger", { + const reply = await commandHub("schedule.trigger", { scheduleId, wait: false, }); return { execution: reply.execution ?? null }; } if (command === "delete_routine_schedule") { - const reply = await clientCommand("schedule.delete", { scheduleId }); + const reply = await commandHub("schedule.delete", { scheduleId }); return { deleted: reply.deleted === true }; } throw new Error(`unsupported routine schedule command: ${command}`); diff --git a/apps/cline-hub/src/server/session-mapping.ts b/apps/cline-hub/src/server/session-mapping.ts index 04ad70ea93..068dd3a725 100644 --- a/apps/cline-hub/src/server/session-mapping.ts +++ b/apps/cline-hub/src/server/session-mapping.ts @@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): { normalizedType === "code-sidecar-observer" || normalizedType === "code-sidecar-list" ) { - return { key: "code-app", label: "Code App", name: "Code App" }; + return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" }; } return { key: client.clientId, diff --git a/apps/examples/desktop-app/CHANGELOG.md b/apps/examples/desktop-app/CHANGELOG.md index 4c36bd9ec2..93f0e12c1f 100644 --- a/apps/examples/desktop-app/CHANGELOG.md +++ b/apps/examples/desktop-app/CHANGELOG.md @@ -1,13 +1,11 @@ -# Cline Code Desktop Changelog +# Cline Desktop Changelog -## 0.0.14-beta.1 +## 0.0.15-beta.1 -- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected. -- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings. -- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing. -- Onboarding now includes a GitHub integration step. -- Early proof of concept for running sessions in SSH remote environments. -- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish. +- Beta: hand off a local session to Cline Cloud with `/handoff` — the conversation, attached images, and an optional follow-up command move to a cloud workspace that keeps working after you close the app. A preflight confirms the repository, branch, and commit are pushed; the composer shows handoff progress and finishes with a receipt linking to the cloud session. +- Beta: Cloud now lives in the existing Local / Remote environment menu. It is selectable when the Cloud sessions feature flag is on and shows as "Coming soon" otherwise. The separate Local / Cloud toggle is gone; repository and branch controls are unchanged. +- If a handoff is interrupted — the app restarts, the network drops, or the branch moves mid-transfer — reopening the session recovers or cleanly retries it, and your typed draft and attachments are restored. +- Includes the 0.0.14 stable release and everything on main since: the unified Plugins hub with a Marketplace page, recommended and free model tiers in the model picker, scheduled tasks for agents, and the app rename to "Cline" (this beta is now "Cline Beta"). ## 0.0.14 @@ -33,6 +31,15 @@ - Fixed misaligned columns in the Usage table, and added a See More link to the full usage dashboard. - Fixed routine dialog dropdowns not responding to mouse clicks. +## 0.0.14-beta.1 + +- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected. +- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings. +- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing. +- Onboarding now includes a GitHub integration step. +- Early proof of concept for running sessions in SSH remote environments. +- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish. + ## 0.0.13 - Added an app font size setting. A slider in Settings scales the interface, and your size is applied before the window paints, so launching no longer flashes at the old size first. diff --git a/apps/examples/desktop-app/EXPERIMENTAL.md b/apps/examples/desktop-app/EXPERIMENTAL.md index 087ba7e828..d532aa13ad 100644 --- a/apps/examples/desktop-app/EXPERIMENTAL.md +++ b/apps/examples/desktop-app/EXPERIMENTAL.md @@ -1,7 +1,7 @@ # Desktop Experimental Branch & Beta Channel How experimental desktop features are developed on the `desktop-experimental` -branch, shipped to users as **Cline Code Beta**, and graduated into `main`. +branch, shipped to users as **Cline Beta**, and graduated into `main`. The release mechanics (workflow internals, secrets) live in [`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml) and the `publish-desktop` skill @@ -12,8 +12,8 @@ this doc is the process. The beta is a **separate app**, not a mode of the stable app: -- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta` - (stable is `Cline Code` / `bot.cline.app`) — set by +- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta` + (stable is `Cline` / `bot.cline.app`) — set by [`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which is layered over `tauri.release.conf.json` at build time. - Both apps install and run **side by side**, so people can compare beta diff --git a/apps/examples/desktop-app/README.md b/apps/examples/desktop-app/README.md index e3a95dc222..de4888d537 100644 --- a/apps/examples/desktop-app/README.md +++ b/apps/examples/desktop-app/README.md @@ -6,8 +6,9 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin From `apps/examples/desktop-app/`: -- `bun run dev:web` - Next.js UI only (`http://localhost:3125`) -- `bun run dev:sidecar` - sidecar backend only +- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential +- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app) +- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app) - `bun run dev` - Tauri desktop dev - `bun run build` - build web assets - `bun run build:sidecar` - build the Bun sidecar bundle @@ -108,7 +109,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it, shipped apps can't verify new updates). -There is also a beta channel ("Cline Code Beta", a separate app that installs +There is also a beta channel ("Cline Beta", a separate app that installs side by side with stable) cut from the `desktop-experimental` branch and served by the rolling `desktop-beta` release — the same never-delete rule applies to it. The experimental-branch process and beta release flow live in diff --git a/apps/examples/desktop-app/package.json b/apps/examples/desktop-app/package.json index 3ea0845fb2..2c87222ddd 100644 --- a/apps/examples/desktop-app/package.json +++ b/apps/examples/desktop-app/package.json @@ -1,12 +1,14 @@ { "name": "@cline/code", - "version": "0.0.14-beta.1", + "version": "0.0.15-beta.1", "private": true, "scripts": { "build:ui": "bun -F @cline/ui build", "predev:web": "bun run build:ui", "dev:web": "next dev webview -p 3125 --turbo", "dev:sidecar": "bun run sidecar/index.ts", + "predev:headless": "bun run build:ui", + "dev:headless": "bun run scripts/dev-headless.ts", "dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "prebuild": "bun run build:ui", "build": "bun run bun.mts", @@ -36,6 +38,7 @@ "@cline/shared": "workspace:*", "@cline/ui": "workspace:*", "@pierre/diffs": "^1.3.0", + "ai": "^7.0.58", "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@hookform/resolvers": "^3.9.1", @@ -71,7 +74,6 @@ "@streamdown/cjk": "^1.0.3", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-notification": "^2.0.0", - "ai": "^7.0.58", "autoprefixer": "^10.4.20", "ansi-to-react": "^6.2.6", "class-variance-authority": "^0.7.1", diff --git a/apps/examples/desktop-app/scripts/dev-headless.ts b/apps/examples/desktop-app/scripts/dev-headless.ts new file mode 100644 index 0000000000..cf64c513f4 --- /dev/null +++ b/apps/examples/desktop-app/scripts/dev-headless.ts @@ -0,0 +1,62 @@ +import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; + +const approvalToken = randomUUID(); +const children: ReturnType[] = []; + +async function reserveAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to reserve a sidecar port")); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function spawn(command: string[], env: Record) { + const child = Bun.spawn(command, { + cwd: import.meta.dir + "/..", + env: { ...process.env, ...env }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + children.push(child); + return child; +} + +function stopChildren(): void { + for (const child of children) { + if (!child.killed) child.kill(); + } +} + +process.on("SIGINT", stopChildren); +process.on("SIGTERM", stopChildren); + +async function main(): Promise { + const sidecarPort = await reserveAvailablePort(); + const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`; + const sidecar = spawn(["bun", "run", "sidecar/index.ts"], { + CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken, + CLINE_SIDECAR_PORT: String(sidecarPort), + }); + const web = spawn( + ["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"], + { NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint }, + ); + + const exitCode = await Promise.race([sidecar.exited, web.exited]); + stopChildren(); + await Promise.allSettled(children.map((child) => child.exited)); + process.exit(exitCode); +} + +void main(); diff --git a/apps/examples/desktop-app/scripts/generate-update-manifest.ts b/apps/examples/desktop-app/scripts/generate-update-manifest.ts index 733c8da565..3ae65544f8 100644 --- a/apps/examples/desktop-app/scripts/generate-update-manifest.ts +++ b/apps/examples/desktop-app/scripts/generate-update-manifest.ts @@ -121,7 +121,7 @@ const main = () => { const notes = notesFile ? readFileSync(notesFile, "utf8").trim() - : `Cline Code v${version}`; + : `Cline v${version}`; const manifest = buildUpdateManifest({ version, diff --git a/apps/examples/desktop-app/scripts/package-desktop.ts b/apps/examples/desktop-app/scripts/package-desktop.ts index deeb13ce2c..0432c545f7 100644 --- a/apps/examples/desktop-app/scripts/package-desktop.ts +++ b/apps/examples/desktop-app/scripts/package-desktop.ts @@ -15,7 +15,7 @@ const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]); const VALUE_FLAGS = new Set(["--platform", "--target"]); const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS]; -const APP_NAME = "Cline Code"; +const APP_NAME = "Cline"; const APP_ROOT = path.resolve(import.meta.dir, ".."); const BUNDLE_ROOT = path.join( APP_ROOT, diff --git a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md index 7292ca8e52..eb22c16c89 100644 --- a/apps/examples/desktop-app/sidecar/ARCHITECTURE.md +++ b/apps/examples/desktop-app/sidecar/ARCHITECTURE.md @@ -53,7 +53,7 @@ const sessionManager = await ClineCore.create({ workspaceRoot, cwd: workspaceRoot, clientType: "code-sidecar", - displayName: "Code App sidecar", + displayName: "Cline Desktop sidecar", }, capabilities: { requestToolApproval: async (request) => { @@ -195,7 +195,8 @@ provider credentials remain in the sidecar. ## Dev Workflow ```bash -bun run dev:sidecar # Start sidecar on port 3126 -bun run dev:web # Start Next.js on port 3125 +bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential +bun run dev:sidecar # Start only the sidecar (no browser approval surface) +bun run dev:web # Start only Next.js (no authenticated approval connection) bun run dev # Both concurrently ``` diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.ts index cac14ecfec..291feff7ff 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.ts @@ -2597,7 +2597,7 @@ export class CloudSessionManager { url: toWebSocketUrl(this.options.apiBaseUrl, outerSessionId), clientId: `code-cloud-${outerSessionId}`, clientType: "code-cloud-sidecar", - displayName: "Cline Code cloud session", + displayName: "Cline cloud session", workspaceRoot: CLOUD_WORKSPACE_ROOT, cwd: CLOUD_WORKSPACE_ROOT, resolveConnectionHeaders: async () => { diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 27f7136797..a728afff6c 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -26,6 +26,7 @@ import { createUserInstructionConfigService, ensureCustomProvidersLoaded, executeClineAccountAction, + fetchClineRecommendedModels, getCoreBuiltinToolCatalog, getLocalProviderModels, listHookConfigFiles, @@ -92,6 +93,7 @@ import { findSessionRuntimeBinding, getRuntimeBinding, resolveSidecarAskQuestion, + sendEventToClient, } from "./context"; import { readDesktopSettings, @@ -145,6 +147,7 @@ import type { ChatSessionCommandRequest, JsonRecord, SidecarContext, + SidecarWebSocketClient, } from "./types"; import { LOCAL_ENVIRONMENT_ID } from "./types"; import { pickWorkspaceDirectory } from "./workspace-picker"; @@ -1111,6 +1114,43 @@ async function handleRoutineScheduleCommand( throw new Error(`unsupported routine schedule command: ${command}`); } +// --------------------------------------------------------------------------- +// Agenda task queue helpers (in-process via shared hub server) +// --------------------------------------------------------------------------- + +const AGENDA_TASK_COMMANDS = new Set([ + "task.create", + "task.list", + "task.get", + "task.update", + "task.approve", + "task.cancel", + "task.run", + "task.automation.get", + "task.automation.set", +]); + +const AGENDA_TASK_EXECUTION_COMMANDS = new Set([ + "task.create", + "task.approve", + "task.cancel", + "task.run", + "task.automation.set", +]); + +async function handleAgendaTaskCommand( + ctx: SidecarContext, + command: string, + args?: Record, +): Promise { + const hubClient = await ensureSharedHubClient(ctx); + const reply = await hubClient.command(command as never, args); + if (!reply.ok) { + throw new Error(reply.error?.message ?? `hub command failed: ${command}`); + } + return reply.payload ?? {}; +} + // --------------------------------------------------------------------------- // User instruction config listing through the core config service. // --------------------------------------------------------------------------- @@ -1519,7 +1559,7 @@ export async function handleCommand( ctx: SidecarContext, command: string, args?: Record, - options?: { connection?: object }, + options?: { connection?: SidecarWebSocketClient }, ): Promise { // ── SSH remote environments ────────────────────────────────────── if (command === "list_remote_environments") { @@ -1856,8 +1896,16 @@ export async function handleCommand( // ── Tool approvals (in-memory) ──────────────────────────────────── if (command === "poll_tool_approvals") { const sessionId = String(args?.sessionId ?? "").trim(); + const connection = options?.connection; + if (!connection?.data?.canApproveTools) { + throw new Error("tool approvals require a trusted desktop connection"); + } return Array.from(ctx.pendingApprovals.values()) - .filter((a) => a.item.sessionId === sessionId) + .filter( + (a) => + (!a.owner || a.owner === connection) && + a.item.sessionId === sessionId, + ) .map((a) => a.item); } if (command === "respond_tool_approval") { @@ -1866,20 +1914,36 @@ export async function handleCommand( if (!sessionId || !requestId) { throw new Error("sessionId and requestId are required"); } - const pending = ctx.pendingApprovals.get(requestId); - if (pending) { - await pending.resolve({ - approved: Boolean(args?.approved), - ...(typeof args?.reason === "string" && args.reason.trim().length > 0 - ? { reason: args.reason.trim() } - : {}), - }); + const connection = options?.connection; + if (!connection?.data?.canApproveTools) { + throw new Error("tool approvals require a trusted desktop connection"); } + const pending = ctx.pendingApprovals.get(requestId); + // Ownerless approvals are cloud-session relays: any trusted surface may + // answer them (the pod outlives individual desktop connections). + if (!pending || (pending.owner && pending.owner !== connection)) { + throw new Error("tool approval does not belong to this connection"); + } + if (pending.item.sessionId !== sessionId) { + throw new Error("tool approval does not belong to this session"); + } + // Cloud approvals resolve asynchronously (they relay the answer to the + // pod) and may throw; keep the entry pending if the relay fails. + await pending.resolve({ + approved: Boolean(args?.approved), + ...(typeof args?.reason === "string" && args.reason.trim().length > 0 + ? { reason: args.reason.trim() } + : {}), + }); ctx.pendingApprovals.delete(requestId); const remaining = Array.from(ctx.pendingApprovals.values()) - .filter((a) => a.item.sessionId === sessionId) + .filter( + (a) => + (!a.owner || a.owner === connection) && + a.item.sessionId === sessionId, + ) .map((a) => a.item); - broadcastEvent(ctx, "tool_approval_state", { + sendEventToClient(ctx, connection, "tool_approval_state", { sessionId, items: remaining, }); @@ -2265,6 +2329,11 @@ export async function handleCommand( { loadLatest: providerId === "cline" }, ); } + if (command === "list_cline_recommended_models") { + // Tiered picker data (recommended / free / clinePass) with + // display-ready names; falls back to a bundled list offline. + return await fetchClineRecommendedModels(); + } if (command === "create_mode_session") { const mode = ProviderSessionModeSchema.parse(args?.mode); const manager = createDesktopProviderSettingsManager(); @@ -2882,6 +2951,17 @@ export async function handleCommand( return await handleRoutineScheduleCommand(ctx, command, args); } + // ── Agenda task queue ───────────────────────────────────────────── + if (AGENDA_TASK_COMMANDS.has(command)) { + if ( + AGENDA_TASK_EXECUTION_COMMANDS.has(command) && + !options?.connection?.data?.canApproveTools + ) { + throw new Error("task execution requires a trusted desktop connection"); + } + return await handleAgendaTaskCommand(ctx, command, args); + } + // ── User instruction configs ────────────────────────────────────── if (command === "list_user_instruction_configs") { return await listUserInstructionConfigs(ctx); diff --git a/apps/examples/desktop-app/sidecar/context.test.ts b/apps/examples/desktop-app/sidecar/context.test.ts index 496dfef164..aa2fe1825e 100644 --- a/apps/examples/desktop-app/sidecar/context.test.ts +++ b/apps/examples/desktop-app/sidecar/context.test.ts @@ -15,6 +15,11 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn()); const hubIsConnectedMock = vi.hoisted(() => vi.fn()); const nodeHubClientCtorMock = vi.hoisted(() => vi.fn()); const subscribeMock = vi.hoisted(() => vi.fn()); +const updateCapabilitiesMock = vi.hoisted(() => vi.fn()); + +vi.mock("@ai-sdk/provider-utils", () => ({ + createProviderDefinedToolFactory: vi.fn(() => vi.fn()), +})); vi.mock("@cline/core", async () => { const actual = @@ -40,6 +45,7 @@ vi.mock("@cline/core", async () => { getUrl = hubGetUrlMock; isConnected = hubIsConnectedMock; subscribe = subscribeMock; + updateCapabilities = updateCapabilitiesMock; dispose = vi.fn(); }, }; @@ -69,6 +75,7 @@ describe("Code sidecar runtime capabilities", () => { hubIsConnectedMock.mockReset(); nodeHubClientCtorMock.mockReset(); subscribeMock.mockReset(); + updateCapabilitiesMock.mockReset(); connectMock.mockResolvedValue(undefined); ensureCompatibleLocalHubUrlMock.mockResolvedValue( "ws://127.0.0.1:25463/hub", @@ -78,6 +85,7 @@ describe("Code sidecar runtime capabilities", () => { hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub"); hubIsConnectedMock.mockReturnValue(true); subscribeMock.mockReturnValue(() => {}); + updateCapabilitiesMock.mockResolvedValue(undefined); createCoreMock.mockResolvedValue({ runtimeAddress: "ws://127.0.0.1:25463/hub", subscribe: vi.fn(() => () => {}), @@ -85,7 +93,7 @@ describe("Code sidecar runtime capabilities", () => { }); }); - it("registers Code App capability factory with core", async () => { + it("registers the desktop capability factory with core", async () => { const { createSidecarContext, initializeSessionManager } = await import( "./context" ); @@ -107,7 +115,7 @@ describe("Code sidecar runtime capabilities", () => { workspaceRoot: "/workspace/project", cwd: "/workspace/project", clientType: "code-sidecar", - displayName: "Code App sidecar", + displayName: "Cline Desktop sidecar", }), }), ); @@ -118,7 +126,7 @@ describe("Code sidecar runtime capabilities", () => { expect.objectContaining({ url: "ws://127.0.0.1:25463/hub", clientType: "code-sidecar-observer", - displayName: "Code App observer", + displayName: "Cline Desktop observer", }), ); }); @@ -521,7 +529,11 @@ describe("Code sidecar runtime capabilities", () => { const { handleCommand } = await import("./commands"); const ctx = createSidecarContext("/workspace/project"); - ctx.wsClients.add({ send: vi.fn() }); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); await initializeSessionManager(ctx); @@ -627,7 +639,11 @@ describe("Code sidecar runtime capabilities", () => { const { handleCommand } = await import("./commands"); const ctx = createSidecarContext("/workspace/project"); - ctx.wsClients.add({ send: vi.fn() }); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); await initializeSessionManager(ctx); @@ -640,7 +656,7 @@ describe("Code sidecar runtime capabilities", () => { hub: expect.objectContaining({ strategy: "require-hub", clientType: "code-sidecar", - displayName: "Code App sidecar", + displayName: "Cline Desktop sidecar", }), }), ); @@ -659,9 +675,12 @@ describe("Code sidecar runtime capabilities", () => { }); expect(approval).toBeInstanceOf(Promise); - const pending = await handleCommand(ctx, "poll_tool_approvals", { - sessionId: "sess-1", - }); + const pending = await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + ); expect(pending).toEqual([ expect.objectContaining({ sessionId: "sess-1", @@ -687,15 +706,32 @@ describe("Code sidecar runtime capabilities", () => { ); const [{ requestId }] = pending as Array<{ requestId: string }>; - await handleCommand(ctx, "respond_tool_approval", { - sessionId: "sess-1", - requestId, - approved: true, - }); + const untrustedClient = { send: vi.fn() }; + ctx.wsClients.add(untrustedClient); + await expect( + handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: untrustedClient }, + ), + ).rejects.toThrow("trusted desktop connection"); + expect(ctx.pendingApprovals.size).toBe(1); + await handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: approvalClient }, + ); await expect(approval).resolves.toEqual({ approved: true }); expect( - await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }), + await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + ), ).toEqual([]); }); @@ -703,6 +739,13 @@ describe("Code sidecar runtime capabilities", () => { const { createSidecarContext } = await import("./context"); const { handleCommand } = await import("./commands"); const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); + // Cloud-session approvals are relayed from a pod without a local + // owner; any trusted surface may answer them. ctx.pendingApprovals.set("cloud-approval", { item: { requestId: "cloud-approval", @@ -717,19 +760,262 @@ describe("Code sidecar runtime capabilities", () => { }); await expect( - handleCommand(ctx, "respond_tool_approval", { - sessionId: "ses-cloud", - requestId: "cloud-approval", - approved: true, - }), + handleCommand( + ctx, + "respond_tool_approval", + { + sessionId: "ses-cloud", + requestId: "cloud-approval", + approved: true, + }, + { connection: approvalClient }, + ), ).rejects.toThrow("hub disconnected"); expect( - await handleCommand(ctx, "poll_tool_approvals", { - sessionId: "ses-cloud", - }), + await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "ses-cloud" }, + { connection: approvalClient }, + ), ).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]); }); + it("rejects and removes an approval when initial delivery fails", async () => { + const { createSidecarContext, createSidecarRuntimeCapabilities } = + await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + const failedClient = { + data: { canApproveTools: true }, + send: vi.fn(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(failedClient); + + const approval = createSidecarRuntimeCapabilities( + ctx, + ).requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId: "tool-call-1", + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + + await expect(approval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(failedClient)).toBe(false); + }); + + it("rejects an owned approval when a later broadcast fails", async () => { + const { + broadcastEvent, + createSidecarContext, + createSidecarRuntimeCapabilities, + } = await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(approvalClient); + + const approval = createSidecarRuntimeCapabilities( + ctx, + ).requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId: "tool-call-1", + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + expect(ctx.pendingApprovals.size).toBe(1); + + broadcastEvent(ctx, "task.updated", { taskId: "task-1" }); + + await expect(approval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(approvalClient)).toBe(false); + }); + + it("rejects sibling approvals when a targeted state update fails", async () => { + const { createSidecarContext, createSidecarRuntimeCapabilities } = + await import("./context"); + const { handleCommand } = await import("./commands"); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("socket closed"); + }), + }; + ctx.wsClients.add(approvalClient); + const capabilities = createSidecarRuntimeCapabilities(ctx); + const request = (toolCallId: string) => + capabilities.requestToolApproval?.({ + sessionId: "sess-1", + agentId: "agent-1", + conversationId: "conversation-1", + iteration: 1, + toolCallId, + toolName: "run_commands", + input: { commands: ["echo hi"] }, + policy: { autoApprove: false }, + }); + const firstApproval = request("tool-call-1"); + const siblingApproval = request("tool-call-2"); + const [{ requestId }] = (await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "sess-1" }, + { connection: approvalClient }, + )) as Array<{ requestId: string }>; + + await handleCommand( + ctx, + "respond_tool_approval", + { sessionId: "sess-1", requestId, approved: true }, + { connection: approvalClient }, + ); + + await expect(firstApproval).resolves.toEqual({ approved: true }); + await expect(siblingApproval).resolves.toEqual({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + expect(ctx.pendingApprovals.size).toBe(0); + expect(ctx.wsClients.has(approvalClient)).toBe(false); + }); + + it("serializes approval readiness updates and publishes the latest state", async () => { + const { + createSidecarContext, + initializeSessionManager, + syncSidecarApprovalReadiness, + } = await import("./context"); + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + updateCapabilitiesMock.mockReset(); + + let finishDisconnectedUpdate: (() => void) | undefined; + updateCapabilitiesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishDisconnectedUpdate = resolve; + }), + ) + .mockResolvedValue(undefined); + + const disconnected = syncSidecarApprovalReadiness(ctx); + await vi.waitFor(() => + expect(updateCapabilitiesMock).toHaveBeenCalledWith([]), + ); + ctx.wsClients.add({ + data: { canApproveTools: true }, + send: vi.fn(), + }); + const connected = syncSidecarApprovalReadiness(ctx); + expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1); + + finishDisconnectedUpdate?.(); + await Promise.all([disconnected, connected]); + expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([ + expect.objectContaining({ name: "approval.respond" }), + ]); + }); + + it("forwards Hub-owned task session approvals to the live desktop", async () => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + let onHubEvent: ((event: Record) => void) | undefined; + subscribeMock.mockImplementation((handler) => { + onHubEvent = handler; + return () => {}; + }); + const ctx = createSidecarContext("/workspace/project"); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + ctx.wsClients.add(approvalClient); + await initializeSessionManager(ctx); + + expect(updateCapabilitiesMock).toHaveBeenCalledWith([ + expect.objectContaining({ name: "approval.respond" }), + ]); + onHubEvent?.({ + event: "approval.requested", + sessionId: "task-session-1", + payload: { + approvalId: "hub-approval-1", + agendaTaskId: "task-1", + agentId: "task-agent-1", + conversationId: "task-conversation-1", + iteration: 2, + toolCallId: "tool-call-1", + toolName: "write_to_file", + inputJson: JSON.stringify({ path: "src/a.ts" }), + policy: { autoApprove: false }, + }, + }); + await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1)); + const pendingItems = (await handleCommand( + ctx, + "poll_tool_approvals", + { sessionId: "task-session-1" }, + { connection: approvalClient }, + )) as Array<{ requestId: string }>; + const pending = pendingItems[0]; + if (!pending) throw new Error("expected a pending task approval"); + await handleCommand( + ctx, + "respond_tool_approval", + { + sessionId: "task-session-1", + requestId: pending.requestId, + approved: true, + }, + { connection: approvalClient }, + ); + + await vi.waitFor(() => + expect(hubCommandMock).toHaveBeenCalledWith( + "approval.respond", + { + approvalId: "hub-approval-1", + approved: true, + reason: undefined, + }, + "task-session-1", + ), + ); + }); + it("routes routine commands through the connected shared Hub client", async () => { const { createSidecarContext, initializeSessionManager } = await import( "./context" @@ -754,6 +1040,115 @@ describe("Code sidecar runtime capabilities", () => { scheduleId: "schedule-1", }); }); + + it("proxies Agenda task commands through the connected shared Hub", async () => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + const task = { + taskId: "task-1", + title: "Review the PR", + status: "pending_approval", + }; + hubCommandMock.mockResolvedValue({ + ok: true, + payload: { tasks: [task] }, + }); + + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + const approvalClient = { + data: { canApproveTools: true }, + send: vi.fn(), + }; + + await expect( + handleCommand(ctx, "task.list", { + workspaceRoot: "/workspace/project", + statuses: ["pending_approval"], + }), + ).resolves.toEqual({ tasks: [task] }); + expect(hubCommandMock).toHaveBeenCalledWith("task.list", { + workspaceRoot: "/workspace/project", + statuses: ["pending_approval"], + }); + + hubCommandMock.mockResolvedValueOnce({ + ok: true, + payload: { task: { ...task, status: "in_progress", revision: 4 } }, + }); + await expect( + handleCommand( + ctx, + "task.run", + { + taskId: "task-1", + expectedRevision: 4, + }, + { connection: approvalClient }, + ), + ).resolves.toEqual({ + task: { ...task, status: "in_progress", revision: 4 }, + }); + expect(hubCommandMock).toHaveBeenCalledWith("task.run", { + taskId: "task-1", + expectedRevision: 4, + }); + }); + + it.each([ + "task.create", + "task.approve", + "task.cancel", + "task.run", + "task.automation.set", + ])("rejects untrusted %s commands before they reach the shared Hub", async (command) => { + const { createSidecarContext, initializeSessionManager } = await import( + "./context" + ); + const { handleCommand } = await import("./commands"); + const ctx = createSidecarContext("/workspace/project"); + await initializeSessionManager(ctx); + const untrustedClient = { + data: { canApproveTools: false }, + send: vi.fn(), + }; + + await expect( + handleCommand(ctx, command, {}, { connection: untrustedClient }), + ).rejects.toThrow("task execution requires a trusted desktop connection"); + expect(hubCommandMock).not.toHaveBeenCalled(); + }); + + it("forwards Hub task events that do not have a session", async () => { + const { createSidecarContext, handleHubLiveEvent } = await import( + "./context" + ); + const ctx = createSidecarContext("/workspace/project"); + ctx.wsClients.add({ send: vi.fn() } as never); + + handleHubLiveEvent(ctx, { + event: "task.created", + payload: { + taskId: "task-1", + status: "pending_approval", + }, + }); + + expect(readEvents(ctx)).toEqual([ + { + type: "event", + event: { + name: "task.created", + payload: { + taskId: "task-1", + status: "pending_approval", + }, + }, + }, + ]); + }); }); describe("disposeSidecarContext attachment cleanup", () => { diff --git a/apps/examples/desktop-app/sidecar/context.ts b/apps/examples/desktop-app/sidecar/context.ts index cf4287e144..8fd81e5300 100644 --- a/apps/examples/desktop-app/sidecar/context.ts +++ b/apps/examples/desktop-app/sidecar/context.ts @@ -15,7 +15,11 @@ import { type ToolApprovalRequest, type ToolApprovalResult, } from "@cline/core"; -import { type AgentEvent, isGeneratedMedia } from "@cline/shared"; +import { + type AgentEvent, + HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, + isGeneratedMedia, +} from "@cline/shared"; import { discardAllTrackedAttachments, flushConsumedAttachments, @@ -30,6 +34,7 @@ import type { PromptInQueue, SessionRuntimeBinding, SidecarContext, + SidecarWebSocketClient, } from "./types"; import { LOCAL_ENVIRONMENT_ID } from "./types"; @@ -38,6 +43,7 @@ const hubClientInitialization = new WeakMap< SidecarContext, Promise >(); +const approvalReadinessUpdates = new WeakMap>(); // --------------------------------------------------------------------------- // Helpers — WebSocket broadcast @@ -61,10 +67,81 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void { client.send(encoded); } catch { ctx.wsClients.delete(client); + cancelSidecarToolApprovalsForOwner(ctx, client); + void syncSidecarApprovalReadiness(ctx).catch((error) => + ctx.logger?.error?.("Hub approval readiness update failed", { error }), + ); } } } +export function sendEventToClient( + ctx: SidecarContext, + client: SidecarWebSocketClient, + name: string, + payload: unknown, +): boolean { + try { + client.send(encodeSidecarEvent(name, payload)); + return true; + } catch { + ctx.wsClients.delete(client); + cancelSidecarToolApprovalsForOwner(ctx, client); + void syncSidecarApprovalReadiness(ctx).catch((error) => + ctx.logger?.error?.("Hub approval readiness update failed", { error }), + ); + return false; + } +} + +export function cancelSidecarToolApprovalsForOwner( + ctx: SidecarContext, + owner: SidecarWebSocketClient, +): void { + for (const [requestId, pending] of ctx.pendingApprovals) { + if (pending.owner !== owner) continue; + ctx.pendingApprovals.delete(requestId); + pending.resolve({ + approved: false, + reason: "Desktop approval surface disconnected", + }); + } +} + +export function syncSidecarApprovalReadiness( + ctx: SidecarContext, +): Promise { + const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve(); + const update = previous + .catch(() => undefined) + .then(async () => { + // The approval capability rides on the shared local hub observer; the + // multi-environment refactor keeps that client on the local binding. + const hubClient = + ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient; + if (!hubClient) return; + await hubClient.updateCapabilities( + [...ctx.wsClients].some( + (client) => client.data?.canApproveTools === true, + ) + ? [ + { + name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY, + description: + "Cline Code has a live user surface for tool review.", + }, + ] + : [], + ); + }); + approvalReadinessUpdates.set(ctx, update); + return update.finally(() => { + if (approvalReadinessUpdates.get(ctx) === update) { + approvalReadinessUpdates.delete(ctx); + } + }); +} + // Session log appends are chained per session so writes stay ordered, but // they run asynchronously: a synchronous write per streamed token would stall // the sidecar event loop (and therefore every pending UI command) under load. @@ -683,6 +760,15 @@ function requestSidecarToolApproval( ctx: SidecarContext, request: ToolApprovalRequest, ): Promise { + const owner = [...ctx.wsClients].find( + (client) => client.data?.canApproveTools === true, + ); + if (!owner) { + return Promise.resolve({ + approved: false, + reason: "No trusted desktop approval surface is connected", + }); + } return new Promise((resolve) => { const requestId = randomUUID(); const pending: PendingToolApproval = { @@ -697,16 +783,25 @@ function requestSidecarToolApproval( agentId: request.agentId, conversationId: request.conversationId, }, + owner, resolve, }; ctx.pendingApprovals.set(requestId, pending); const sessionApprovals = Array.from(ctx.pendingApprovals.values()) - .filter((approval) => approval.item.sessionId === request.sessionId) + .filter( + (approval) => + approval.owner === owner && + approval.item.sessionId === request.sessionId, + ) .map((approval) => approval.item); - sendEvent(ctx, "tool_approval_state", { - sessionId: request.sessionId, - items: sessionApprovals, - }); + if ( + !sendEventToClient(ctx, owner, "tool_approval_state", { + sessionId: request.sessionId, + items: sessionApprovals, + }) + ) { + cancelSidecarToolApprovalsForOwner(ctx, owner); + } }); } @@ -719,6 +814,25 @@ export function handleHubLiveEvent( }, options: { relayRawAssistantText?: boolean } = {}, ): void { + if (event.event === "approval.requested") { + if (typeof event.payload?.agendaTaskId !== "string") return; + void handleHubApprovalRequest(ctx, event).catch((error) => { + ctx.logger?.error?.("Hub task approval forwarding failed", { error }); + }); + return; + } + // Task lifecycle events are Hub-wide invalidations and usually do not have a + // session yet (pending and approved tasks explicitly predate their session). + // Forward them before the session-only live-chat projection below so Agenda + // surfaces stay current without polling. + if (event.event.startsWith("task.")) { + sendEvent(ctx, event.event, { + ...(event.payload ?? {}), + ...(event.sessionId ? { sessionId: event.sessionId } : {}), + }); + return; + } + const sessionId = typeof event.sessionId === "string" ? event.sessionId : ""; if (!sessionId) { return; @@ -918,6 +1032,72 @@ export function handleHubLiveEvent( } } +async function handleHubApprovalRequest( + ctx: SidecarContext, + event: { + sessionId?: string; + payload?: Record; + }, +): Promise { + const sessionId = event.sessionId?.trim() || ""; + const approvalId = + typeof event.payload?.approvalId === "string" + ? event.payload.approvalId.trim() + : ""; + const toolCallId = + typeof event.payload?.toolCallId === "string" + ? event.payload.toolCallId.trim() + : ""; + const toolName = + typeof event.payload?.toolName === "string" + ? event.payload.toolName.trim() + : ""; + if (!sessionId || !approvalId || !toolCallId || !toolName) return; + let input: unknown; + try { + input = + typeof event.payload?.inputJson === "string" + ? JSON.parse(event.payload.inputJson) + : undefined; + } catch { + input = undefined; + } + const result = await requestSidecarToolApproval(ctx, { + sessionId, + agentId: + typeof event.payload?.agentId === "string" ? event.payload.agentId : "", + conversationId: + typeof event.payload?.conversationId === "string" + ? event.payload.conversationId + : sessionId, + iteration: + typeof event.payload?.iteration === "number" + ? event.payload.iteration + : 0, + toolCallId, + toolName, + input, + policy: + event.payload?.policy && + typeof event.payload.policy === "object" && + !Array.isArray(event.payload.policy) + ? (event.payload.policy as ToolApprovalRequest["policy"]) + : { autoApprove: false }, + }); + const client = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient; + if (!client) + throw new Error("Hub client disconnected before approval response"); + await client.command( + "approval.respond", + { + approvalId, + approved: result.approved, + reason: result.reason, + }, + sessionId, + ); +} + export async function initializeSessionManager( ctx: SidecarContext, ): Promise { @@ -933,7 +1113,7 @@ export async function initializeSessionManager( workspaceRoot: ctx.localWorkspaceRoot, cwd: ctx.localWorkspaceRoot, clientType: "code-sidecar", - displayName: "Code App sidecar", + displayName: "Cline Desktop sidecar", }, }); @@ -959,6 +1139,11 @@ export async function initializeSessionManager( hubClient, unsubscribeSessionEvents: unsubscribe, }); + // Advertise the tool-approval surface once the local hub binding exists; + // clients that connected before the hub came up are picked up here. + await syncSidecarApprovalReadiness(ctx).catch((error) => + ctx.logger?.error?.("Hub approval readiness update failed", { error }), + ); } export function getRuntimeBinding( @@ -1143,7 +1328,7 @@ export async function ensureSharedHubClient( const client = new NodeHubClient({ url, clientType: "code-sidecar-observer", - displayName: "Code App observer", + displayName: "Cline Desktop observer", workspaceRoot: ctx.localWorkspaceRoot, cwd: ctx.localWorkspaceRoot, }); diff --git a/apps/examples/desktop-app/sidecar/index.ts b/apps/examples/desktop-app/sidecar/index.ts index 100c91eda0..858d9e72bc 100644 --- a/apps/examples/desktop-app/sidecar/index.ts +++ b/apps/examples/desktop-app/sidecar/index.ts @@ -129,7 +129,7 @@ async function main() { void shutdown("code_sidecar_before_exit"); }); - const { port } = startServer(ctx, SIDECAR_PORT, shutdown); + const { port, approvalToken } = startServer(ctx, SIDECAR_PORT, shutdown); observability.logger.log("Desktop sidecar ready", { port, mode: SIDECAR_MODE, @@ -153,12 +153,13 @@ async function main() { // 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 = `ws://${dialHost}:${port}/transport`; + const wsEndpoint = new URL(`ws://${dialHost}:${port}/transport`); + wsEndpoint.searchParams.set("approval_token", approvalToken); process.stdout.write( `${JSON.stringify({ type: "ready", endpoint, - wsEndpoint, + wsEndpoint: wsEndpoint.toString(), pid: process.pid, mode: SIDECAR_MODE, })}\n`, diff --git a/apps/examples/desktop-app/sidecar/observability.test.ts b/apps/examples/desktop-app/sidecar/observability.test.ts index 19ce913338..f10ea83421 100644 --- a/apps/examples/desktop-app/sidecar/observability.test.ts +++ b/apps/examples/desktop-app/sidecar/observability.test.ts @@ -58,7 +58,7 @@ describe("desktop observability", () => { expect(mocks.createClineTelemetryServiceConfig).toHaveBeenCalledWith({ metadata: expect.objectContaining({ cline_type: "desktop", - platform: "Cline Code", + platform: "Cline", }), }); expect(mocks.createConfiguredTelemetryHandle).toHaveBeenCalledWith( diff --git a/apps/examples/desktop-app/sidecar/observability.ts b/apps/examples/desktop-app/sidecar/observability.ts index 50c59e7ef4..c4b20f38bf 100644 --- a/apps/examples/desktop-app/sidecar/observability.ts +++ b/apps/examples/desktop-app/sidecar/observability.ts @@ -30,7 +30,7 @@ export function createDesktopObservability(): DesktopObservability { metadata: { extension_version: version, cline_type: "desktop", - platform: "Cline Code", + platform: "Cline", platform_version: process.version, os_type: os.platform(), os_version: os.version(), diff --git a/apps/examples/desktop-app/sidecar/server.test.ts b/apps/examples/desktop-app/sidecar/server.test.ts index 33191d2200..33716a7cf2 100644 --- a/apps/examples/desktop-app/sidecar/server.test.ts +++ b/apps/examples/desktop-app/sidecar/server.test.ts @@ -9,6 +9,8 @@ import { import { createFetchHandler, createWebSocketHandler } from "./server"; import type { SidecarContext } from "./types"; +const TEST_APPROVAL_TOKEN = "test-approval-token"; + function createTestServer() { return { port: 3126, @@ -17,7 +19,11 @@ function createTestServer() { } function createHandler(onShutdown = vi.fn()) { - return createFetchHandler({} as SidecarContext, onShutdown); + return createFetchHandler( + {} as SidecarContext, + onShutdown, + TEST_APPROVAL_TOKEN, + ); } function createTelemetryHandler(capture = vi.fn()) { @@ -102,6 +108,37 @@ describe("sidecar HTTP origin checks", () => { expect(server.upgrade).not.toHaveBeenCalled(); }); + it("does not grant approval authority to originless local clients", async () => { + const server = createTestServer(); + await createHandler()( + new Request( + `http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`, + ), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: false }, + }); + }); + + it("grants approval authority to the trusted desktop webview", async () => { + const server = createTestServer(); + await createHandler()( + new Request( + `http://127.0.0.1:3126/transport?approval_token=${TEST_APPROVAL_TOKEN}`, + { + headers: { origin: "tauri://localhost" }, + }, + ), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: true }, + }); + }); + it("allows desktop webview origins in preflight responses", async () => { const server = createTestServer(); const response = await createHandler()( @@ -120,6 +157,20 @@ describe("sidecar HTTP origin checks", () => { "tauri://localhost", ); }); + + it("does not grant approval authority to a spoofed trusted origin", async () => { + const server = createTestServer(); + await createHandler()( + new Request("http://127.0.0.1:3126/transport", { + headers: { origin: "tauri://localhost" }, + }), + server, + ); + + expect(server.upgrade).toHaveBeenCalledWith(expect.any(Request), { + data: { canApproveTools: false }, + }); + }); }); describe("session video artifacts", () => { diff --git a/apps/examples/desktop-app/sidecar/server.ts b/apps/examples/desktop-app/sidecar/server.ts index 69f3b9de6e..2112a86266 100644 --- a/apps/examples/desktop-app/sidecar/server.ts +++ b/apps/examples/desktop-app/sidecar/server.ts @@ -1,3 +1,4 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; @@ -10,7 +11,12 @@ import { import type { DesktopTransportRequest } from "../webview/lib/desktop-transport"; import { MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES } from "../webview/lib/voice-input-limits"; import { handleCommand } from "./commands"; -import { encodeSidecarEvent, sendEvent } from "./context"; +import { + cancelSidecarToolApprovalsForOwner, + encodeSidecarEvent, + sendEvent, + syncSidecarApprovalReadiness, +} from "./context"; import { fetchMarketplaceCatalog } from "./marketplace"; import { cancelMcpOAuthAuthorizationsForOwner } from "./mcp-oauth"; import { cancelProviderOAuthLoginsForOwner } from "./oauth-login"; @@ -26,7 +32,10 @@ import { type SidecarServer = { port: number; - upgrade(req: Request): boolean; + upgrade( + req: Request, + options?: { data?: { canApproveTools?: boolean } }, + ): boolean; }; // Comma-separated extra origins (e.g. a dev server on a nonstandard port when @@ -49,6 +58,19 @@ const JSON_HEADERS = { "content-type": "application/json", }; +const APPROVAL_TOKEN_QUERY_PARAM = "approval_token"; + +function hasValidApprovalToken(url: URL, expectedToken: string): boolean { + const candidate = url.searchParams.get(APPROVAL_TOKEN_QUERY_PARAM); + if (!candidate) return false; + const candidateBytes = Buffer.from(candidate); + const expectedBytes = Buffer.from(expectedToken); + return ( + candidateBytes.length === expectedBytes.length && + timingSafeEqual(candidateBytes, expectedBytes) + ); +} + function artifactContentType(filename: string): string { const lower = filename.toLowerCase(); if (lower.endsWith(".mp3")) return "audio/mpeg"; @@ -173,7 +195,9 @@ export function startServer( ctx: SidecarContext, preferredPort: number = SIDECAR_PORT, onShutdown?: (reason?: string) => Promise, -): { port: number } { + approvalToken = process.env.CLINE_SIDECAR_APPROVAL_TOKEN?.trim() || + randomUUID(), +): { port: number; approvalToken: string } { if (!BunRuntime) { throw new Error("sidecar must be run with Bun"); } @@ -188,7 +212,7 @@ export function startServer( server = BunRuntime.serve({ hostname: SIDECAR_HOST, port: candidate, - fetch: createFetchHandler(ctx, onShutdown), + fetch: createFetchHandler(ctx, onShutdown, approvalToken), websocket: createWebSocketHandler(ctx), }) as SidecarServer; break; @@ -201,12 +225,13 @@ export function startServer( throw lastError ?? new Error("Failed to start sidecar server"); } - return { port: server.port }; + return { port: server.port, approvalToken }; } export function createFetchHandler( ctx: SidecarContext, onShutdown?: (reason?: string) => Promise, + approvalToken = "", ) { return async (req: Request, server: SidecarServer) => { const url = new URL(req.url); @@ -299,7 +324,15 @@ export function createFetchHandler( if ( url.pathname === "/transport" && isTrustedRequestOrigin(req) && - server.upgrade(req) + server.upgrade(req, { + data: { + // Originless clients remain supported for local integrations, but only + // the browser-hosted desktop UI may receive or resolve approvals. + canApproveTools: + Boolean(readOrigin(req)) && + hasValidApprovalToken(url, approvalToken), + }, + }) ) { return undefined; } @@ -456,6 +489,7 @@ export function createWebSocketHandler(ctx: SidecarContext) { maxPayloadLength: MAX_DESKTOP_TRANSPORT_PAYLOAD_BYTES, open(ws: SidecarWebSocketClient) { ctx.wsClients.add(ws); + void syncSidecarApprovalReadiness(ctx).catch(() => {}); sendEvent(ctx, "host_ready", { pid: process.pid, mode: SIDECAR_MODE, @@ -496,6 +530,8 @@ export function createWebSocketHandler(ctx: SidecarContext) { }, close(ws: SidecarWebSocketClient) { ctx.wsClients.delete(ws); + cancelSidecarToolApprovalsForOwner(ctx, ws); + void syncSidecarApprovalReadiness(ctx).catch(() => {}); // Browser OAuth flows are interactive: if the connection that started // one goes away (webview reload, transport drop), cancel its callback // wait so the sidecar cannot retain an abandoned authorization attempt. diff --git a/apps/examples/desktop-app/sidecar/session-data/messages.test.ts b/apps/examples/desktop-app/sidecar/session-data/messages.test.ts index 4d672f6659..e0e1e6e489 100644 --- a/apps/examples/desktop-app/sidecar/session-data/messages.test.ts +++ b/apps/examples/desktop-app/sidecar/session-data/messages.test.ts @@ -88,6 +88,166 @@ describe("readSessionMessages", () => { ]); }); + it("projects pre-tool thinking before the tool row it preceded", async () => { + // A thinking model can issue a tool call without narration text: + // content = [thinking, tool_use]. The thinking happened before the + // tool executed, so it must project before the tool row — matching the + // live-stream order and keeping the reasoning from attaching to the + // next turn-ending answer (which would corrupt the work summary's + // duration anchor in the webview). + const sessionId = `thinking-tool-projection-${Date.now()}`; + const userTimestamp = 1_781_041_621_000; + const assistantTimestamp = userTimestamp + 5_000; + const resultTimestamp = userTimestamp + 13_000; + const answerTimestamp = userTimestamp + 13_500; + const liveSessions = new Map([ + [ + sessionId, + { + messages: [ + { + id: "user-message", + role: "user", + content: [{ type: "text", text: "Run the command" }], + ts: userTimestamp, + }, + { + id: "assistant-tool", + role: "assistant", + content: [ + { type: "thinking", thinking: "Planning the command" }, + { + type: "tool_use", + id: "tool-use", + name: "run_commands", + input: { commands: ["sleep 8"] }, + }, + ], + ts: assistantTimestamp, + }, + { + id: "tool-result-message", + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-use", + content: "done", + }, + ], + ts: resultTimestamp, + }, + { + id: "assistant-answer", + role: "assistant", + content: [{ type: "text", text: "The command finished." }], + ts: answerTimestamp, + }, + ], + }, + ], + ]); + + await expect( + readSessionMessages( + { liveSessions } as Parameters[0], + sessionId, + ), + ).resolves.toEqual([ + expect.objectContaining({ + id: "user-message_text_0", + role: "user", + createdAt: userTimestamp, + }), + expect.objectContaining({ + id: "assistant-tool_reasoning_0", + role: "assistant", + reasoning: "Planning the command", + createdAt: assistantTimestamp, + }), + expect.objectContaining({ + id: "assistant-tool_tool_use_1", + role: "tool", + createdAt: assistantTimestamp + 1, + meta: expect.objectContaining({ + toolCallId: "tool-use", + hookEventName: "history_tool_result", + }), + }), + expect.objectContaining({ + id: "assistant-answer_text_0", + role: "assistant", + content: "The command finished.", + createdAt: answerTimestamp, + }), + ]); + }); + + it("keeps interleaved thinking between the tool calls it separates", async () => { + // Interleaved thinking can produce [thinking, tool_use, thinking, + // tool_use] in a single assistant message. Each thinking segment must + // project at its own position — merging the second segment into the + // first row would display it before a tool call it actually followed. + const sessionId = `interleaved-thinking-projection-${Date.now()}`; + const assistantTimestamp = 1_781_041_621_000; + const liveSessions = new Map([ + [ + sessionId, + { + messages: [ + { + id: "assistant-tools", + role: "assistant", + content: [ + { type: "thinking", thinking: "First I need the date" }, + { + type: "tool_use", + id: "tool-a", + name: "run_commands", + input: { commands: ["date"] }, + }, + { type: "thinking", thinking: "Now check the files" }, + { + type: "tool_use", + id: "tool-b", + name: "read_files", + input: { paths: ["a.ts"] }, + }, + ], + ts: assistantTimestamp, + }, + ], + }, + ], + ]); + + await expect( + readSessionMessages( + { liveSessions } as Parameters[0], + sessionId, + ), + ).resolves.toEqual([ + expect.objectContaining({ + id: "assistant-tools_reasoning_0", + role: "assistant", + reasoning: "First I need the date", + }), + expect.objectContaining({ + id: "assistant-tools_tool_use_1", + role: "tool", + }), + expect.objectContaining({ + id: "assistant-tools_reasoning_1", + role: "assistant", + reasoning: "Now check the files", + }), + expect.objectContaining({ + id: "assistant-tools_tool_use_3", + role: "tool", + }), + ]); + }); + it("projects image content blocks without replacing them with placeholder text", async () => { const sessionId = `image-projection-${Date.now()}`; const liveSessions = new Map([ diff --git a/apps/examples/desktop-app/sidecar/session-data/messages.ts b/apps/examples/desktop-app/sidecar/session-data/messages.ts index 0397ad3595..33b321034f 100644 --- a/apps/examples/desktop-app/sidecar/session-data/messages.ts +++ b/apps/examples/desktop-app/sidecar/session-data/messages.ts @@ -484,6 +484,11 @@ export async function readSessionMessages( const reasoningParts: string[] = []; let reasoningRedacted = false; let textSegmentIndex = 0; + let reasoningSegmentIndex = 0; + // The text row pushed since the last reasoning flush. Reasoning that + // streamed alongside it (the classic [thinking, text] shape) attaches + // there instead of becoming a separate row. + let reasoningTextTarget: JsonRecord | undefined; const outStartIndex = out.length; const flushTextParts = () => { if (textParts.length === 0) { @@ -494,7 +499,7 @@ export async function readSessionMessages( if (!joined.trim()) { return; } - out.push({ + const textRow: JsonRecord = { id: `${messageIdBase}_text_${textSegmentIndex}`, sessionId, role, @@ -505,10 +510,50 @@ export async function readSessionMessages( // the run; later segments must not acquire a fallback ordinal in // the webview. meta: textMeta ?? (role === "user" ? { userRunSpan: 0 } : undefined), - }); + }; + out.push(textRow); + reasoningTextTarget = textRow; textSegmentIndex += 1; textMeta = undefined; }; + const flushReasoningParts = () => { + const reasoning = reasoningParts.join("\n").trim(); + const redacted = reasoningRedacted; + reasoningParts.length = 0; + reasoningRedacted = false; + // Consumed per flush: reasoning must only attach to a text row from + // its own segment, never to one emitted before an earlier tool call. + const target = reasoningTextTarget; + reasoningTextTarget = undefined; + if (!reasoning && !redacted) { + return; + } + if (target) { + if (reasoning) { + const existing = + typeof target.reasoning === "string" && target.reasoning + ? `${target.reasoning}\n` + : ""; + target.reasoning = `${existing}${reasoning}`; + } + if (redacted) { + target.reasoningRedacted = true; + } + return; + } + out.push({ + id: `${messageIdBase}_reasoning_${reasoningSegmentIndex}`, + sessionId, + role, + content: "", + reasoning: reasoning || undefined, + reasoningRedacted: redacted || undefined, + createdAt: nextPartCreatedAt(), + meta: textMeta, + }); + reasoningSegmentIndex += 1; + textMeta = undefined; + }; for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx += 1) { const block = contentBlocks[blockIdx]; @@ -523,6 +568,13 @@ export async function readSessionMessages( const blockType = typeof record.type === "string" ? record.type : ""; if (blockType === "tool_use") { flushTextParts(); + // Everything the model emitted in this message — thinking + // included — happened before the tool executed. Flushing the + // reasoning here keeps the thinking row ahead of the tool row + // (matching the live-stream order) so the webview never attaches + // pre-tool reasoning to a later answer, which would drag the + // work summary's duration anchor back before the tool ran. + flushReasoningParts(); const toolName = typeof record.name === "string" ? record.name : "tool_call"; const toolUseId = typeof record.id === "string" ? record.id : ""; @@ -712,32 +764,7 @@ export async function readSessionMessages( textMeta = undefined; } } - if (reasoningParts.length > 0 || reasoningRedacted) { - const reasoning = reasoningParts.join("\n").trim(); - const target = out - .slice(outStartIndex) - .find((item) => item.role === role); - if (target) { - if (reasoning) { - target.reasoning = reasoning; - } - if (reasoningRedacted) { - target.reasoningRedacted = true; - } - } else { - out.push({ - id: `${messageIdBase}_reasoning`, - sessionId, - role, - content: "", - reasoning: reasoning || undefined, - reasoningRedacted: reasoningRedacted || undefined, - createdAt: nextPartCreatedAt(), - meta: textMeta, - }); - textMeta = undefined; - } - } + flushReasoningParts(); if (textMeta && out[outStartIndex]) { out[outStartIndex].meta = { ...(typeof out[outStartIndex].meta === "object" diff --git a/apps/examples/desktop-app/sidecar/types.ts b/apps/examples/desktop-app/sidecar/types.ts index 53aedab2a5..76cb11a311 100644 --- a/apps/examples/desktop-app/sidecar/types.ts +++ b/apps/examples/desktop-app/sidecar/types.ts @@ -106,6 +106,11 @@ export type ToolApprovalRequestItem = { export type PendingToolApproval = { item: ToolApprovalRequestItem; + // Approvals created for a trusted desktop connection carry that owner and + // may only be listed/answered by it. Cloud-session approvals are relayed + // from a pod without a local owner and stay answerable from any trusted + // surface (and survive local disconnects). + owner?: SidecarWebSocketClient; resolve: (result: ToolApprovalResult) => void | Promise; }; @@ -129,6 +134,7 @@ export type PendingAskQuestion = { }; export type SidecarWebSocketClient = { + data?: { canApproveTools?: boolean }; send: (message: string) => void; close?: () => void; }; diff --git a/apps/examples/desktop-app/src-tauri/Info.plist b/apps/examples/desktop-app/src-tauri/Info.plist index c2d8e132fa..961bf2ca44 100644 --- a/apps/examples/desktop-app/src-tauri/Info.plist +++ b/apps/examples/desktop-app/src-tauri/Info.plist @@ -3,6 +3,6 @@ NSMicrophoneUsageDescription - Cline Code uses the microphone to transcribe speech into chat input. + Cline uses the microphone to transcribe speech into chat input. diff --git a/apps/examples/desktop-app/src-tauri/app-icon.png b/apps/examples/desktop-app/src-tauri/app-icon.png new file mode 100644 index 0000000000..6343aca9f4 Binary files /dev/null and b/apps/examples/desktop-app/src-tauri/app-icon.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/128x128.png b/apps/examples/desktop-app/src-tauri/icons/128x128.png index 6c1fedbc8a..89e5634e0d 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/128x128.png and b/apps/examples/desktop-app/src-tauri/icons/128x128.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/128x128@2x.png b/apps/examples/desktop-app/src-tauri/icons/128x128@2x.png index 9918d3e9ad..4293db1e12 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/128x128@2x.png and b/apps/examples/desktop-app/src-tauri/icons/128x128@2x.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/32x32.png b/apps/examples/desktop-app/src-tauri/icons/32x32.png index 40cfd2192a..12deacc501 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/32x32.png and b/apps/examples/desktop-app/src-tauri/icons/32x32.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/chip.png b/apps/examples/desktop-app/src-tauri/icons/dock/chip.png new file mode 100644 index 0000000000..fc72db2961 Binary files /dev/null and b/apps/examples/desktop-app/src-tauri/icons/dock/chip.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/classic.png b/apps/examples/desktop-app/src-tauri/icons/dock/classic.png index e0a9e3a046..a539ce6bf4 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/dock/classic.png and b/apps/examples/desktop-app/src-tauri/icons/dock/classic.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/hologram.png b/apps/examples/desktop-app/src-tauri/icons/dock/hologram.png new file mode 100644 index 0000000000..c05b42818a Binary files /dev/null and b/apps/examples/desktop-app/src-tauri/icons/dock/hologram.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/midnight.png b/apps/examples/desktop-app/src-tauri/icons/dock/midnight.png index e0f4ca3d9a..35989fde30 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/dock/midnight.png and b/apps/examples/desktop-app/src-tauri/icons/dock/midnight.png differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/steel.png b/apps/examples/desktop-app/src-tauri/icons/dock/steel.png deleted file mode 100644 index 89c43b876f..0000000000 Binary files a/apps/examples/desktop-app/src-tauri/icons/dock/steel.png and /dev/null differ diff --git a/apps/examples/desktop-app/src-tauri/icons/dock/sunrise.png b/apps/examples/desktop-app/src-tauri/icons/dock/sunrise.png deleted file mode 100644 index 1283b83095..0000000000 Binary files a/apps/examples/desktop-app/src-tauri/icons/dock/sunrise.png and /dev/null differ diff --git a/apps/examples/desktop-app/src-tauri/icons/icon.icns b/apps/examples/desktop-app/src-tauri/icons/icon.icns index 6fe189e415..d3c65c2b85 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/icon.icns and b/apps/examples/desktop-app/src-tauri/icons/icon.icns differ diff --git a/apps/examples/desktop-app/src-tauri/icons/icon.ico b/apps/examples/desktop-app/src-tauri/icons/icon.ico index ea0e465fb9..069b7a976d 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/icon.ico and b/apps/examples/desktop-app/src-tauri/icons/icon.ico differ diff --git a/apps/examples/desktop-app/src-tauri/icons/tray/cline-template.png b/apps/examples/desktop-app/src-tauri/icons/tray/cline-template.png index b6ded44ee2..13f1eeea16 100644 Binary files a/apps/examples/desktop-app/src-tauri/icons/tray/cline-template.png and b/apps/examples/desktop-app/src-tauri/icons/tray/cline-template.png differ diff --git a/apps/examples/desktop-app/src-tauri/src/macos_notification.rs b/apps/examples/desktop-app/src-tauri/src/macos_notification.rs index f9e4f21d9a..e298990c88 100644 --- a/apps/examples/desktop-app/src-tauri/src/macos_notification.rs +++ b/apps/examples/desktop-app/src-tauri/src/macos_notification.rs @@ -6,7 +6,7 @@ use std::sync::OnceLock; use tauri::AppHandle; const DEV_APP_DIRECTORY: &str = "notification-identity"; -const DEV_BUNDLE_NAME: &str = "Cline Code.app"; +const DEV_BUNDLE_NAME: &str = "Cline.app"; const LAUNCH_SERVICES_REGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; static CONFIGURATION: OnceLock> = OnceLock::new(); @@ -200,17 +200,13 @@ mod tests { fs::write(&executable, b"test executable").unwrap(); fs::write(&icon, b"test icon").unwrap(); - let bundle = create_dev_application_bundle( - &executable, - &icon, - "bot.cline.app.dev", - "Cline Code Dev", - ) - .unwrap(); + let bundle = + create_dev_application_bundle(&executable, &icon, "bot.cline.app.dev", "Cline Dev") + .unwrap(); let plist = fs::read_to_string(bundle.join("Contents/Info.plist")).unwrap(); assert!(plist.contains("bot.cline.app.dev")); - assert!(plist.contains("Cline Code Dev")); + assert!(plist.contains("Cline Dev")); assert_eq!( fs::read_link(bundle.join("Contents/MacOS/cline-app")).unwrap(), executable diff --git a/apps/examples/desktop-app/src-tauri/src/main.rs b/apps/examples/desktop-app/src-tauri/src/main.rs index 6ff066be9b..45f2fb0666 100644 --- a/apps/examples/desktop-app/src-tauri/src/main.rs +++ b/apps/examples/desktop-app/src-tauri/src/main.rs @@ -209,7 +209,7 @@ fn running_sessions_text(running_sessions: u32) -> String { } // app_name is package_info().name (the configured productName), so beta -// builds ("Cline Code Beta") identify themselves in the tooltip too. +// builds ("Cline Beta") identify themselves in the tooltip too. fn tray_tooltip_text(app_name: &str, running_sessions: u32) -> String { if running_sessions == 0 { app_name.to_string() @@ -815,7 +815,7 @@ async fn check_for_update_now( /// Icon ids accepted by `set_app_icon`; kept in sync with APP_ICONS in /// webview/lib/app-icon.ts. Every non-default id has a matching bundled /// resource at icons/dock/.png. -const APP_DOCK_ICONS: [&str; 4] = ["classic", "sunrise", "steel", "midnight"]; +const APP_DOCK_ICONS: [&str; 4] = ["classic", "midnight", "hologram", "chip"]; #[tauri::command] fn set_app_icon(app: tauri::AppHandle, icon: String) -> Result { @@ -1369,7 +1369,7 @@ fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> { .text( TRAY_OPEN_MENU_ID, // package_info().name is the configured productName, so beta - // builds ("Cline Code Beta") identify themselves in the tray too. + // builds ("Cline Beta") identify themselves in the tray too. format!( "{} v{}", app.package_info().name, @@ -1708,14 +1708,11 @@ mod tests { assert_eq!(running_sessions_text(0), "0 sessions running"); assert_eq!(running_sessions_text(1), "1 session running"); assert_eq!(running_sessions_text(3), "3 sessions running"); - assert_eq!(tray_tooltip_text("Cline Code", 0), "Cline Code"); + assert_eq!(tray_tooltip_text("Cline", 0), "Cline"); + assert_eq!(tray_tooltip_text("Cline", 3), "Cline — 3 sessions running"); assert_eq!( - tray_tooltip_text("Cline Code", 3), - "Cline Code — 3 sessions running" - ); - assert_eq!( - tray_tooltip_text("Cline Code Beta", 2), - "Cline Code Beta — 2 sessions running" + tray_tooltip_text("Cline Beta", 2), + "Cline Beta — 2 sessions running" ); assert_eq!(tray_badge_text(0), None); assert_eq!(tray_badge_text(3), Some("3".to_string())); diff --git a/apps/examples/desktop-app/src-tauri/tauri.beta.conf.json b/apps/examples/desktop-app/src-tauri/tauri.beta.conf.json index ed9234cb21..b3eb60e20c 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.beta.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.beta.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Cline Code Beta", + "productName": "Cline Beta", "identifier": "bot.cline.app.beta", "plugins": { "updater": { diff --git a/apps/examples/desktop-app/src-tauri/tauri.conf.json b/apps/examples/desktop-app/src-tauri/tauri.conf.json index cae8257992..78aefcbbea 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Cline Code", - "version": "0.0.14-beta.1", + "productName": "Cline", + "version": "0.0.15-beta.1", "identifier": "bot.cline.app", "build": { "beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web", @@ -22,7 +22,7 @@ "windows": [ { "label": "main", - "title": "Cline Code", + "title": "Cline", "width": 1500, "height": 980, "resizable": true, diff --git a/apps/examples/desktop-app/src-tauri/tauri.dev.conf.json b/apps/examples/desktop-app/src-tauri/tauri.dev.conf.json index a1b88b10dd..54855be112 100644 --- a/apps/examples/desktop-app/src-tauri/tauri.dev.conf.json +++ b/apps/examples/desktop-app/src-tauri/tauri.dev.conf.json @@ -1,5 +1,5 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Cline Code Dev", + "productName": "Cline Dev", "identifier": "bot.cline.app.dev" } diff --git a/apps/examples/desktop-app/webview/app/globals.css b/apps/examples/desktop-app/webview/app/globals.css index 92aff5f730..a8ad0f00fc 100644 --- a/apps/examples/desktop-app/webview/app/globals.css +++ b/apps/examples/desktop-app/webview/app/globals.css @@ -59,26 +59,6 @@ * @cline/ui (components/markdown.css and agent-chat.css), shared with the * cloud dashboard so both products render assistant output identically. */ -.cline-thinking-slider-shimmer { - background-image: linear-gradient( - 105deg, - transparent 20%, - color-mix(in oklab, var(--primary-foreground) 45%, transparent) 42%, - color-mix(in oklab, var(--primary-foreground) 70%, transparent) 50%, - color-mix(in oklab, var(--primary-foreground) 45%, transparent) 58%, - transparent 80% - ); - background-position: 180% 0; - background-size: 220% 100%; - animation: cline-thinking-slider-shimmer 1.8s ease-in-out infinite; -} - -@keyframes cline-thinking-slider-shimmer { - to { - background-position: -180% 0; - } -} - /* Softens the welcome <-> conversation swap: the hero and the message grid * replace each other in a single commit, which otherwise reads as a hard * white flash. Plays whenever the element (re)becomes visible — display:none @@ -99,10 +79,6 @@ } @media (prefers-reduced-motion: reduce) { - .cline-thinking-slider-shimmer { - display: none; - } - .cline-view-enter { animation: none; } diff --git a/apps/examples/desktop-app/webview/app/page.tsx b/apps/examples/desktop-app/webview/app/page.tsx index 8665308a23..5d1692cc5d 100644 --- a/apps/examples/desktop-app/webview/app/page.tsx +++ b/apps/examples/desktop-app/webview/app/page.tsx @@ -912,6 +912,7 @@ export default function Home() { onNavigateBack={handleNavigateBack} onNavigateForward={handleNavigateForward} onNewThread={handleNewThread} + onOpenSessionById={handleOpenSessionById} realtimeVoiceControl={ 0} canNavigateForward={navigation.forward.length > 0} /> @@ -1851,7 +1857,7 @@ function ChatThreadPane({ toast({ title: "Opened handoff in your browser", description: - "The cloud session could not be attached inside Cline Code.", + "The cloud session could not be attached inside Cline.", }); } catch { toast({ @@ -2968,6 +2974,7 @@ function ChatThreadPane({ ) : undefined } onListGitBranches={listGitBranches} + onOpenSession={onOpenSessionById} onSwitchGitBranch={switchGitBranch} executionTarget={isCloudSession ? "cloud" : "local"} repoUrl={config.repoUrl ?? ""} diff --git a/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx b/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx new file mode 100644 index 0000000000..23b65a112b --- /dev/null +++ b/apps/examples/desktop-app/webview/components/agenda-task-review-dialog.tsx @@ -0,0 +1,170 @@ +"use client"; + +import type { AgendaTaskRecord } from "@cline/shared"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function AgendaTaskReviewDialog({ + task, + open, + pending, + confirmLabel = "Approve", + rejectLabel = "Reject", + onOpenChange, + onConfirm, + onReject, +}: { + task: AgendaTaskRecord | null; + open: boolean; + pending: boolean; + confirmLabel?: string; + rejectLabel?: string; + onOpenChange: (open: boolean) => void; + onConfirm: (task: AgendaTaskRecord) => void | Promise; + onReject?: (task: AgendaTaskRecord) => void | Promise; +}) { + return ( + + + {task ? ( + <> + + {task.title} + + Review the exact revision before it can start a new agent + session. + + +
+
+ + + + + + + + + + {task.cwd ? ( + + ) : null} + +
+ {task.description ? ( + + ) : null} + + {task.systemPrompt ? ( + + ) : null} + {task.resourcePaths.length > 0 ? ( +
+

Files

+
    + {task.resourcePaths.map((path) => ( +
  • + {path} +
  • + ))} +
+
+ ) : null} +
+ + + + + + ) : null} +
+
+ ); +} + +function ReviewField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function ReviewText({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+
+ {value} +
+
+ ); +} diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx index 8cdbe4b8aa..c21492fc6f 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import type { AgendaTaskRecord } from "@cline/shared"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,8 +16,20 @@ import type { UseSessionHistoryResult, } from "@/hooks/use-session-history"; -const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() })); -vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } })); +const desktopMocks = vi.hoisted(() => ({ + invoke: vi.fn(), + createAgendaTask: vi.fn(), + listAgendaTasks: vi.fn(), + approveAgendaTask: vi.fn(), + cancelAgendaTask: vi.fn(), + runAgendaTask: vi.fn(), + getAgendaAutomationPolicy: vi.fn(), + setAgendaAutomationPolicy: vi.fn(), + subscribe: vi.fn(() => () => undefined), + subscribeTransportState: vi.fn(() => () => undefined), +})); +const { invoke } = desktopMocks; +vi.mock("@/lib/desktop-client", () => ({ desktopClient: desktopMocks })); let container: HTMLDivElement; let root: Root; @@ -41,12 +54,13 @@ function makeSessionHistory( options: { loadOlderSessions?: ReturnType; mayHaveMoreSessions?: boolean; + hasLoadedHistory?: boolean; } = {}, ): UseSessionHistoryResult { return { deleteThread: vi.fn(), forkThread: vi.fn(), - isLoadingHistory: false, + hasLoadedHistory: options.hasLoadedHistory ?? true, isLoadingMore: false, loadOlderSessions: options.loadOlderSessions ?? vi.fn(), loadMoreSessions, @@ -80,6 +94,20 @@ async function hover(element: Element): Promise { }); } +async function changeField( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +): Promise { + await act(async () => { + const prototype = Object.getPrototypeOf(element) as object; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + setter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + await Promise.resolve(); + }); +} + function buttonWithText(text: string, rootNode: ParentNode = container) { const button = [ ...rootNode.querySelectorAll("button"), @@ -117,6 +145,29 @@ beforeEach(() => { window.localStorage.clear(); invoke.mockReset(); invoke.mockRejectedValue(new Error("No Cline account auth token found")); + desktopMocks.createAgendaTask.mockReset(); + desktopMocks.listAgendaTasks.mockReset(); + desktopMocks.listAgendaTasks.mockResolvedValue([]); + desktopMocks.approveAgendaTask.mockReset(); + desktopMocks.cancelAgendaTask.mockReset(); + desktopMocks.runAgendaTask.mockReset(); + desktopMocks.getAgendaAutomationPolicy.mockReset(); + desktopMocks.getAgendaAutomationPolicy.mockResolvedValue({ + scopeKey: "global", + mode: "manual", + applyToAgentCreated: true, + maxConcurrentRuns: 1, + maxChainDepth: 3, + maxStartsPerHour: 20, + updatedAt: "2026-08-13T00:00:00.000Z", + }); + desktopMocks.setAgendaAutomationPolicy.mockReset(); + desktopMocks.subscribe.mockReset(); + desktopMocks.subscribe.mockImplementation(() => () => undefined); + desktopMocks.subscribeTransportState.mockReset(); + desktopMocks.subscribeTransportState.mockImplementation( + () => () => undefined, + ); Object.defineProperty(window, "matchMedia", { configurable: true, value: vi.fn(() => ({ @@ -140,6 +191,251 @@ afterEach(async () => { }); describe("AgentSidebar session organization", () => { + it("shows an unread dot when a new Todo item arrives and clears it on open", async () => { + const eventHandlers = new Map void>(); + desktopMocks.subscribe.mockImplementation( + (eventName: string, handler: () => void) => { + eventHandlers.set(eventName, handler); + return () => eventHandlers.delete(eventName); + }, + ); + + await act(async () => { + root.render( + + + , + ); + }); + await vi.waitFor(() => + expect(desktopMocks.listAgendaTasks).toHaveBeenCalled(), + ); + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).toBeNull(); + + desktopMocks.listAgendaTasks.mockResolvedValue([makeAgendaTask()]); + await act(async () => { + eventHandlers.get("task.created")?.(); + }); + await vi.waitFor(() => + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).not.toBeNull(), + ); + + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + expect( + container.querySelector('[data-testid="new-todo-indicator"]'), + ).toBeNull(); + }); + + it("shows pending Agenda work and requires approval before run", async () => { + const task = makeAgendaTask(); + desktopMocks.listAgendaTasks.mockResolvedValue([task]); + desktopMocks.approveAgendaTask.mockResolvedValue({ + ...task, + status: "approved", + revision: 2, + }); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + + expect(container.textContent).toContain("Review PR checks"); + expect(container.textContent).toContain("cline"); + expect(container.textContent).not.toContain("P1 · pending approval"); + expect(desktopMocks.listAgendaTasks).toHaveBeenCalledWith({ + statuses: ["pending_approval", "approved", "in_progress", "failed"], + workspaceRoot: "/projects/current", + limit: 200, + }); + const approve = container.querySelector( + '[aria-label="Approve Review PR checks"]', + ); + expect(approve).not.toBeNull(); + expect(approve?.className).toContain("text-emerald-500!"); + expect( + container.querySelector('[aria-label="Cancel Review PR checks"]') + ?.className, + ).toContain("text-destructive!"); + expect(approve?.closest(".group")?.className).toContain("max-w-full"); + expect( + buttonWithText("Review PR checks").querySelector(".truncate"), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Run Review PR checks"]'), + ).toBeNull(); + + await click(buttonWithText("Review PR checks")); + expect(desktopMocks.approveAgendaTask).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + "Review CI and report failures.", + ); + expect(buttonWithText("Reject", document)).toBeDefined(); + await click(buttonWithText("Approve", document)); + expect(desktopMocks.approveAgendaTask).toHaveBeenCalledWith({ + taskId: "task-1", + expectedRevision: 1, + }); + }); + + it("uses each displayed Agenda revision when running or cancelling", async () => { + const runnable = makeAgendaTask({ + taskId: "task-run", + title: "Run task", + status: "approved", + revision: 4, + }); + const cancellable = makeAgendaTask({ + taskId: "task-cancel", + title: "Cancel task", + status: "approved", + revision: 9, + }); + desktopMocks.listAgendaTasks.mockResolvedValue([runnable, cancellable]); + desktopMocks.runAgendaTask.mockResolvedValue({ + task: { ...runnable, status: "in_progress" }, + }); + desktopMocks.cancelAgendaTask.mockResolvedValue({ + ...cancellable, + status: "cancelled", + }); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + + await click( + container.querySelector('[aria-label="Run Run task"]') as Element, + ); + expect(desktopMocks.runAgendaTask).toHaveBeenCalledWith({ + taskId: "task-run", + expectedRevision: 4, + }); + + await click( + container.querySelector('[aria-label="Cancel Cancel task"]') as Element, + ); + expect(desktopMocks.cancelAgendaTask).toHaveBeenCalledWith({ + taskId: "task-cancel", + expectedRevision: 9, + }); + }); + + it("creates a workspace task with the selected priority, expiry, and model", async () => { + const created = makeAgendaTask({ + taskId: "task-created", + title: "Investigate the regression", + }); + desktopMocks.createAgendaTask.mockResolvedValue(created); + window.localStorage.setItem( + "cline.code.model-selection.v1", + JSON.stringify({ + lastProvider: "openrouter", + lastModelByProvider: { openrouter: "anthropic/claude-sonnet-4.6" }, + }), + ); + + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + expect(container.querySelector('[aria-label="Agenda"]')).toBeNull(); + await click( + container.querySelector('[aria-label="Show Agenda"]') as Element, + ); + await click( + container.querySelector('[aria-label="Create Todo item"]') as Element, + ); + const title = + document.querySelector("#agenda-task-title"); + const instructions = document.querySelector( + "#agenda-task-instructions", + ); + expect(title).not.toBeNull(); + expect(instructions).not.toBeNull(); + await changeField(title as HTMLInputElement, "Investigate the regression"); + await changeField( + instructions as HTMLTextAreaElement, + "Inspect the failing build and implement a fix.", + ); + await click(buttonWithText("Add to Agenda", document)); + + await vi.waitFor(() => + expect(desktopMocks.createAgendaTask).toHaveBeenCalledOnce(), + ); + const input = desktopMocks.createAgendaTask.mock.calls[0]?.[0]; + expect(input).toMatchObject({ + type: "todo", + title: "Investigate the regression", + instructions: "Inspect the failing build and implement a fix.", + scope: "workspace", + workspaceRoot: "/projects/current", + priority: 3, + modelSelection: { + providerId: "openrouter", + modelId: "anthropic/claude-sonnet-4.6", + }, + automationEligible: true, + }); + expect(Date.parse(input.expiresAt)).toBeGreaterThan(Date.now()); + }); + it("filters scheduled sessions without changing their titles", async () => { const scheduled = { ...makeThread("scheduled", 1), @@ -224,6 +520,56 @@ describe("AgentSidebar session organization", () => { expect(sessionIsVisible("cli session 1")).toBe(true); }); + it("keeps the loading state until the first history response arrives", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + // Before the backend has answered, an empty list means "still loading", + // never "no sessions": the definitive copy would read as lost history. + expect(container.textContent).toContain("Loading session history..."); + expect(container.textContent).not.toContain("No sessions found in history"); + }); + + it("shows the empty state only after the backend answered with zero sessions", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("No sessions found in history"); + expect(container.textContent).not.toContain("Loading session history..."); + }); + it("builds the hover overview with branch and secondary metadata last", () => { const thread = { ...makeThread("cline", 5), @@ -426,6 +772,44 @@ describe("AgentSidebar session organization", () => { expect(setView).not.toHaveBeenCalled(); }); + it("suppresses the settings gear hover state while the Account screen is open", async () => { + invoke.mockResolvedValue(signedInUser); + + const renderSidebar = async (settingsSection: "Account" | "General") => { + await act(async () => { + root.render( + + + + + , + ); + }); + return vi.waitFor(() => { + const button = container.querySelector('[aria-label="Settings"]'); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + }); + }; + + const gearOnAccount = await renderSidebar("Account"); + expect(gearOnAccount.className).toContain("hover:bg-transparent"); + expect(gearOnAccount.className).not.toContain("bg-surface-hover"); + + const gearOnGeneral = await renderSidebar("General"); + expect(gearOnGeneral.className).not.toContain("hover:bg-transparent"); + expect(gearOnGeneral.className).toContain("bg-surface-hover"); + }); + it("shows the desktop app version and connected Hub when the logo is hovered", async () => { const onHome = vi.fn(); invoke.mockImplementation(async (command: string) => { @@ -551,7 +935,7 @@ describe("AgentSidebar session organization", () => { const titleBar = container.querySelector("[data-tauri-drag-region]"); expect(titleBar).not.toBeNull(); - expect(titleBar?.textContent).not.toContain("Cline Code"); + expect(titleBar?.textContent).not.toContain("Cline"); await click( container.querySelector('[aria-label="Previous page"]') as Element, @@ -586,11 +970,13 @@ describe("AgentSidebar session organization", () => { }); const logo = container.querySelector('[aria-label="Cline home"]'); + const showAgenda = container.querySelector('[aria-label="Show Agenda"]'); const newSession = container.querySelector('[aria-label="New Session"]'); const realtimeVoice = container.querySelector( '[data-testid="realtime-voice-control"]', ); expect(logo).not.toBeNull(); + expect(showAgenda).not.toBeNull(); expect(newSession).not.toBeNull(); expect(realtimeVoice?.parentElement).toBe(newSession?.parentElement); expect(newSession?.textContent).toBe(""); @@ -716,3 +1102,29 @@ describe("AgentSidebar session organization", () => { ).toContain("Settings"); }); }); + +function makeAgendaTask( + overrides: Partial = {}, +): AgendaTaskRecord { + return { + taskId: "task-1", + type: "follow-up", + status: "pending_approval", + title: "Review PR checks", + description: "Confirm that CI passed.", + instructions: "Review CI and report failures.", + scope: "workspace", + workspaceRoot: "/projects/cline", + resourcePaths: [], + priority: 1, + availableAt: "2026-08-13T00:00:00.000Z", + expiresAt: "2099-08-20T00:00:00.000Z", + automationEligible: true, + revision: 1, + createdBy: { kind: "agent" }, + updatedBy: { kind: "agent" }, + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z", + ...overrides, + }; +} diff --git a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx index fe9d1ebf4a..70de8675f8 100644 --- a/apps/examples/desktop-app/webview/components/agent-sidebar.tsx +++ b/apps/examples/desktop-app/webview/components/agent-sidebar.tsx @@ -1,13 +1,21 @@ "use client"; +import type { + AgendaTaskPriority, + AgendaTaskRecord, + AgendaTaskType, + HubTaskCreateInput, +} from "@cline/shared"; +import { isChatWorkspacePath } from "@cline/shared/browser"; import { - Activity, ArrowDownUp, Bot, + Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, + ClipboardList, Clock3, Cloud, Code, @@ -16,19 +24,23 @@ import { FolderTree, GitFork, Loader2, + MessageSquarePlus, Network, PanelLeftOpen, Pencil, + Play, Plug, Plus, Radio, Search, - Server, Settings, SlidersHorizontal, Star, + Store, Trash2, Wrench, + X, + Zap, } from "lucide-react"; import { type ReactNode, @@ -38,6 +50,7 @@ import { useRef, useState, } from "react"; +import { AgendaTaskReviewDialog } from "@/components/agenda-task-review-dialog"; import { AppUpdateIndicator } from "@/components/app-update-indicator"; import { ClineLogo } from "@/components/cline-logo"; import { @@ -58,6 +71,14 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -75,6 +96,7 @@ import { import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useSidebar } from "@/components/ui/sidebar"; +import { Textarea } from "@/components/ui/textarea"; import { normalizeTitle } from "@/components/utils"; import { CUSTOMIZATION_SECTIONS, @@ -82,6 +104,7 @@ import { type SettingsSection, } from "@/components/views/settings/sections"; import { useAccount } from "@/contexts/account-context"; +import { useAgendaAutomation, useAgendaTasks } from "@/hooks/use-agenda-tasks"; import type { SessionThread, UseSessionHistoryResult, @@ -94,6 +117,7 @@ import { } from "@/lib/app-channel"; import { isCloudProvisioningSessionId } from "@/lib/cloud-repositories"; import { desktopClient } from "@/lib/desktop-client"; +import { readModelSelectionStorageFromWindow } from "@/lib/model-selection"; import { ALL_SESSION_SOURCES, filterSessionsBySource, @@ -113,6 +137,7 @@ type AppView = "chat" | "sessions" | "settings"; const filterOptions = ["All", "Running", "Schedules", "Favorites"] as const; type FilterOption = (typeof filterOptions)[number]; type SidebarSortMode = "time" | "project"; +type SidebarContent = "sessions" | "agenda"; type DesktopProcessContext = { appVersion?: unknown; hub?: { @@ -146,8 +171,7 @@ const SETTINGS_SECTION_ICONS = { Remote: Network, Account: CircleUserRound, Plugins: Plug, - Skills: Activity, - MCP: Server, + Marketplace: Store, Hooks: Code, Rules: FileText, Agents: Bot, @@ -220,6 +244,7 @@ export function AgentSidebar({ onNavigateBack, onNavigateForward, onNewThread, + onOpenSessionById, onSettingsSectionChange, setView, settingsSection, @@ -227,6 +252,7 @@ export function AgentSidebar({ activeSessionId, sessionHistory, realtimeVoiceControl, + workspaceRoot, }: { canNavigateBack?: boolean; canNavigateForward?: boolean; @@ -234,6 +260,7 @@ export function AgentSidebar({ onNavigateBack?: () => void; onNavigateForward?: () => void; onNewThread?: () => void; + onOpenSessionById?: (sessionId: string) => void | Promise; onSettingsSectionChange: (section: SettingsSection) => void; setView: (view: AppView) => void; settingsSection: SettingsSection; @@ -241,6 +268,7 @@ export function AgentSidebar({ activeSessionId?: string | null; sessionHistory: UseSessionHistoryResult; realtimeVoiceControl?: ReactNode; + workspaceRoot?: string; }) { const { isMobile, setOpen, setOpenMobile, state } = useSidebar(); const isCollapsed = !isMobile && state === "collapsed"; @@ -255,7 +283,7 @@ export function AgentSidebar({ const { deleteThread: deleteHistoryThread, forkThread: forkHistoryThread, - isLoadingHistory, + hasLoadedHistory, isLoadingMore, loadOlderSessions, loadMoreSessions, @@ -271,6 +299,10 @@ export function AgentSidebar({ const [filter, setFilter] = useState("All"); const [sourceFilter, setSourceFilter] = useState(ALL_SESSION_SOURCES); const [sortMode, setSortMode] = useState("time"); + const [sidebarContent, setSidebarContent] = + useState("sessions"); + const [hasNewTodoTasks, setHasNewTodoTasks] = useState(false); + const knownAgendaTaskIdsRef = useRef | null>(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [showMoreCount, setShowMoreCount] = useState( @@ -289,6 +321,38 @@ export function AgentSidebar({ >({}); const [appVersion, setAppVersion] = useState(null); const [hubStatus, setHubStatus] = useState(null); + const normalizedWorkspaceRoot = workspaceRoot?.trim() ?? ""; + const agendaWorkspaceRoot = + normalizedWorkspaceRoot && !isChatWorkspacePath(normalizedWorkspaceRoot) + ? normalizedWorkspaceRoot + : undefined; + const agenda = useAgendaTasks( + { + statuses: ["pending_approval", "approved", "in_progress", "failed"], + workspaceRoot: agendaWorkspaceRoot, + limit: 200, + }, + view !== "settings", + ); + const agendaAutomation = useAgendaAutomation(view !== "settings"); + + useEffect(() => { + if (view === "settings") { + knownAgendaTaskIdsRef.current = null; + return; + } + if (agenda.isLoading) return; + const currentTaskIds = new Set(agenda.tasks.map((task) => task.taskId)); + const knownTaskIds = knownAgendaTaskIdsRef.current; + if ( + knownTaskIds !== null && + sidebarContent !== "agenda" && + agenda.tasks.some((task) => !knownTaskIds.has(task.taskId)) + ) { + setHasNewTodoTasks(true); + } + knownAgendaTaskIdsRef.current = currentTaskIds; + }, [agenda.isLoading, agenda.tasks, sidebarContent, view]); const loadProcessContext = useCallback(async () => { try { @@ -360,6 +424,25 @@ export function AgentSidebar({ const closeMobileSidebar = useCallback(() => { if (isMobile) setOpenMobile(false); }, [isMobile, setOpenMobile]); + const openAgendaSession = useCallback( + (task: AgendaTaskRecord) => { + if (!task.lastSessionId) return; + void onOpenSessionById?.(task.lastSessionId); + closeMobileSidebar(); + }, + [closeMobileSidebar, onOpenSessionById], + ); + const runAgendaTask = useCallback( + async (task: AgendaTaskRecord) => { + try { + const started = await agenda.runTask(task); + if (started.lastSessionId) openAgendaSession(started); + } catch { + // The queue hook exposes the error inline and refreshes after recovery. + } + }, + [agenda.runTask, openAgendaSession], + ); const openThread = useCallback( (threadId: string) => { @@ -398,6 +481,12 @@ export function AgentSidebar({ const navigateForward = useCallback(() => { onNavigateForward?.(); }, [onNavigateForward]); + const toggleSidebarContent = useCallback(() => { + const next = sidebarContent === "agenda" ? "sessions" : "agenda"; + setSidebarContent(next); + if (next === "agenda") setHasNewTodoTasks(false); + if (next === "agenda" && view === "settings") setView("chat"); + }, [setView, sidebarContent, view]); const startRenameThread = useCallback((thread: Thread) => { setEditingSessionId(thread.id); @@ -718,16 +807,47 @@ export function AgentSidebar({ > {realtimeVoiceControl} {!isCollapsed ? ( - + <> + + + ) : null} @@ -761,6 +881,36 @@ export function AgentSidebar({ onSelect={openSettingsSection} /> + ) : sidebarContent === "agenda" ? ( + { + return agenda.cancelTask(task).catch(() => undefined); + }} + onOpen={openAgendaSession} + onRun={(task) => void runAgendaTask(task)} + onToggleAutomation={() => { + void agendaAutomation + .setAutomatic( + agendaAutomation.policy?.mode !== "auto_start" && + agendaAutomation.policy?.mode !== "unattended", + ) + .catch(() => undefined); + }} + pendingTaskIds={agenda.pendingTaskIds} + tasks={agenda.tasks} + workspaceRoot={agendaWorkspaceRoot} + /> ) : ( <>
@@ -808,7 +958,11 @@ export function AgentSidebar({
- {isLoadingHistory && threads.length === 0 ? ( + {/* Empty-state copy is reserved for a definitive zero- + session answer from the backend: before the first + response (or while a failed fetch is being retried) + "No sessions found" would read as lost history. */} + {!hasLoadedHistory && threads.length === 0 ? (
Loading session history...
@@ -957,8 +1111,11 @@ export function AgentSidebar({ className={cn( "size-9 shrink-0 justify-center px-0", view === "settings" && - settingsSection !== "Account" && - "bg-surface-hover text-sidebar-foreground", + (settingsSection !== "Account" + ? "bg-surface-hover text-sidebar-foreground" + : // Clicking the gear is a no-op while the Account (profile) + // screen is open, so don't hint interactivity on hover. + "hover:bg-transparent hover:text-muted-foreground"), )} onClick={openSettings} title="Settings" @@ -1037,6 +1194,421 @@ export function AgentSidebar({ ); } +function AgendaSection({ + tasks, + workspaceRoot, + isLoading, + error, + pendingTaskIds, + automatic, + automationDisabled, + onApprove, + onRun, + onOpen, + onCancel, + onToggleAutomation, + onCreate, +}: { + tasks: AgendaTaskRecord[]; + workspaceRoot?: string; + isLoading: boolean; + error: string | null; + pendingTaskIds: ReadonlySet; + automatic: boolean; + automationDisabled: boolean; + onApprove: (task: AgendaTaskRecord) => Promise; + onRun: (task: AgendaTaskRecord) => void; + onOpen: (task: AgendaTaskRecord) => void; + onCancel: (task: AgendaTaskRecord) => void | Promise; + onToggleAutomation: () => void; + onCreate: (input: HubTaskCreateInput) => Promise; +}) { + const [createOpen, setCreateOpen] = useState(false); + const [reviewTask, setReviewTask] = useState(null); + const [creating, setCreating] = useState(false); + const [title, setTitle] = useState(""); + const [instructions, setInstructions] = useState(""); + const [type, setType] = useState("todo"); + const [priority, setPriority] = useState(3); + const [scope, setScope] = useState<"workspace" | "global">( + workspaceRoot ? "workspace" : "global", + ); + const [expiresAt, setExpiresAt] = useState(() => + new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16), + ); + const resetCreateForm = () => { + setTitle(""); + setInstructions(""); + setType("todo"); + setPriority(3); + setScope(workspaceRoot ? "workspace" : "global"); + setExpiresAt( + new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 16), + ); + }; + const submitCreate = async () => { + const normalizedTitle = title.trim(); + const normalizedInstructions = instructions.trim(); + if (!normalizedTitle || !normalizedInstructions || !expiresAt) return; + const expiration = new Date(expiresAt); + if (Number.isNaN(expiration.getTime())) return; + setCreating(true); + try { + const rememberedModel = readModelSelectionStorageFromWindow(); + const providerId = rememberedModel.lastProvider.trim(); + const modelId = providerId + ? rememberedModel.lastModelByProvider[providerId]?.trim() + : undefined; + await onCreate({ + type, + title: normalizedTitle, + instructions: normalizedInstructions, + scope: scope === "workspace" && workspaceRoot ? "workspace" : "global", + workspaceRoot: + scope === "workspace" && workspaceRoot ? workspaceRoot : undefined, + priority, + modelSelection: providerId + ? { providerId, ...(modelId ? { modelId } : {}) } + : undefined, + expiresAt: expiration.toISOString(), + automationEligible: true, + }); + setCreateOpen(false); + resetCreateForm(); + } catch { + // The Agenda hook surfaces the manager's structured error inline. + } finally { + setCreating(false); + } + }; + return ( +
+
+ Todo +
+ + { + setCreateOpen(open); + if (open) setScope(workspaceRoot ? "workspace" : "global"); + }} + open={createOpen} + > + + + + + + Create Todo Item + +
+ +