From 934faaec7634b34bf4923de7e8e1574216c594ec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:54:22 -0700 Subject: [PATCH 01/20] refactor: deduplicate Linear team readers (#13417) --- src/main/linear/teams.test.ts | 30 ++++++++- src/main/linear/teams.ts | 118 +++++++--------------------------- 2 files changed, 51 insertions(+), 97 deletions(-) diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index d04845e6f28..00d68988bab 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -5,10 +5,12 @@ import { credentialDecryptionMessage } from '../../shared/integration-credential const getClients = vi.fn() const clearToken = vi.fn() const isAuthError = vi.fn() +const acquire = vi.fn().mockResolvedValue(undefined) +const release = vi.fn() vi.mock('./client', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), + acquire, + release, getClients: (...args: unknown[]) => getClients(...args), isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) @@ -283,6 +285,30 @@ describe('Linear teams', () => { expect(members).toHaveBeenCalledWith({ first: 100 }) }) + it.each([ + ['getTeamStates', 'getTeamStatesOrThrow'], + ['getTeamLabels', 'getTeamLabelsOrThrow'], + ['getTeamMembers', 'getTeamMembersOrThrow'] + ] as const)('preserves the %s error policy', async (fallbackReader, throwingReader) => { + const error = new Error('request failed') + const entry = makeTeamLookupEntry('workspace-1', 'Workspace', null) + vi.mocked(entry.client.team).mockRejectedValue(error) + getClients.mockReturnValue([entry]) + const readers = await import('./teams') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(readers[fallbackReader]('team-1', 'workspace-1')).resolves.toEqual([]) + await expect(readers[throwingReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(warn).toHaveBeenCalledWith(`[linear] ${fallbackReader} failed:`, error) + expect(release).toHaveBeenCalledTimes(2) + + isAuthError.mockReturnValue(true) + await expect(readers[fallbackReader]('team-1', 'workspace-1')).rejects.toBe(error) + expect(clearToken).toHaveBeenCalledWith('workspace-1') + expect(release).toHaveBeenCalledTimes(3) + warn.mockRestore() + }) + it('surfaces Linear credential decrypt errors on active team reads', async () => { const error = new Error(credentialDecryptionMessage('Linear')) getClients.mockImplementation(() => { diff --git a/src/main/linear/teams.ts b/src/main/linear/teams.ts index e06697e8d46..004c8fbd92a 100644 --- a/src/main/linear/teams.ts +++ b/src/main/linear/teams.ts @@ -1,3 +1,4 @@ +import type { Team } from '@linear/sdk' import type { LinearTeam, LinearWorkflowState, @@ -108,10 +109,12 @@ export async function listTeamsForAgent( } } -export async function getTeamStates( +async function readTeamResource( teamId: string, - workspaceId?: string | null -): Promise { + workspaceId: string | null | undefined, + fetchAll: (team: Team) => Promise, + fallbackWarning?: string +): Promise { const entry = getClients(workspaceId)[0] if (!entry) { return [] @@ -119,137 +122,62 @@ export async function getTeamStates( await acquire() try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) + return await fetchAll(await entry.client.team(teamId)) } catch (error) { if (isAuthError(error)) { clearToken(entry.workspace.id) throw error } - console.warn('[linear] getTeamStates failed:', error) + if (!fallbackWarning) { + throw error + } + console.warn(`[linear] ${fallbackWarning} failed:`, error) return [] } finally { release() } } +export async function getTeamStates( + teamId: string, + workspaceId?: string | null +): Promise { + return readTeamResource(teamId, workspaceId, fetchAllTeamStates, 'getTeamStates') +} + export async function getTeamStatesOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamStates(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamStates) } export async function getTeamLabels( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamLabels failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, 'getTeamLabels') } export async function getTeamLabelsOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamLabels(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamLabels) } export async function getTeamMembers( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - console.warn('[linear] getTeamMembers failed:', error) - return [] - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, 'getTeamMembers') } export async function getTeamMembersOrThrow( teamId: string, workspaceId?: string | null ): Promise { - const entry = getClients(workspaceId)[0] - if (!entry) { - return [] - } - - await acquire() - try { - const team = await entry.client.team(teamId) - return await fetchAllTeamMembers(team) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } + return readTeamResource(teamId, workspaceId, fetchAllTeamMembers) } export async function getViewerForWorkspaceOrThrow( From 403c60bbadb734d870a22c372aca8a988e348d21 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:55:36 -0700 Subject: [PATCH 02/20] refactor(mobile): remove unused review code (#13421) --- mobile/src/source-control/mobile-pr-create.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index 20f908d3df2..65a70a63ed9 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -2,7 +2,6 @@ import { hostedReviewCopy } from './hosted-review-copy' import { buildMobileHostedReviewCreateParams, createMobileHostedReview, - fetchMobileHostedReviewEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill, shouldPushBeforeMobileHostedReviewCreate, @@ -20,7 +19,6 @@ export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome export { buildMobileHostedReviewCreateParams as buildMobilePrCreateParams, createMobileHostedReview as createMobilePr, - fetchMobileHostedReviewEligibility as fetchMobilePrEligibility, mobileRepoSelectorFromWorktreeId, resolveMobileHostedReviewPrefill as resolveMobilePrPrefill, shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate From 3b1017c4fbd9a3e2cbac66feac35d5c6b5342da2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:01:35 -0700 Subject: [PATCH 03/20] Add nightly cut (#13410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add daily macOS dev build release channel Publish once-daily signed macOS builds from main at a dedicated cadence, separate from hourly (too noisy) and release branches (too infrequent). Builds are notarized and installable via the updater, but unvetted — published to stablyai/orca-daily rather than the main repo to avoid evicting stable/RC entries from the releases feed. * fix lint * fix commit * Add third token mint to daily macOS build workflow The upload step's 2x45m retry budget can outlive the one-hour token, so a third is minted after it for verify and cleanup operations. Release notes are moved to a file to ensure consistency between draft creation and publish. Daily channel description updated with specific UTC release time. --- .github/workflows/daily-mac-build.yml | 471 ++++++++++++++++++ config/electron-builder.config.cjs | 25 +- config/scripts/daily-build-version.mjs | 109 ++++ config/scripts/daily-build-version.test.mjs | 111 +++++ .../scripts/electron-builder-config.test.mjs | 119 ----- ...ectron-builder-mac-channel-config.test.mjs | 152 ++++++ config/scripts/setup-adhoc-release-repo.sh | 2 +- config/scripts/setup-daily-release-repo.sh | 98 ++++ config/scripts/setup-hourly-release-token.sh | 7 +- src/main/updater-release-builds.test.ts | 28 ++ src/main/updater.test.ts | 1 + .../settings/ReleaseChannelSection.tsx | 35 +- src/renderer/src/i18n/locales/en.json | 1 + src/shared/release-channel.test.ts | 44 +- src/shared/release-channel.ts | 61 ++- 15 files changed, 1106 insertions(+), 158 deletions(-) create mode 100644 .github/workflows/daily-mac-build.yml create mode 100755 config/scripts/daily-build-version.mjs create mode 100644 config/scripts/daily-build-version.test.mjs create mode 100644 config/scripts/electron-builder-mac-channel-config.test.mjs create mode 100755 config/scripts/setup-daily-release-repo.sh diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml new file mode 100644 index 00000000000..5670c634d2d --- /dev/null +++ b/.github/workflows/daily-mac-build.yml @@ -0,0 +1,471 @@ +name: Daily macOS Dev Build + +# Why: gives developers a signed macOS build of main once a day that the in-app +# updater can install directly, without waiting for an RC cut — less noise than +# hourly. +# +# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules). +# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute +# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour. +# +# Deliberately narrow scope: +# - macOS only. Other platforms keep using RC/stable. +# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI +# and release-cut remain the gates that matter. +# - Signed AND notarized, exactly like a release. The notary round trip is the +# one slow step kept: macOS anchors a notarized app's TCC grants on identifier +# + team rather than on its cdhash, so those grants survive an update. Without +# a ticket every daily reads as a new client and silently loses file access +# under Documents/Desktop/Downloads. +# +# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main +# repo's releases atom feed exposes only its 10 newest entries, so high-volume +# dev tags would evict every stable/RC entry and break updates for real users. +# Separate from orca-hourly so someone riding main's hourlies never sees the +# sparse daily series mixed into that list. +# +# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the +# same GitHub App as hourly (installed on orca-daily with Contents: Read and +# write). Provision the App secrets with +# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access +# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`: +# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc) +# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key +# +# Installation tokens live one hour, which is why this mints three times. Install +# and build need no token at all, and notarization can hold the publish step for +# tens of minutes; minting again once the build is done starts the clock at the +# first call that actually uses it rather than burning a third of it on +# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that +# second token, so a third is minted after it for verify/publish/prune/cleanup — +# without it a stuck notary run would strand a draft that cleanup gets a 401 on. +# The release stays an unpublished draft until the manifest check passes, so the +# worst case is still an invisible draft — and the build-number query counts +# drafts, so it holds its number and the next run does not reuse it. + +on: + schedule: + # Once a day, early morning Pacific. Single cron — no DST twin, no clock gate. + - cron: '15 14 * * *' + workflow_dispatch: + inputs: + force: + description: Build even if main has not moved since the last daily + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: daily-mac-build + cancel-in-progress: false + +env: + DAILY_REPO: stablyai/orca-daily + # Keep ~30 days so a regression can be bisected without holding a full year. + DAILY_RETAIN_COUNT: 30 + +jobs: + build-daily-mac: + if: github.repository == 'stablyai/orca' + runs-on: blacksmith-6vcpu-macos-15 + # Why 150: it must exceed the worst case the retry budgets below can produce + # (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or + # the job is killed mid-retry and no cleanup step runs at all. A typical run + # is far shorter — this is the notary queue's tail, not its median. + timeout-minutes: 150 + env: + NODE_OPTIONS: --max-old-space-size=4096 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + # Why: this job only reads stablyai/orca and never pushes; every write + # goes to the daily repo through a minted App token passed by env. + # Not persisting the checkout credential shrinks the blast radius if a + # build step is compromised (zizmor: artipacked). + persist-credentials: false + + - name: Mint daily repo token + id: app_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: main is often idle. Rebuilding an unchanged commit burns a runner + # hour and adds a redundant tag to the retention window. + - name: Check whether main moved since the last daily + id: freshness + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }} + run: | + set -euo pipefail + head_sha="$(git rev-parse HEAD)" + echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT" + if [[ "$FORCED" == "true" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "Forced dispatch; building $head_sha." + exit 0 + fi + # The previous daily records its source commit in the release body. + # Drafts are excluded: an unpublished leftover never shipped, so treating + # it as "the last build" would skip a build that never actually happened. + last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \ + --jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)" + if [[ -z "$last_body" ]]; then + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "No prior daily release found; building $head_sha." + exit 0 + fi + last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \ + --jq '.body | capture("commit `(?[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)" + if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then + echo "should_build=false" >>"$GITHUB_OUTPUT" + echo "main is unchanged since $last_body ($last_sha); skipping." + else + echo "should_build=true" >>"$GITHUB_OUTPUT" + echo "main moved to $head_sha (last daily built $last_sha); building." + fi + + - name: Setup pnpm + if: steps.freshness.outputs.should_build == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + if: steps.freshness.outputs.should_build == 'true' + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + if: steps.freshness.outputs.should_build == 'true' + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-mac- + + - name: Install dependencies + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: signing is what makes a daily installable over an existing Orca, so + # a missing cert must fail here rather than after a 20-minute build. + - name: Verify macOS signing environment + if: steps.freshness.outputs.should_build == 'true' + run: node config/scripts/verify-macos-release-env.mjs + env: + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Compute daily version + id: daily + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token.outputs.token }} + MAIN_REPO_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. + # + # Why drafts count here but not in the freshness check: that check asks + # "did this commit ship", where a draft is a no. This one asks "is the + # number free", where a stranded draft still holds one. + names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" + # Why the main repo's tags decide the base version rather than + # package.json: main's version only moves on `release:` commits, and + # stable patches are cut from release branches that never merge back, so + # package.json can sit several patches behind what users are running. A + # separate token because GH_TOKEN above is the App's, scoped to the + # daily repo. Empty on failure — the script then falls back to + # package.json, which is stale but never wrong enough to fail a build. + published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \ + --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ + --json tagName --jq '.[].tagName' || true)" + echo "Highest published tag seen: $(head -1 <<<"$published")" + ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \ + node config/scripts/daily-build-version.mjs \ + >"$RUNNER_TEMP/daily-identity.txt" + grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt" + # Why check rather than trust: the checkout above pins `ref: main`, but a + # workflow_dispatch runs this file from whatever branch was dispatched. A + # branch that edits this step while main still has the old script yields + # an empty name and an untitled release — silent, and only visible once + # someone opens the releases page. Fail here instead. + if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then + echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync." + exit 1 + fi + cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT" + + - name: Build app + if: steps.freshness.outputs.should_build == 'true' + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + # Why: daily builds are not an official channel — telemetry's transport + # gate accepts only 'stable' or 'rc', so leaving this unset keeps them + # silent, which is correct for unvetted dev artifacts. + ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token + + # Why a second mint: everything from here on writes to the daily repo, and + # the notary round trip inside the publish step can be tens of minutes. The + # token minted at the top has already spent install + build of its one hour + # on steps that never touched it; restarting the clock here gives the slow + # part the full budget. + - name: Re-mint daily repo token for publish + id: app_token_publish + if: steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + - name: Create daily release + id: release + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + TAG: v${{ steps.daily.outputs.version }} + NAME: ${{ steps.daily.outputs.name }} + SHA: ${{ steps.freshness.outputs.head_sha }} + run: | + set -euo pipefail + # Kept at 12 even though the title shows 7: the freshness check above + # parses this back out of the body to decide whether main has moved. + short_sha="${SHA:0:12}" + # Why a file rather than an inline string: the publish step re-asserts + # this same body, and the freshness check only works if the two agree + # exactly. One source, written once, read twice. + notes_file="$RUNNER_TEMP/daily-release-notes.md" + cat >"$notes_file" <>"$GITHUB_OUTPUT" + echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT" + + - name: Publish daily macOS artifacts + if: steps.freshness.outputs.should_build == 'true' + uses: nick-fields/retry@v4 + with: + # Why 45 like the release pipeline: an attempt is pack + notarize + + # upload, and the notary queue is the unbounded part. Why 2 attempts and + # not 3: a missed daily costs a day, and a third attempt buys less than + # it costs in runner time once the notary is that stuck. + timeout_minutes: 45 + max_attempts: 2 + retry_wait_seconds: 30 + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + env: + # Why: electron-builder's github publisher targets the repo named in the + # config; the token must therefore carry write access to orca-daily. + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }} + ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }} + CSC_LINK: ${{ secrets.MAC_CERTS }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + # Why all three: electron-builder's notarize step authenticates to the + # Apple notary service with the app-specific password, not with the + # signing cert. Omitting them fails the build rather than skipping it, + # since `notarize` is now on for this path. + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # Why a third mint: the upload step above allows two 45-minute attempts, so + # it can outlive the one-hour token minted before it. Every remaining step + # writes to the daily repo, including the failure path that discards the + # draft — a 401 there is exactly the stranded draft nobody can clean up. + # + # Why always(): a failed or cancelled upload is the case that needs this + # most, since the cleanup step below runs only on that path. + - name: Re-mint daily repo token for verify and cleanup + id: app_token_final + if: always() && steps.freshness.outputs.should_build == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }} + private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }} + owner: stablyai + repositories: orca-daily + + # Why: the updater resolves a tag, then fetches latest-mac.yml from it. A + # release missing that manifest is a tag the picker offers and the download + # 404s on, so fail loudly instead of leaving a broken entry. + - name: Verify update manifest published + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')" + echo "Published assets:" + echo "$assets" + # Why exit 1 without deleting here: the release is still a draft, so it is + # already invisible to users, and the failure handler below owns cleanup. + # Deleting inline under `set -e` would also let the delete's exit code + # preempt this explicit failure. + for required in latest-mac.yml; do + if ! grep -qx "$required" <<<"$assets"; then + echo "::error::Daily draft $TAG is missing $required; the updater could not install it." + exit 1 + fi + done + if ! grep -q '\.zip$' <<<"$assets"; then + echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download." + exit 1 + fi + + # Why this is the last mutating step: publishing the draft is what makes the + # build visible to listReleaseBuilds. Doing it only after the manifest check + # means the picker can never offer a release whose assets are incomplete. + - name: Publish the verified release + id: publish_live + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + NAME: ${{ steps.daily.outputs.name }} + NOTES_FILE: ${{ steps.release.outputs.notes_file }} + run: | + set -euo pipefail + # --title again: electron-builder resolves this draft by tag and may + # rewrite its title on upload. Re-asserting here means the name the + # picker reads is the one composed above, whatever it did in between. + # --notes-file for the same reason, and it matters more: the next run's + # freshness check parses the source commit back out of this body, so a + # body the publisher overwrote would rebuild an unchanged main daily. + gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \ + --title "$NAME" --notes-file "$NOTES_FILE" + echo "Published $TAG as \"$NAME\"" + + # Why: a draft left behind by a failed publish is invisible to users but still + # holds its tag name, so the next run for the same minute would collide. + # + # Why it is gated on publish_live not having succeeded: a later failure (the + # prune step) must not delete a release that already went live and that users + # may already be installing. A job killed by the outer timeout runs no steps + # at all — which is exactly why the release stays a draft until verified. + # Why cancelled() too: a run stopped from the Actions UI is not a failure(), + # so without it a manual cancel mid-publish would strand the draft. + - name: Discard the draft release on failure + if: >- + (failure() || cancelled()) && steps.release.outputs.tag != '' && + steps.publish_live.outcome != 'success' + shell: bash + env: + # Fall back to the publish token: if the re-mint itself is what failed, + # the older token is the only one left and may still have time on it. + GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -uo pipefail + # No --cleanup-tag: an unpublished draft never created a git tag. + echo "Run failed before publish; discarding draft $TAG" + gh release delete "$TAG" --repo "$DAILY_REPO" --yes || + echo "::warning::Could not discard draft $TAG; remove it manually." + + - name: Prune old daily releases + # Only after a live publish: $TAG is then a non-draft we must not delete, + # and failed runs should not reshuffle retention around a draft that the + # failure path is about to discard. + if: steps.publish_live.outcome == 'success' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + # Protect the tag this run just shipped; at the retain cap, a bad sort + # can otherwise mark the newest release as stale and delete it. + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + # Why: --cleanup-tag so pruning does not leave orphan tags behind that + # keep showing up in tag lists with no release or assets attached. + # Drafts are excluded so retention counts shipped builds only; a stale + # draft is handled by the failure path, not by the retention window. + # + # Sort by publishedAt (not createdAt). Many non-draft dailies can share + # one createdAt (bulk import / re-create), so createdAt ranking is + # unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is + # the deterministic tie-break. + # + # Why force $TAG to the front before slicing: a hard retain-window seat + # for this run's release. Dropping $TAG from the list *before* the slice + # would permanently keep retain+1 releases; skipping it only in the + # delete loop would under-prune when the sort is still wrong. Partition + # keeps relative order of every other tag. + jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse' + if [[ -n "${TAG:-}" ]]; then + jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))" + fi + jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName" + stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \ + --jq "$jq_filter")" + if [[ -z "$stale" ]]; then + echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds." + exit 0 + fi + while read -r tag; do + [[ -n "$tag" ]] || continue + # Belt-and-suspenders: partition above should already exclude $TAG. + if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then + echo "::warning::Prune list still included just-published $tag after protect; skipping delete." + continue + fi + echo "Pruning $tag" + gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \ + echo "::warning::Could not prune $tag" + done <<<"$stale" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e581cd7a44e..9933df8156e 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime. // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to // swap them over an installed Orca and macOS treats each build as a new app. const isMacHourly = process.env.ORCA_MAC_HOURLY === '1' +const isMacDaily = process.env.ORCA_MAC_DAILY === '1' const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1' -const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc +const isMacRelease = + process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION const devChannelBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION - : isMacAdhoc - ? process.env.ORCA_ADHOC_BUILD_VERSION - : undefined + : isMacDaily + ? process.env.ORCA_DAILY_BUILD_VERSION + : isMacAdhoc + ? process.env.ORCA_ADHOC_BUILD_VERSION + : undefined // Why each dev channel gets its own repo rather than tagging into the main one: // the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a // day would evict every stable/RC entry and strand users on a feed with nothing -// to install. Keeping adhoc separate from hourly too means a branch build cannot -// be picked up by someone who only meant to ride main. -const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null +// to install. Keeping adhoc/daily separate from hourly too means a branch build +// or a once-a-day cut cannot be picked up by someone who only meant to ride +// main's hourlies. +const devChannelRepo = isMacHourly + ? 'orca-hourly' + : isMacDaily + ? 'orca-daily' + : isMacAdhoc + ? 'orca-adhoc' + : null const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', diff --git a/config/scripts/daily-build-version.mjs b/config/scripts/daily-build-version.mjs new file mode 100755 index 00000000000..1d27ef5fb34 --- /dev/null +++ b/config/scripts/daily-build-version.mjs @@ -0,0 +1,109 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs' +import { + readPublishedVersionsFromEnv, + resolveDevChannelBaseVersion +} from './dev-channel-base-version.mjs' + +/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically + * by semver and every build is uniquely versioned. */ +export function createDailyBuildVersion(baseVersion, date) { + const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion) + if (!match) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + throw new Error('Daily build timestamp is invalid.') + } + const pad = (value, width = 2) => String(value).padStart(width, '0') + const stamp = [ + pad(date.getUTCFullYear(), 4), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()) + ].join('') + // Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the + // RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an + // ordinary RC-channel check offer untested daily builds to RC users. Stripping + // to the base parks dailies below both rc.N and stable ('daily' < 'rc' + // alphabetically), reachable only by an explicit pinned jump. + return `${match[1]}-daily.${stamp}` +} + +/** + * The next build number for `baseVersion`, counting from the titles of existing + * daily releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextDailyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. + */ +export function formatDailyReleaseName(version, buildNumber, commit, date) { + if (!Number.isInteger(buildNumber) || buildNumber < 1) { + throw new Error(`Daily build number must be a positive integer: ${buildNumber}`) + } + return [ + version.split('-')[0], + String(buildNumber).padStart(2, '0'), + formatReleaseTitleTimestamp(date), + commit.slice(0, 7) + ].join(' • ') +} + +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) + const version = createDailyBuildVersion(base, now) + const buildNumber = nextDailyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatDailyReleaseName(version, buildNumber, commit, now) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getDailyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) + // Consumed by the workflow via $GITHUB_OUTPUT. + process.stdout.write( + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` + ) +} diff --git a/config/scripts/daily-build-version.test.mjs b/config/scripts/daily-build-version.test.mjs new file mode 100644 index 00000000000..7f0e71b92c9 --- /dev/null +++ b/config/scripts/daily-build-version.test.mjs @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + createDailyBuildVersion, + formatDailyReleaseName, + nextDailyBuildNumber +} from './daily-build-version.mjs' +import { compareAppVersions } from '../../src/shared/app-version' + +describe('createDailyBuildVersion', () => { + it('stamps the version with a zero-padded UTC timestamp', () => { + expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe( + '1.4.160-daily.202607281300' + ) + }) + + // Why: main's package.json carries the in-flight RC tail. Keeping it would make + // every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X > + // 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily + // builds to RC users. Dropping it parks dailies below both rc.N and stable, + // reachable only by an explicit pinned jump. + it('drops an in-flight rc tail so dailies never outrank the rc series', () => { + const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z')) + expect(version).toBe('1.4.160-daily.202607281300') + expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0) + expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0) + }) + + // Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same + // base never outranks an hourly — both stay below rc/stable and only the + // channel picker offers them. + it('sorts below the hourly build of the same base version', () => { + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + + it('rejects invalid input', () => { + expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/) + expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/) + }) +}) + +describe('formatDailyReleaseName', () => { + const name = (iso, buildNumber = 1, commit = 'e698241abcde') => + formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso)) + + it('renders version, number, Pacific timestamp, and short sha', () => { + // 13:15 UTC is 6:15AM PDT in July (the daily cut time). + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so + // the offset between them is not a constant. A test pinned to one season would + // pass all summer and start failing in November. + it('follows the Pacific offset across DST', () => { + expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241') + expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241') + }) + + it('pads to two digits and grows past them', () => { + expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ') + expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ') + }) + + it('rejects a build number that is not a positive integer', () => { + expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/) + expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/) + }) + + it('rejects an invalid timestamp', () => { + expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow( + /invalid/ + ) + }) +}) + +describe('nextDailyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 28, 6:00AM • e698241', + '1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa', + '1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10) + }) + + it('restarts at 1 when the base version moves', () => { + expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextDailyBuildNumber('1.4.163')).toBe(1) + expect(nextDailyBuildNumber('1.4.163', [])).toBe(1) + }) + + it('ignores titles that are not this version', () => { + expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1) + expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3227d957b24..2ecda0e80eb 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -19,42 +19,6 @@ const { verifyPackagedMainRuntimeDeps } = require('../packaged-runtime-node-modules.cjs') -const MUTABLE_BUILD_ENV = [ - 'ORCA_MAC_HOURLY', - 'ORCA_MAC_ADHOC', - 'ORCA_MAC_RELEASE', - 'ORCA_HOURLY_BUILD_VERSION', - 'ORCA_ADHOC_BUILD_VERSION', - 'ORCA_LOCAL_BUILD_VERSION' -] - -/** Re-requires the config under a temporary env, then restores env and module cache. */ -function withEnv(env, assert) { - const configPath = require.resolve('../electron-builder.config.cjs') - const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) - try { - for (const key of MUTABLE_BUILD_ENV) { - delete process.env[key] - } - Object.assign(process.env, env) - delete require.cache[configPath] - assert(require('../electron-builder.config.cjs')) - } finally { - for (const [key, value] of Object.entries(original)) { - if (value === undefined) { - delete process.env[key] - } else { - process.env[key] = value - } - } - delete require.cache[configPath] - require('../electron-builder.config.cjs') - } -} - -const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) -const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) - describe('electron-builder config', () => { it('keeps the packaged app identity aligned with local-build validation', () => { expect(electronBuilderConfig.appId).toBe( @@ -303,89 +267,6 @@ describe('electron-builder config', () => { } }) - // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the - // same bundle id and a valid Developer ID signature. A hourly built on the local - // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real - // Orca — the whole point of the channel. - it('builds hourly artifacts with the release signing identity', () => { - withHourlyEnv((config) => { - expect(config.mac.appId).toBeUndefined() - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.forceCodeSigning).toBe(true) - }) - }) - - // Why hourly must notarize despite the round trip: TCC anchors a notarized - // Developer ID app's grants on identifier + team, not on its cdhash, so they - // survive an update. An unnotarized hourly reads as a new client every build - // and loses file access under Documents/Desktop/Downloads with no re-prompt. - it('notarizes hourly builds like releases, and neither locally', () => { - withHourlyEnv((config) => { - expect(config.mac.notarize).toBe(true) - }) - withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { - expect(config.mac.notarize).toBe(true) - }) - expect(electronBuilderConfig.mac.notarize).toBe(false) - }) - - // Why: the main repo's releases atom feed exposes only its 10 newest entries. - // Publishing 24 hourly tags a day there would evict every stable/RC entry and - // break update checks for every real user. - it('publishes hourly builds to the separate hourly repo', () => { - withHourlyEnv((config) => { - expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) - }) - expect(electronBuilderConfig.publish).toMatchObject({ - repo: 'orca', - releaseType: 'release' - }) - }) - - it('stamps hourly packages with the hourly version', () => { - withEnv( - { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) - } - ) - }) - - // Why adhoc carries the identical mac identity to hourly: it installs over a - // real Orca through the same updater path, so the same signing and the same TCC - // argument apply. Only the destination repo differs. - it('builds adhoc artifacts with the release identity and its own repo', () => { - withAdhocEnv((config) => { - expect(config.appId).toBe('com.stablyai.orca') - expect(config.mac.hardenedRuntime).toBe(true) - expect(config.mac.notarize).toBe(true) - expect(config.forceCodeSigning).toBe(true) - expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) - }) - }) - - it('stamps adhoc packages with the adhoc version', () => { - withEnv( - { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, - (config) => { - expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) - } - ) - }) - - // Why: the two dev channels share every packaging decision except where they - // publish, so a future edit that collapses them must not also collapse the - // repos — a branch build landing in orca-hourly would be offered to everyone - // riding main. - it('keeps the two dev channels on separate repos', () => { - withHourlyEnv((hourly) => { - withAdhocEnv((adhoc) => { - expect(hourly.publish.repo).not.toBe(adhoc.publish.repo) - }) - }) - }) - it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs new file mode 100644 index 00000000000..dbd5a1170a1 --- /dev/null +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -0,0 +1,152 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MUTABLE_BUILD_ENV = [ + 'ORCA_MAC_HOURLY', + 'ORCA_MAC_DAILY', + 'ORCA_MAC_ADHOC', + 'ORCA_MAC_RELEASE', + 'ORCA_HOURLY_BUILD_VERSION', + 'ORCA_DAILY_BUILD_VERSION', + 'ORCA_ADHOC_BUILD_VERSION', + 'ORCA_LOCAL_BUILD_VERSION' +] + +/** Re-requires the config under a temporary env, then restores env and module cache. */ +function withEnv(env, assert) { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of MUTABLE_BUILD_ENV) { + delete process.env[key] + } + Object.assign(process.env, env) + delete require.cache[configPath] + assert(require('../electron-builder.config.cjs')) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } +} + +const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert) +const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert) +const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert) + +describe('electron-builder mac channel config', () => { + // Why: Squirrel.Mac swaps the .app in place only when the replacement carries the + // same bundle id and a valid Developer ID signature. A hourly built on the local + // (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real + // Orca — the whole point of the channel. + it('builds hourly artifacts with the release signing identity', () => { + withHourlyEnv((config) => { + expect(config.mac.appId).toBeUndefined() + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.forceCodeSigning).toBe(true) + }) + }) + + // Why hourly must notarize despite the round trip: TCC anchors a notarized + // Developer ID app's grants on identifier + team, not on its cdhash, so they + // survive an update. An unnotarized hourly reads as a new client every build + // and loses file access under Documents/Desktop/Downloads with no re-prompt. + it('notarizes hourly builds like releases, and neither locally', () => { + withHourlyEnv((config) => { + expect(config.mac.notarize).toBe(true) + }) + withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => { + expect(config.mac.notarize).toBe(true) + }) + expect(electronBuilderConfig.mac.notarize).toBe(false) + }) + + // Why: the main repo's releases atom feed exposes only its 10 newest entries. + // Publishing 24 hourly tags a day there would evict every stable/RC entry and + // break update checks for every real user. + it('publishes hourly builds to the separate hourly repo', () => { + withHourlyEnv((config) => { + expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' }) + }) + expect(electronBuilderConfig.publish).toMatchObject({ + repo: 'orca', + releaseType: 'release' + }) + }) + + it('stamps hourly packages with the hourly version', () => { + withEnv( + { ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' }) + } + ) + }) + + // Why adhoc carries the identical mac identity to hourly: it installs over a + // real Orca through the same updater path, so the same signing and the same TCC + // argument apply. Only the destination repo differs. + it('builds adhoc artifacts with the release identity and its own repo', () => { + withAdhocEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' }) + }) + }) + + it('stamps adhoc packages with the adhoc version', () => { + withEnv( + { ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' }) + } + ) + }) + + it('builds daily artifacts with the release identity and its own repo', () => { + withDailyEnv((config) => { + expect(config.appId).toBe('com.stablyai.orca') + expect(config.mac.hardenedRuntime).toBe(true) + expect(config.mac.notarize).toBe(true) + expect(config.forceCodeSigning).toBe(true) + expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' }) + }) + }) + + it('stamps daily packages with the daily version', () => { + withEnv( + { ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' }, + (config) => { + expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' }) + } + ) + }) + + // Why: the dev channels share every packaging decision except where they + // publish, so a future edit that collapses them must not also collapse the + // repos — a branch or daily build landing in orca-hourly would be offered to + // everyone riding main's hourlies. + it('keeps the dev channels on separate repos', () => { + withHourlyEnv((hourly) => { + withDailyEnv((daily) => { + withAdhocEnv((adhoc) => { + expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe( + 3 + ) + }) + }) + }) + }) +}) diff --git a/config/scripts/setup-adhoc-release-repo.sh b/config/scripts/setup-adhoc-release-repo.sh index 3e232c5fce8..1f3debf125c 100755 --- a/config/scripts/setup-adhoc-release-repo.sh +++ b/config/scripts/setup-adhoc-release-repo.sh @@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in 1. Open: https://github.com/organizations/$ORG/settings/installations 2. Configure -> $APP_SLUG 3. Repository access -> Only select repositories -> add $ADHOC_REPO - (keep orca-hourly selected; both dev channels use this one App) + (keep orca-hourly/orca-daily selected; all dev channels use this one App) 4. Save. EOF fi diff --git a/config/scripts/setup-daily-release-repo.sh b/config/scripts/setup-daily-release-repo.sh new file mode 100755 index 00000000000..e9437c0c64a --- /dev/null +++ b/config/scripts/setup-daily-release-repo.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Creates stablyai/orca-daily and grants the existing release App write access to +# it, so daily-mac-build.yml can publish there. +# +# Why a separate repo rather than reusing orca-hourly: the daily channel is a +# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a +# sparse daily series into the 72-entry hourly retention window and make both +# pickers harder to read. +# +# Why no secrets are set here: the daily workflow reuses the same GitHub App as +# hourly/adhoc — one App id, one private key, one thing to rotate. This script +# only has to widen that App's installation to cover the new repo. +# +# Run once, after config/scripts/setup-hourly-release-token.sh: +# bash config/scripts/setup-daily-release-repo.sh +# +set -euo pipefail + +ORG="stablyai" +DAILY_REPO="$ORG/orca-daily" +MAIN_REPO="$ORG/orca" +APP_SLUG="orca-hourly-release" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com" +gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login" + +if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then + echo "$DAILY_REPO already exists." +else + echo "Creating $DAILY_REPO..." + # Why public: the in-app updater fetches release assets unauthenticated, exactly + # as it does for orca-hourly. A private repo would 404 for every client. + # + # Why the features are off: this repo holds releases and nothing else. Leaving + # issues open invites bug reports against an unvetted daily in a repo nobody + # watches, where they are simply lost. + # + # Why --add-readme in a repo with no source: publishing a release creates a tag, + # and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in. + gh repo create "$DAILY_REPO" \ + --public \ + --description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \ + --add-readme \ + --disable-issues \ + --disable-wiki || + fail "Could not create $DAILY_REPO." +fi + +# Also checked outside the create branch: a repo made before --add-readme is here. +if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then + fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first." +fi + +echo +echo "Granting $APP_SLUG access to $DAILY_REPO..." + +# Why attempt the API before printing instructions: an org owner can do this in +# one call. Everyone else gets a 403 and the manual path below — GitHub does not +# let a mere admin widen an App's repository selection. +INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \ + --jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)" +REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)" + +GRANTED=false +if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then + if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then + GRANTED=true + echo "Done — $APP_SLUG can now write to $DAILY_REPO." + fi +fi + +if [[ "$GRANTED" != "true" ]]; then + # Why no automated check afterwards: the endpoints that report an App's + # repository access (repos/*/installation, user/installations/*/repositories) + # both reject an ordinary `gh auth login` token, so any "verified" this script + # printed would be guesswork. The smoke test below is the real check. + cat < $APP_SLUG + 3. Repository access -> Only select repositories -> add $DAILY_REPO + (keep orca-hourly and orca-adhoc selected; all dev channels use this one App) + 4. Save. +EOF +fi + +echo +echo "Smoke-test the pipeline (after this merges):" +echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true" +echo " gh run watch --repo $MAIN_REPO" diff --git a/config/scripts/setup-hourly-release-token.sh b/config/scripts/setup-hourly-release-token.sh index 3d846bc01d7..77f6b483445 100755 --- a/config/scripts/setup-hourly-release-token.sh +++ b/config/scripts/setup-hourly-release-token.sh @@ -8,9 +8,10 @@ # — no yearly rotation — and it belongs to the org rather than to the person who # created it, so it survives that person leaving. # -# The same App also serves adhoc-mac-build.yml, which reads these same two -# secrets: one credential, one rotation, both dev channels. Widening it to cover -# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job. +# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which +# read these same two secrets: one credential, one rotation, all dev channels. +# Widening it to cover stablyai/orca-adhoc / orca-daily is +# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job. # # The key is read from a file and piped straight into `gh secret set`. It is never # echoed, never passed as a command-line argument (argv is world-readable via diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index b9344954a5a..5e7b55e9f75 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -45,6 +45,25 @@ describe('listReleaseBuilds', () => { ]) }) + it('lists daily builds from the dedicated repo, newest first', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.160-daily.202607271300'), + release('v1.4.160-daily.202607291300'), + release('v1.4.160-daily.202607281300') + ]) + ) + + const builds = await listReleaseBuilds('daily') + + expect(fetchMock.mock.calls[0][0]).toContain('stablyai/orca-daily') + expect(builds.map((build) => build.version)).toEqual([ + '1.4.160-daily.202607291300', + '1.4.160-daily.202607281300', + '1.4.160-daily.202607271300' + ]) + }) + // Why: the main repo serves stable and rc from one endpoint, so an unfiltered // list would offer RC tags under the Stable channel. it('separates stable from rc in the shared main repo', async () => { @@ -124,6 +143,15 @@ describe('resolveTargetBuild', () => { }) }) + it('pins a daily tag at the daily repo download path', () => { + expect(resolveTargetBuild('daily', 'v1.4.160-daily.202607281300')).toEqual({ + tag: 'v1.4.160-daily.202607281300', + version: '1.4.160-daily.202607281300', + feedUrl: + 'https://github.com/stablyai/orca-daily/releases/download/v1.4.160-daily.202607281300' + }) + }) + it('pins a stable tag at the main repo download path', () => { expect(resolveTargetBuild('stable', 'v1.4.159').feedUrl).toBe( 'https://github.com/stablyai/orca/releases/download/v1.4.159' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index dce8b39a7b6..1f5cdc06994 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -301,6 +301,7 @@ describe('updater', () => { it.each([ ['hourly', 'v1.4.160-hourly.202607281400', 'Hourly builds are produced only for macOS.'], + ['daily', 'v1.4.160-daily.202607281300', 'Daily builds are produced only for macOS.'], ['adhoc', 'v1.4.160-adhoc.20260728140533', 'Adhoc builds are produced only for macOS.'] ] as const)( 'uses the display label in the mac-only %s pinned-build error', diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index b89d8aa5084..bfe2321bd32 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -16,6 +16,7 @@ import { hasDedicatedReleaseRepo, isChannelSupportedOnPlatform, parseDevBuildStamp, + type DedicatedRepoChannel, type ReleaseBuild, type ReleaseChannel } from '../../../../shared/release-channel' @@ -24,9 +25,30 @@ const CHANNEL_DESCRIPTIONS: Record = { stable: 'Shipped releases. What everyone else is running.', rc: 'Release candidates cut ahead of each stable.', hourly: 'macOS only. Unvetted builds from main, built every hour. No tests.', + daily: + 'macOS only. Unvetted builds from main, cut once a day at 14:15 UTC (early morning Pacific). No tests.', adhoc: 'macOS only. One-off builds cut from a branch to try a feature before it lands.' } +const DEDICATED_CHANNEL_WARNINGS: Record = + { + hourly: { + key: 'auto.components.settings.ReleaseChannelSection.hourlyWarning', + fallback: + 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + daily: { + key: 'auto.components.settings.ReleaseChannelSection.dailyWarning', + fallback: + 'Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' + }, + adhoc: { + key: 'auto.components.settings.ReleaseChannelSection.adhocWarning', + fallback: + 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' + } + } + function formatBuildLabel(build: ReleaseBuild): string { // Why the release's own title wins: the build workflows compose it (hourly // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`), @@ -226,15 +248,10 @@ export function ReleaseChannelSection(): React.JSX.Element {

- {activeChannel === 'hourly' - ? translate( - 'auto.components.settings.ReleaseChannelSection.hourlyWarning', - 'Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.' - ) - : translate( - 'auto.components.settings.ReleaseChannelSection.adhocWarning', - 'Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.' - )} + {translate( + DEDICATED_CHANNEL_WARNINGS[activeChannel].key, + DEDICATED_CHANNEL_WARNINGS[activeChannel].fallback + )}

) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 08fd9b389c8..3e0980342eb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10389,6 +10389,7 @@ "devOnly": "Dev only", "channelAriaLabel": "Update channel", "hourlyWarning": "Hourly builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", + "dailyWarning": "Daily builds are macOS-only and ship straight from main with no test gate. Keep a stable build handy.", "adhocWarning": "Adhoc builds are macOS-only and come from a branch that has not landed. Whoever cut one may abandon it — keep a stable build handy.", "loadingBuilds": "Loading builds…", "noBuilds": "No builds found", diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index a12cc94c929..fc7172efbf6 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatAdhocVersion, + formatDailyVersion, formatHourlyVersion, getReleaseNotesUrlForVersion, getReleaseRepoForChannel, @@ -8,9 +9,11 @@ import { hasDedicatedReleaseRepo, isAdhocVersion, isChannelSupportedOnPlatform, + isDailyVersion, isHourlyVersion, isReleaseChannel, parseAdhocVersionStamp, + parseDailyVersionStamp, parseDevBuildStamp, parseHourlyVersionStamp, sortReleaseBuildsNewestFirst, @@ -24,6 +27,7 @@ describe('release channel', () => { expect(getVersionChannel('v1.4.160')).toBe('stable') expect(getVersionChannel('1.4.160-rc.3')).toBe('rc') expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly') + expect(getVersionChannel('1.4.160-daily.202607281300')).toBe('daily') expect(getVersionChannel('1.4.160-adhoc.20260728140533')).toBe('adhoc') expect(getVersionChannel('not-a-version')).toBeNull() }) @@ -33,7 +37,8 @@ describe('release channel', () => { // entry and leave real users with nothing to update to. it('keeps dev builds out of the main release repo, and apart from each other', () => { expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly') - // Why adhoc gets a third repo rather than sharing hourly's: an unlanded + expect(getReleaseRepoForChannel('daily')).toBe('stablyai/orca-daily') + // Why adhoc gets its own repo rather than sharing hourly's: an unlanded // branch build must never surface to someone who only meant to ride main. expect(getReleaseRepoForChannel('adhoc')).toBe('stablyai/orca-adhoc') expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca') @@ -42,6 +47,7 @@ describe('release channel', () => { it('marks exactly the dev channels as having their own repo', () => { expect(hasDedicatedReleaseRepo('hourly')).toBe(true) + expect(hasDedicatedReleaseRepo('daily')).toBe(true) expect(hasDedicatedReleaseRepo('adhoc')).toBe(true) expect(hasDedicatedReleaseRepo('stable')).toBe(false) expect(hasDedicatedReleaseRepo('rc')).toBe(false) @@ -53,6 +59,9 @@ describe('release channel', () => { expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe( 'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400' ) + expect(getReleaseNotesUrlForVersion('1.4.160-daily.202607281300')).toBe( + 'https://github.com/stablyai/orca-daily/releases/tag/v1.4.160-daily.202607281300' + ) expect(getReleaseNotesUrlForVersion('1.4.160')).toBe( 'https://github.com/stablyai/orca/releases/tag/v1.4.160' ) @@ -71,6 +80,12 @@ describe('release channel', () => { expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z') }) + it('round-trips a daily version stamp as UTC', () => { + const version = formatDailyVersion('1.4.160', '202607281300') + expect(isDailyVersion(version)).toBe(true) + expect(parseDailyVersionStamp(version)?.toISOString()).toBe('2026-07-28T13:00:00.000Z') + }) + it('rejects malformed hourly identifiers', () => { expect(isHourlyVersion('1.4.160-hourly')).toBe(false) expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false) @@ -91,6 +106,8 @@ describe('release channel', () => { expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe( '2028-02-29T00:00:00.000Z' ) + expect(parseDailyVersionStamp('1.4.160-daily.202602300000')).toBeNull() + expect(parseDailyVersionStamp('not-a-version-daily.202601010000')).toBeNull() }) // Why seconds and not hourly's minutes: adhoc builds are dispatched on demand, @@ -102,9 +119,12 @@ describe('release channel', () => { expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z') }) - it('keeps the two dev stamp formats from matching each other', () => { + it('keeps the dev stamp formats from matching each other', () => { expect(isAdhocVersion('1.4.160-hourly.202607281400')).toBe(false) expect(isHourlyVersion('1.4.160-adhoc.20260728140533')).toBe(false) + expect(isDailyVersion('1.4.160-hourly.202607281400')).toBe(false) + expect(isHourlyVersion('1.4.160-daily.202607281300')).toBe(false) + expect(isDailyVersion('1.4.160-adhoc.20260728140533')).toBe(false) // A 12-digit adhoc tail is an hourly stamp wearing the wrong identifier, not // a second-resolution one; rejecting it keeps the parse unambiguous. expect(isAdhocVersion('1.4.160-adhoc.202607281405')).toBe(false) @@ -118,13 +138,16 @@ describe('release channel', () => { expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull() }) - // Why one entry point for both: the picker renders a row without knowing which + // Why one entry point for all: the picker renders a row without knowing which // dev channel produced it, so a channel added without a case here would fall // back to showing its raw opaque timestamp tail. - it('reads the build timestamp of either dev channel', () => { + it('reads the build timestamp of any dev channel', () => { expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe( '2026-07-28T14:05:00.000Z' ) + expect(parseDevBuildStamp('1.4.160-daily.202607281300')?.toISOString()).toBe( + '2026-07-28T13:00:00.000Z' + ) expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe( '2026-07-28T14:05:33.000Z' ) @@ -132,11 +155,11 @@ describe('release channel', () => { expect(parseDevBuildStamp('1.4.160')).toBeNull() }) - // Why: both dev workflows are macOS-only, so the channels have no artifact to + // Why: all dev workflows are macOS-only, so the channels have no artifact to // offer elsewhere. Both the picker and the main-process check read this, so a // regression here would silently re-expose an uninstallable channel. it('offers the dev channels only on macOS', () => { - for (const channel of ['hourly', 'adhoc'] as const) { + for (const channel of ['hourly', 'daily', 'adhoc'] as const) { expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true) expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false) expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(false) @@ -152,6 +175,7 @@ describe('release channel', () => { it('accepts only known channels', () => { expect(isReleaseChannel('hourly')).toBe(true) + expect(isReleaseChannel('daily')).toBe(true) expect(isReleaseChannel('adhoc')).toBe(true) expect(isReleaseChannel('stable')).toBe(true) expect(isReleaseChannel('nightly')).toBe(false) @@ -188,6 +212,13 @@ describe('release channel', () => { expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0) }) + it('orders a daily below its own stable release and below hourly of the same base', () => { + expect(compareAppVersions('1.4.160-daily.202607281300', '1.4.160')).toBeLessThan(0) + expect( + compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400') + ).toBeLessThan(0) + }) + // Why adhoc sits at the very bottom: it is an unlanded branch, the least // trustworthy thing the updater can hand anyone. Every other channel of the // same base version must outrank it so no routine check ever selects one. @@ -196,6 +227,7 @@ describe('release channel', () => { expect(compareAppVersions(adhoc, '1.4.160')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-rc.1')).toBeLessThan(0) expect(compareAppVersions(adhoc, '1.4.160-hourly.202607280000')).toBeLessThan(0) + expect(compareAppVersions(adhoc, '1.4.160-daily.202607281300')).toBeLessThan(0) }) it('sorts consecutive adhoc builds newest first', () => { diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index 743ef6529f3..74b08f8eea5 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -1,13 +1,20 @@ import { compareAppVersions, isValidAppVersion } from './app-version' -export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'adhoc' +export type ReleaseChannel = 'stable' | 'rc' | 'hourly' | 'daily' | 'adhoc' -export const RELEASE_CHANNELS: readonly ReleaseChannel[] = ['stable', 'rc', 'hourly', 'adhoc'] +export const RELEASE_CHANNELS: readonly ReleaseChannel[] = [ + 'stable', + 'rc', + 'hourly', + 'daily', + 'adhoc' +] export const RELEASE_CHANNEL_LABELS: Readonly> = { stable: 'Stable', rc: 'RC', hourly: 'Hourly', + daily: 'Daily', adhoc: 'Adhoc' } @@ -15,14 +22,16 @@ export const RELEASE_CHANNEL_LABELS: Readonly> = * releases atom feed, which only exposes the 10 newest entries — 24 hourly * tags a day would evict every stable/RC entry and strand real users. */ export const HOURLY_RELEASE_REPO = 'stablyai/orca-hourly' +export const DAILY_RELEASE_REPO = 'stablyai/orca-daily' export const ADHOC_RELEASE_REPO = 'stablyai/orca-adhoc' export const MAIN_RELEASE_REPO = 'stablyai/orca' export const HOURLY_PRERELEASE_IDENTIFIER = 'hourly' +export const DAILY_PRERELEASE_IDENTIFIER = 'daily' export const ADHOC_PRERELEASE_IDENTIFIER = 'adhoc' /** The dev channels, each published to its own repo rather than the main one. */ -const DEDICATED_REPO_CHANNELS = ['hourly', 'adhoc'] as const +const DEDICATED_REPO_CHANNELS = ['hourly', 'daily', 'adhoc'] as const export type DedicatedRepoChannel = (typeof DEDICATED_REPO_CHANNELS)[number] @@ -30,6 +39,7 @@ const CHANNEL_RELEASE_REPOS: Record = { stable: MAIN_RELEASE_REPO, rc: MAIN_RELEASE_REPO, hourly: HOURLY_RELEASE_REPO, + daily: DAILY_RELEASE_REPO, adhoc: ADHOC_RELEASE_REPO } @@ -47,8 +57,8 @@ export function hasDedicatedReleaseRepo(channel: ReleaseChannel): channel is Ded * Shared so the picker, the main-process check, and any future surface cannot * drift on where a channel is available. * - * Why this rides on the dev-channel list: both dev channels are produced only by - * macOS workflows, so neither has an artifact to offer elsewhere. If one ever + * Why this rides on the dev-channel list: all dev channels are produced only by + * macOS workflows, so none has an artifact to offer elsewhere. If one ever * gains a Windows or Linux job, split the two concepts apart — they coincide * today, but "published to its own repo" and "built for macOS only" are not the * same claim. @@ -72,14 +82,19 @@ export function normalizeTagToVersion(tag: string): string { * uniquely versioned so electron-updater never reads one as "same version". */ const HOURLY_VERSION = /^\d+\.\d+\.\d+-hourly\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ +/** `1.4.160-daily.202607281415` — same minute stamp as hourly. Daily cuts once + * per day, so collisions are not a concern; the stamp still carries the hour so + * a forced re-cut the same calendar day remains unique. */ +const DAILY_VERSION = /^\d+\.\d+\.\d+-daily\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})$/ + /** * `1.4.160-adhoc.20260728140533` — same idea, but stamped to the second. * - * Why seconds here and not for hourly: hourly runs under a concurrency group, so - * two of them can never be cut in the same minute. Adhoc builds are dispatched - * on demand by whoever wants one, so two people cutting from different branches - * at once is ordinary — and a minute-resolution stamp would collide on the tag - * and fail the second build eight minutes in. + * Why seconds here and not for hourly/daily: those run under a concurrency + * group, so two of them can never be cut in the same minute. Adhoc builds are + * dispatched on demand by whoever wants one, so two people cutting from + * different branches at once is ordinary — and a minute-resolution stamp would + * collide on the tag and fail the second build eight minutes in. */ const ADHOC_VERSION = /^\d+\.\d+\.\d+-adhoc\.(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/ @@ -113,6 +128,10 @@ export function isHourlyVersion(version: string): boolean { return HOURLY_VERSION.test(normalizeTagToVersion(version)) } +export function isDailyVersion(version: string): boolean { + return DAILY_VERSION.test(normalizeTagToVersion(version)) +} + export function isAdhocVersion(version: string): boolean { return ADHOC_VERSION.test(normalizeTagToVersion(version)) } @@ -121,6 +140,10 @@ export function formatHourlyVersion(baseVersion: string, stamp: string): string return `${baseVersion}-${HOURLY_PRERELEASE_IDENTIFIER}.${stamp}` } +export function formatDailyVersion(baseVersion: string, stamp: string): string { + return `${baseVersion}-${DAILY_PRERELEASE_IDENTIFIER}.${stamp}` +} + export function formatAdhocVersion(baseVersion: string, stamp: string): string { return `${baseVersion}-${ADHOC_PRERELEASE_IDENTIFIER}.${stamp}` } @@ -130,15 +153,24 @@ export function parseHourlyVersionStamp(version: string): Date | null { return parseStampedVersion(version, HOURLY_VERSION) } +/** Returns the build's UTC timestamp, or null when the version isn't daily. */ +export function parseDailyVersionStamp(version: string): Date | null { + return parseStampedVersion(version, DAILY_VERSION) +} + /** Returns the build's UTC timestamp, or null when the version isn't adhoc. */ export function parseAdhocVersionStamp(version: string): Date | null { return parseStampedVersion(version, ADHOC_VERSION) } -/** The build's UTC timestamp for either dev channel, so a picker row can render - * a date without first working out which channel produced the version. */ +/** The build's UTC timestamp for any dev channel, so a picker row can render a + * date without first working out which channel produced the version. */ export function parseDevBuildStamp(version: string): Date | null { - return parseHourlyVersionStamp(version) ?? parseAdhocVersionStamp(version) + return ( + parseHourlyVersionStamp(version) ?? + parseDailyVersionStamp(version) ?? + parseAdhocVersionStamp(version) + ) } export function getVersionChannel(version: string): ReleaseChannel | null { @@ -149,6 +181,9 @@ export function getVersionChannel(version: string): ReleaseChannel | null { if (isHourlyVersion(normalized)) { return 'hourly' } + if (isDailyVersion(normalized)) { + return 'daily' + } if (isAdhocVersion(normalized)) { return 'adhoc' } From 8a33ce2536e7b478ae234bc5888a5cb383da2f4c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:04:05 -0700 Subject: [PATCH 04/20] [leas code] Simplify boolean guards (#13401) * refactor: remove unreachable code * refactor: simplify boolean guards --- .../right-sidebar-activity-visibility.ts | 18 ++++++------------ .../terminal/active-terminal-repair.ts | 16 +++++----------- .../src/lib/new-workspace-enter-guard.ts | 15 +-------------- .../pane-manager/windows-pty-compatibility.ts | 16 ++++++---------- .../src/lib/passive-macos-app-data-access.ts | 19 +++++++------------ 5 files changed, 25 insertions(+), 59 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts index bcb65fa6123..f4bc0958959 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts +++ b/src/renderer/src/components/right-sidebar/right-sidebar-activity-visibility.ts @@ -10,16 +10,10 @@ export function getVisibleRightSidebarActivityItems( items: ActivityBarItem[], { isFolder, isFolderWorkspace, isSshRepo }: RightSidebarActivityVisibilityState ): ActivityBarItem[] { - return items.filter((item) => { - if (item.gitOnly && isFolder) { - return false - } - if (item.folderOnly && !isFolderWorkspace) { - return false - } - if (item.sshOnly && !isSshRepo) { - return false - } - return true - }) + return items.filter( + (item) => + (!item.gitOnly || !isFolder) && + (!item.folderOnly || isFolderWorkspace) && + (!item.sshOnly || isSshRepo) + ) } diff --git a/src/renderer/src/components/terminal/active-terminal-repair.ts b/src/renderer/src/components/terminal/active-terminal-repair.ts index 86a491775bc..81dc5b2c6d3 100644 --- a/src/renderer/src/components/terminal/active-terminal-repair.ts +++ b/src/renderer/src/components/terminal/active-terminal-repair.ts @@ -5,19 +5,13 @@ export function shouldRepairActiveTerminalTab(args: { activeTabId: string | null tabs: TerminalTab[] }): boolean { - if (args.activeTabType !== 'terminal') { - return false - } - if (args.tabs.length === 0) { - return false - } - if (args.activeTabId && args.tabs.some((tab) => tab.id === args.activeTabId)) { - return false - } - return true + return ( + args.activeTabType === 'terminal' && + args.tabs.length > 0 && + (!args.activeTabId || !args.tabs.some((tab) => tab.id === args.activeTabId)) + ) } -// Resolve which terminal tab to open after a project/agent is selected, or null if no repair is needed. export function resolveRepairedActiveTerminalTabId(args: { activeTabType: WorkspaceVisibleTabType activeTabId: string | null diff --git a/src/renderer/src/lib/new-workspace-enter-guard.ts b/src/renderer/src/lib/new-workspace-enter-guard.ts index 0359a4892a5..3993ada5055 100644 --- a/src/renderer/src/lib/new-workspace-enter-guard.ts +++ b/src/renderer/src/lib/new-workspace-enter-guard.ts @@ -1,21 +1,8 @@ -/** - * Returns true when an Enter keydown event should be suppressed for submit actions. - * - * Two cases must be blocked: - * 1. IME composition is active — Enter only confirms the conversion candidate. - * 2. Shift+Enter inside a textarea — intended as a newline, not a submit. - */ export function shouldSuppressEnterSubmit( event: { isComposing: boolean; shiftKey: boolean }, isTextarea: boolean ): boolean { - if (event.isComposing) { - return true - } - if (isTextarea && event.shiftKey) { - return true - } - return false + return event.isComposing || (isTextarea && event.shiftKey) } export function shouldAllowComposerEnterSubmitTarget( diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 5dd0349507c..6b5c9141b0b 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -84,16 +84,12 @@ export function resolveWindowsShellOverride( * local pane from a serve pane, so callers gate it with `isLocalNativeWindowsConpty`. */ export function isLocalNativeWindowsPty(context: WindowsPtyCompatibilityContext): boolean { - if (!isWindowsUserAgent(context.userAgent)) { - return false - } - if (context.connectionId !== null) { - return false - } - if (isWslCwd(context.cwd) || isWslShellOverride(context.shellOverride)) { - return false - } - return true + return ( + isWindowsUserAgent(context.userAgent) && + context.connectionId === null && + !isWslCwd(context.cwd) && + !isWslShellOverride(context.shellOverride) + ) } /** diff --git a/src/renderer/src/lib/passive-macos-app-data-access.ts b/src/renderer/src/lib/passive-macos-app-data-access.ts index 5be004d612e..994db53c242 100644 --- a/src/renderer/src/lib/passive-macos-app-data-access.ts +++ b/src/renderer/src/lib/passive-macos-app-data-access.ts @@ -34,18 +34,13 @@ export function hasInteractiveActiveGitStatusConsumer(args: ActiveGitStatusPolli if (!args.activeWorktreeId || !args.worktreePath) { return false } - if ( - args.rightSidebarOpen && - (args.rightSidebarTab === 'source-control' || - (args.rightSidebarTab === 'explorer' && args.rightSidebarExplorerView !== 'search') || - args.rightSidebarTab === 'checks') - ) { - return true - } - if ((args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId)) { - return true - } - return false + return ( + (args.rightSidebarOpen && + (args.rightSidebarTab === 'source-control' || + (args.rightSidebarTab === 'explorer' && args.rightSidebarExplorerView !== 'search') || + args.rightSidebarTab === 'checks')) || + (args.openFiles ?? []).some((file) => file.worktreeId === args.activeWorktreeId) + ) } export function shouldPollActiveGitStatus(args: ActiveGitStatusPollingArgs): boolean { From f3ea376b6b26695d58e340ee9ca33a3cc8e9f90d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:06:07 -0700 Subject: [PATCH 05/20] refactor(rate-limits): share initial account target policy (#13428) --- .../claude-rate-limit-target.test.ts | 179 ------------------ .../rate-limits/claude-rate-limit-target.ts | 68 +------ .../codex-rate-limit-target.test.ts | 179 ------------------ .../rate-limits/codex-rate-limit-target.ts | 67 +------ .../initial-account-rate-limit-target.test.ts | 163 ++++++++++++++++ .../initial-account-rate-limit-target.ts | 80 ++++++++ .../project-runtime-rate-limit-target.ts | 29 --- 7 files changed, 258 insertions(+), 507 deletions(-) delete mode 100644 src/main/rate-limits/claude-rate-limit-target.test.ts delete mode 100644 src/main/rate-limits/codex-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/initial-account-rate-limit-target.test.ts create mode 100644 src/main/rate-limits/initial-account-rate-limit-target.ts delete mode 100644 src/main/rate-limits/project-runtime-rate-limit-target.ts diff --git a/src/main/rate-limits/claude-rate-limit-target.test.ts b/src/main/rate-limits/claude-rate-limit-target.test.ts deleted file mode 100644 index 969124655ad..00000000000 --- a/src/main/rate-limits/claude-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' -import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' - -function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { - const next = { ...settings } as Partial - delete next.localAccountRuntime - return next as GlobalSettings -} - -describe('getInitialClaudeRateLimitTarget', () => { - it('uses the configured WSL account runtime before agent detection runtime', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Fedora', - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) - }) - - it('uses the single selected WSL account distro when account runtime is WSL default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeClaudeManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('uses a single WSL-only active account after restart', () => { - expect( - getInitialClaudeRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }) - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto follows the global WSL project runtime default', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('keeps explicit host runtime on host', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'host', - localAgentRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - activeClaudeManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialClaudeRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'linux' - ) - ).toEqual({ runtime: 'host' }) - }) -}) diff --git a/src/main/rate-limits/claude-rate-limit-target.ts b/src/main/rate-limits/claude-rate-limit-target.ts index 199250cd3e5..dba2e396669 100644 --- a/src/main/rate-limits/claude-rate-limit-target.ts +++ b/src/main/rate-limits/claude-rate-limit-target.ts @@ -1,71 +1,21 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getClaudeWslSelectionKey, normalizeClaudeRuntimeSelection, type ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { - const selection = normalizeClaudeRuntimeSelection(settings) - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length !== 1) { - return null - } - const [distroKey] = selectedWslEntries[0] - return distroKey === getClaudeWslSelectionKey(null) ? null : distroKey -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialClaudeRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): ClaudeAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - const selection = normalizeClaudeRuntimeSelection(settings) - if (!selection.host) { - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length === 1) { - const [distroKey] = selectedWslEntries[0] - return { - runtime: 'wsl', - wslDistro: distroKey === getClaudeWslSelectionKey(null) ? null : distroKey - } - } - } - - return { runtime: 'host' } + return getInitialAccountRateLimitTarget( + settings, + { + getWslSelectionKey: getClaudeWslSelectionKey, + normalizeRuntimeSelection: normalizeClaudeRuntimeSelection + }, + platform + ) } diff --git a/src/main/rate-limits/codex-rate-limit-target.test.ts b/src/main/rate-limits/codex-rate-limit-target.test.ts deleted file mode 100644 index b3b39f027f9..00000000000 --- a/src/main/rate-limits/codex-rate-limit-target.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getDefaultSettings } from '../../shared/constants' -import type { GlobalSettings } from '../../shared/types' -import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' - -function legacySettingsWithoutAccountRuntime(settings: GlobalSettings): GlobalSettings { - const next = { ...settings } as Partial - delete next.localAccountRuntime - return next as GlobalSettings -} - -describe('getInitialCodexRateLimitTarget', () => { - it('uses the configured WSL account runtime before agent detection runtime', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Fedora', - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Fedora' }) - }) - - it('uses the single selected WSL account distro when account runtime is WSL default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - activeCodexManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale terminal WSL distro when account runtime is WSL default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: null, - terminalWindowsWslDistro: 'Debian', - activeCodexManagedAccountIdsByRuntime: { - host: 'host-account-1', - wsl: {} - } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: null }) - }) - - it('uses the global WSL project runtime default when account runtime is unset', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, - localAgentRuntime: 'host', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('ignores stale legacy WSL agent and terminal settings when the project default is host', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - localWindowsRuntimeDefault: { kind: 'windows-host' }, - localAgentRuntime: 'wsl', - localAgentWslDistro: 'Ubuntu', - terminalWindowsShell: 'wsl.exe', - terminalWindowsWslDistro: 'Debian' - }), - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('uses a single WSL-only active account after restart', () => { - expect( - getInitialCodexRateLimitTarget( - legacySettingsWithoutAccountRuntime({ - ...getDefaultSettings('/tmp'), - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }) - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto follows the global WSL project runtime default', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'win32' - ) - ).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) - }) - - it('auto resolves to host when the global project runtime is windows-host', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('does not let a stale WSL-only selection override an auto host target', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'auto', - localWindowsRuntimeDefault: { kind: 'windows-host' }, - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('keeps explicit host runtime on host', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'host', - localAgentRuntime: 'host', - terminalWindowsShell: 'wsl.exe', - activeCodexManagedAccountIdsByRuntime: { - host: null, - wsl: { Ubuntu: 'wsl-account-1' } - } - }, - 'win32' - ) - ).toEqual({ runtime: 'host' }) - }) - - it('ignores a stale explicit WSL runtime on non-Windows hosts', () => { - expect( - getInitialCodexRateLimitTarget( - { - ...getDefaultSettings('/tmp'), - localAccountRuntime: 'wsl', - localAccountWslDistro: 'Ubuntu' - }, - 'darwin' - ) - ).toEqual({ runtime: 'host' }) - }) -}) diff --git a/src/main/rate-limits/codex-rate-limit-target.ts b/src/main/rate-limits/codex-rate-limit-target.ts index 65c459ad7d3..61c116bab2b 100644 --- a/src/main/rate-limits/codex-rate-limit-target.ts +++ b/src/main/rate-limits/codex-rate-limit-target.ts @@ -1,73 +1,18 @@ import type { GlobalSettings } from '../../shared/types' -import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' import { getWslSelectionKey, normalizeCodexRuntimeSelection, type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - getProjectRuntimeRateLimitTarget, - normalizeOptionalDistro -} from './project-runtime-rate-limit-target' - -function getSingleSelectedWslDistro(settings: GlobalSettings): string | null { - const selection = normalizeCodexRuntimeSelection(settings) - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length !== 1) { - return null - } - const [distroKey] = selectedWslEntries[0] - return distroKey === getWslSelectionKey(null) ? null : distroKey -} +import { getInitialAccountRateLimitTarget } from './initial-account-rate-limit-target' export function getInitialCodexRateLimitTarget( settings: GlobalSettings, platform: NodeJS.Platform = process.platform ): CodexAccountSelectionTarget { - if (settings.localAccountRuntime === 'host') { - return { runtime: 'host' } - } - if (settings.localAccountRuntime === 'wsl') { - if (platform !== 'win32') { - return { runtime: 'host' } - } - return { - runtime: 'wsl', - wslDistro: - normalizeOptionalDistro(settings.localAccountWslDistro) ?? - getSingleSelectedWslDistro(settings) - } - } - if (settings.localAccountRuntime === 'auto') { - const target = resolveLocalAccountRuntimeTarget(settings, platform) - return target.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - // Why: pre-setting profiles used account selection as their startup fallback. - const projectRuntimeTarget = getProjectRuntimeRateLimitTarget(settings, platform) - if (projectRuntimeTarget) { - return projectRuntimeTarget - } - - const selection = normalizeCodexRuntimeSelection(settings) - if (!selection.host) { - const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => - Boolean(accountId) - ) - if (selectedWslEntries.length === 1) { - const [distroKey] = selectedWslEntries[0] - // Why: after restart there is no last-clicked switcher target, but a - // single WSL-only active account is the least surprising quota context. - return { - runtime: 'wsl', - wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey - } - } - } - - return { runtime: 'host' } + return getInitialAccountRateLimitTarget( + settings, + { getWslSelectionKey, normalizeRuntimeSelection: normalizeCodexRuntimeSelection }, + platform + ) } diff --git a/src/main/rate-limits/initial-account-rate-limit-target.test.ts b/src/main/rate-limits/initial-account-rate-limit-target.test.ts new file mode 100644 index 00000000000..528a7527968 --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { getInitialClaudeRateLimitTarget } from './claude-rate-limit-target' +import { getInitialCodexRateLimitTarget } from './codex-rate-limit-target' + +type RuntimeSelection = { + host: string | null + wsl: Record +} + +type Scenario = { + name: string + settings: Partial + expected: { runtime: 'host' } | { runtime: 'wsl'; wslDistro: string | null } + legacy?: boolean + platform?: NodeJS.Platform + selection?: RuntimeSelection +} + +const providers = [ + { + name: 'Codex', + getTarget: getInitialCodexRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeCodexManagedAccountIdsByRuntime: selection + }) + }, + { + name: 'Claude', + getTarget: getInitialClaudeRateLimitTarget, + selectionSettings: (selection: RuntimeSelection) => ({ + activeClaudeManagedAccountIdsByRuntime: selection + }) + } +] as const + +const scenarios: Scenario[] = [ + { + name: 'uses the trimmed configured WSL distro before the selected account', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: ' Fedora ', + localAgentRuntime: 'host', + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Fedora' } + }, + { + name: 'uses the single selected distro for an unpinned WSL runtime', + settings: { localAccountRuntime: 'wsl' }, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'ignores a stale terminal distro for an unpinned WSL runtime', + settings: { + localAccountRuntime: 'wsl', + localAccountWslDistro: null, + terminalWindowsWslDistro: 'Debian' + }, + selection: { host: 'host-account-1', wsl: {} }, + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'keeps an explicit host runtime on host', + settings: { localAccountRuntime: 'host', terminalWindowsShell: 'wsl.exe' }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'ignores an explicit WSL runtime on non-Windows hosts', + settings: { localAccountRuntime: 'wsl', localAccountWslDistro: 'Ubuntu' }, + platform: 'linux', + expected: { runtime: 'host' } + }, + { + name: 'auto follows the global WSL runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'auto keeps the global host runtime before stale selections', + settings: { + localAccountRuntime: 'auto', + localWindowsRuntimeDefault: { kind: 'windows-host' } + }, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings use the global WSL runtime before selections', + settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } }, + selection: { host: null, wsl: { Debian: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings ignore stale agent and terminal WSL values', + settings: { + localWindowsRuntimeDefault: { kind: 'windows-host' }, + localAgentRuntime: 'wsl', + localAgentWslDistro: 'Ubuntu', + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy settings preserve a single WSL selection on macOS', + settings: {}, + selection: { host: null, wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + platform: 'darwin', + expected: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }, + { + name: 'legacy settings map the provider default WSL key to no distro', + settings: {}, + selection: { host: null, wsl: { __default__: 'wsl-account-1' } }, + legacy: true, + platform: 'linux', + expected: { runtime: 'wsl', wslDistro: null } + }, + { + name: 'legacy selection fallback requires no selected host account', + settings: {}, + selection: { host: 'host-account-1', wsl: { Ubuntu: 'wsl-account-1' } }, + legacy: true, + expected: { runtime: 'host' } + }, + { + name: 'legacy selection fallback requires exactly one selected WSL account', + settings: {}, + selection: { + host: null, + wsl: { Debian: 'wsl-account-1', Fedora: null, Ubuntu: 'wsl-account-2' } + }, + legacy: true, + expected: { runtime: 'host' } + } +] + +describe.each(providers)('$name initial rate-limit target', ({ getTarget, selectionSettings }) => { + it.each(scenarios)('$name', (scenario) => { + const settings = { + ...getDefaultSettings('/tmp'), + ...scenario.settings, + ...(scenario.selection ? selectionSettings(scenario.selection) : {}) + } + if (scenario.legacy) { + delete (settings as Partial).localAccountRuntime + } + + expect(getTarget(settings, scenario.platform ?? 'win32')).toEqual(scenario.expected) + }) +}) diff --git a/src/main/rate-limits/initial-account-rate-limit-target.ts b/src/main/rate-limits/initial-account-rate-limit-target.ts new file mode 100644 index 00000000000..d60dab69daf --- /dev/null +++ b/src/main/rate-limits/initial-account-rate-limit-target.ts @@ -0,0 +1,80 @@ +import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime' +import type { GlobalSettings } from '../../shared/types' + +type AccountRuntimeSelection = { + host: string | null + wsl: Record +} + +type AccountRuntimeSelectionProvider = { + getWslSelectionKey: (wslDistro: string | null | undefined) => string + normalizeRuntimeSelection: (settings: GlobalSettings) => AccountRuntimeSelection +} + +type WslRateLimitRuntimeTarget = { runtime: 'wsl'; wslDistro: string | null } + +export type AccountRateLimitRuntimeTarget = { runtime: 'host' } | WslRateLimitRuntimeTarget + +export function getInitialAccountRateLimitTarget( + settings: GlobalSettings, + provider: AccountRuntimeSelectionProvider, + platform: NodeJS.Platform = process.platform +): AccountRateLimitRuntimeTarget { + if (settings.localAccountRuntime === 'host') { + return { runtime: 'host' } + } + + if (settings.localAccountRuntime === 'wsl') { + if (platform !== 'win32') { + return { runtime: 'host' } + } + const configuredTarget = resolveLocalAccountRuntimeTarget(settings, platform) + return { + runtime: 'wsl', + wslDistro: + configuredTarget.wslDistro ?? + getSingleSelectedWslTarget( + provider.normalizeRuntimeSelection(settings), + provider.getWslSelectionKey + )?.wslDistro ?? + null + } + } + + // Pre-setting profiles omit the policy and fall back to project/account selection. + const resolvedTarget = toRateLimitTarget(resolveLocalAccountRuntimeTarget(settings, platform)) + if (settings.localAccountRuntime === 'auto' || resolvedTarget.runtime === 'wsl') { + return resolvedTarget + } + + const selection = provider.normalizeRuntimeSelection(settings) + return selection.host + ? { runtime: 'host' } + : (getSingleSelectedWslTarget(selection, provider.getWslSelectionKey) ?? { runtime: 'host' }) +} + +function getSingleSelectedWslTarget( + selection: AccountRuntimeSelection, + getWslSelectionKey: AccountRuntimeSelectionProvider['getWslSelectionKey'] +): WslRateLimitRuntimeTarget | null { + const selectedWslEntries = Object.entries(selection.wsl).filter(([, accountId]) => + Boolean(accountId) + ) + if (selectedWslEntries.length !== 1) { + return null + } + + const [distroKey] = selectedWslEntries[0] + return { + runtime: 'wsl', + wslDistro: distroKey === getWslSelectionKey(null) ? null : distroKey + } +} + +function toRateLimitTarget( + target: ReturnType +): AccountRateLimitRuntimeTarget { + return target.runtime === 'wsl' + ? { runtime: 'wsl', wslDistro: target.wslDistro } + : { runtime: 'host' } +} diff --git a/src/main/rate-limits/project-runtime-rate-limit-target.ts b/src/main/rate-limits/project-runtime-rate-limit-target.ts deleted file mode 100644 index b6504235aa3..00000000000 --- a/src/main/rate-limits/project-runtime-rate-limit-target.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { GlobalSettings } from '../../shared/types' -import { normalizeGlobalWindowsRuntimeDefault } from '../../shared/project-execution-runtime' - -export type AccountRateLimitRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null } - -export function getProjectRuntimeRateLimitTarget( - settings: GlobalSettings, - platform: NodeJS.Platform -): AccountRateLimitRuntimeTarget | null { - if (platform !== 'win32') { - return null - } - - const runtimeDefault = normalizeGlobalWindowsRuntimeDefault(settings.localWindowsRuntimeDefault) - if (runtimeDefault.kind !== 'wsl') { - return null - } - - // Why: account quota polling has no project id, so its best default is the - // global project runtime instead of stale terminal/agent WSL settings. - return { runtime: 'wsl', wslDistro: runtimeDefault.distro } -} - -export function normalizeOptionalDistro(value: string | null | undefined): string | null { - const trimmed = value?.trim() - return trimmed ? trimmed : null -} From a3ca887a3c08439a89c9ce7db95f480ef29c5384 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:09:49 -0700 Subject: [PATCH 06/20] fix(workspaces): start task agents on remote hosts (#13412) --- ...poserState-host-context-boundaries.test.ts | 2 ++ src/renderer/src/hooks/useComposerState.ts | 6 +++- .../src/lib/worktree-activation.test.ts | 32 +++++++++++++++++ src/renderer/src/lib/worktree-activation.ts | 28 +++++++++++---- .../src/lib/worktree-creation-flow.test.ts | 26 ++++++++++---- .../src/lib/worktree-creation-flow.ts | 13 +++++-- .../src/store/slices/worktree-helpers.ts | 2 ++ .../src/store/slices/worktrees.test.ts | 36 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 2 ++ 9 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index f7aa99c8201..635399b63d8 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -750,6 +750,7 @@ describe('useComposerState host-context boundaries', () => { 'const submitQuick = useCallback' ) expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') + expect(fullSubmit).toContain('startupDraft: startupPlan.draftPrompt') expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') const quickSubmit = sourceBetween( @@ -771,6 +772,7 @@ describe('useComposerState host-context boundaries', () => { ) expect(activation).toContain('...(startupPlan && !backendSpawnedStartup') + expect(activation).toContain('backendStartupTerminalSpawned: true') expect(activation).toContain('command: startupPlan.launchCommand') expect(activation).toContain('launchAgent: tuiAgent') // The removed activation-time fallback must not come back through this caller. diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2dcfe314f3e..35f403c8e09 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3849,7 +3849,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS submitCompareBaseRef, { linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), - linkedTaskSourceContext: taskSourceContext + linkedTaskSourceContext: taskSourceContext, + ...(!backendStartup && startupPlan?.draftPrompt + ? { startupDraft: startupPlan.draftPrompt } + : {}) } ) const worktree = result.worktree @@ -3877,6 +3880,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setup: result.setup, defaultTabs: result.defaultTabs, issueCommand, + ...(backendSpawnedStartup ? { backendStartupTerminalSpawned: true } : {}), ...(startupPlan && !backendSpawnedStartup ? { startup: { diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index cdebf649389..923cecd5aed 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -370,6 +370,38 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) + it('does not create a fallback while a backend startup terminal awaits mirroring', () => { + useAppStore.setState({ + getKnownWorktreeById: ((id: string) => + id === 'wt-1' + ? { id: 'wt-1' } + : undefined) as unknown as AppStoreState['getKnownWorktreeById'] + } as Partial) + const store = createMockStore() + + const result = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + undefined, + { command: 'gh issue view 42' }, + undefined, + { backendStartupTerminalSpawned: true } + ) + + expect(result).toBeNull() + expect(store.createTab).not.toHaveBeenCalled() + expect(store.setActiveTab).not.toHaveBeenCalled() + + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [{ id: 'mirror-tab-1' }] } + } as unknown as Partial) + + expect(useAppStore.getState().pendingIssueCommandSplitByTabId['mirror-tab-1']).toEqual({ + command: 'gh issue view 42' + }) + }) + it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => { useAppStore.setState((state) => ({ settings: state.settings diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 7e317024123..d27a18565ef 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -171,6 +171,11 @@ type WorktreeActivationStore = Partial & { settings?: Pick | null } +type InitialTerminalOptions = { + activateCreatedTabs?: boolean + backendStartupTerminalSpawned?: boolean +} + /** * Shared activation sequence used by the worktree palette and add-repo/worktree dialogs. * The caller passes only `worktreeId`; the helper derives `repoId` and returns early @@ -279,6 +284,7 @@ export function activateAndRevealWorktree( notifyHostRuntime?: boolean revealInSidebar?: boolean executionHostId?: ExecutionHostId + backendStartupTerminalSpawned?: boolean } ): ActivateAndRevealResult | false { const state = useAppStore.getState() @@ -339,7 +345,8 @@ export function activateAndRevealWorktree( opts?.startup, opts?.setup, opts?.issueCommand, - opts?.defaultTabs + opts?.defaultTabs, + opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) @@ -371,7 +378,7 @@ export function activateAndRevealWorktree( } } - if (opts?.notifyHostRuntime !== false) { + if (opts?.notifyHostRuntime !== false && !opts?.backendStartupTerminalSpawned) { ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId) } @@ -432,7 +439,7 @@ export function ensureWorktreeHasInitialTerminal( setup?: WorktreeSetupLaunch, issueCommand?: IssueCommandLaunch, defaultTabs?: WorktreeDefaultTabsLaunch, - opts?: { activateCreatedTabs?: boolean } + opts?: InitialTerminalOptions ): string | null { const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId) // Why: creating a terminal just because the legacy terminal slice is empty gives editor/browser-only worktrees an unexpected extra tab. @@ -459,8 +466,12 @@ export function ensureWorktreeHasInitialTerminal( wrappedSetupCommandStr = sequenced.setupCommand } - // Why: web clients mirror the server's session tabs, so avoid spawning a duplicate host terminal before the mirror lands. - if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))) { + const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true + // Why: explicit spawn evidence survives the new-worktree ownership race; active web sessions provide the same authority for later activations. + if ( + backendStartupTerminalSpawned || + isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId)) + ) { const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id if (existingTerminalTabId && (setup || issueCommand)) { queueSetupAndIssueCommands( @@ -474,6 +485,9 @@ export function ensureWorktreeHasInitialTerminal( ) return existingTerminalTabId } + if (existingTerminalTabId && backendStartupTerminalSpawned) { + return existingTerminalTabId + } if (setup || issueCommand) { // Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them. queueHookCommandsForFirstWorktreeTab({ @@ -586,7 +600,7 @@ function applyDefaultTerminalTabs( issueCommand: IssueCommandLaunch | undefined, defaultTabs: WorktreeDefaultTabsLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): string | null { if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) { return null @@ -675,7 +689,7 @@ function queueSetupAndIssueCommands( setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, wrappedSetupCommandStr: string | undefined, - opts: { activateCreatedTabs?: boolean } | undefined + opts: InitialTerminalOptions | undefined ): void { // Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent. if (setup) { diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index ad24a825076..f48591a8b2f 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -512,13 +512,14 @@ describe('staged background worktree creation', () => { expect(store.setSidebarOpen).not.toHaveBeenCalled() }) - it('does not reveal a completed staged create after the user leaves the creation surface', async () => { + it('keeps a backend startup terminal in the background after the user leaves', async () => { store.activeView = 'tasks' store.createWorktree.mockResolvedValueOnce({ worktree: { id: 'wt-1', repoId: 'repo-1' - } + }, + startupTerminal: { tabId: 'agent-tab', spawned: true } }) const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), { @@ -535,7 +536,7 @@ describe('staged background worktree creation', () => { undefined, undefined, undefined, - { activateCreatedTabs: false } + { activateCreatedTabs: false, backendStartupTerminalSpawned: true } ) expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -543,8 +544,11 @@ describe('staged background worktree creation', () => { }) }) - it('reveals the completed workspace after the user switches to another workspace', async () => { - let resolveCreate!: (result: { worktree: { id: string; repoId: string } }) => void + it('reveals a backend-owned startup after the user switches workspaces', async () => { + let resolveCreate!: (result: { + worktree: { id: string; repoId: string } + startupTerminal: { tabId: string; spawned: true } + }) => void store.createWorktree.mockReturnValueOnce( new Promise((resolve) => { resolveCreate = resolve @@ -560,11 +564,15 @@ describe('staged background worktree creation', () => { // Why: selecting a real workspace clears only the pending surface pointer; // completion should still finish the task-launch handoff once it is ready. store.activePendingCreationId = null - resolveCreate({ worktree: { id: 'wt-1', repoId: 'repo-1' } }) + resolveCreate({ + worktree: { id: 'wt-1', repoId: 'repo-1' }, + startupTerminal: { tabId: 'agent-tab', spawned: true } + }) await flushAsyncWorktreeCreation() expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', { - sidebarRevealBehavior: 'auto' + sidebarRevealBehavior: 'auto', + backendStartupTerminalSpawned: true }) expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', { @@ -731,6 +739,10 @@ describe('staged background worktree creation', () => { expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'agent-tab' }) ) + const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined + expect(createCall?.[25]).toEqual({ + startupDraft: 'https://github.com/o/r/issues/12' + }) }) it.each([ diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 83451b8ce9f..132f4825dc8 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -140,6 +140,10 @@ async function executeWorktreeCreation( : {}), ...(preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } + : {}), + // Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell. + ...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt + ? { startupDraft: preparedRequest.launchDraftPrompt } : {}) } ) @@ -167,7 +171,6 @@ async function executeWorktreeCreation( } const worktree = result.worktree - // Why: if the user dismissed/cancelled while the create was in flight, the entry // is gone. Git already made the worktree on disk, but don't auto-provision (trust // write, terminal, agent, note) work they abandoned — it surfaces as a plain row @@ -210,7 +213,8 @@ async function executeWorktreeCreation( ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), - ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}) + ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } else { @@ -224,7 +228,10 @@ async function executeWorktreeCreation( result.setup, preparedRequest.issueCommand, result.defaultTabs, - { activateCreatedTabs: false } + { + activateCreatedTabs: false, + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + } ) } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16a58fd985f..499a4865cfe 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -198,6 +198,8 @@ export type WorktreeSlice = { automationProvenanceRequest?: CreateWorktreeArgs['automationProvenanceRequest'] linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null + /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ + startupDraft?: string } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index b13a197b597..6709169daca 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5913,6 +5913,42 @@ describe('worktree remote runtime mutations', () => { ) }) + it('passes task startup drafts only to the owning remote runtime', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/task-draft', + repoId: 'repo1', + path: '/path/task-draft' + }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-create', + ok: true, + result: { worktree: wt }, + _meta: { runtimeId: 'runtime-remote' } + }) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [] } + } as Partial) + const createWorktree = store.getState().createWorktree + const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] + args[10] = 'codex' + args[25] = { startupDraft: 'https://github.com/stablyai/orca/issues/12' } + + await createWorktree(...args) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'worktree.create', + params: expect.objectContaining({ + createdWithAgent: 'codex', + startupDraft: 'https://github.com/stablyai/orca/issues/12' + }) + }) + ) + expect(mockApi.worktrees.create).not.toHaveBeenCalled() + }) + it('passes startup commands through local worktree creation IPC', async () => { const store = createTestStore() const wt = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index f2efca80185..51613ebdb8a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3989,6 +3989,7 @@ export const createWorktreeSlice: StateCreator const automationProvenanceRequest = options?.automationProvenanceRequest const linkedWorkItem = options?.linkedWorkItem const linkedTaskSourceContext = options?.linkedTaskSourceContext + const startupDraft = options?.startupDraft try { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(name, attempt) @@ -4095,6 +4096,7 @@ export const createWorktreeSlice: StateCreator ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(startupDraft ? { startupDraft } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { From faaf3d2588ded913482d160b639434516b2a8129 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:16:26 -0700 Subject: [PATCH 07/20] refactor(mobile): use shared diff and OSC implementations (#13433) --- .../app/h/[hostId]/session/[worktreeId].tsx | 2 +- mobile/src/session/mobile-diff-comments.ts | 37 ++----------------- mobile/src/terminal/TerminalWebView.tsx | 2 +- .../src/terminal/terminal-osc-link-ranges.ts | 18 --------- .../src/terminal/terminal-webview-contract.ts | 2 +- .../src/terminal/terminal-webview-messages.ts | 2 +- 6 files changed, 7 insertions(+), 56 deletions(-) delete mode 100644 mobile/src/terminal/terminal-osc-link-ranges.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 755d6605501..105f14e8c70 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -106,7 +106,7 @@ import type { TerminalModes, TerminalWebViewHandle } from '../../../../src/terminal/terminal-webview-contract' -import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' +import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges' import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { diff --git a/mobile/src/session/mobile-diff-comments.ts b/mobile/src/session/mobile-diff-comments.ts index b204cba19c5..7fc7140fd42 100644 --- a/mobile/src/session/mobile-diff-comments.ts +++ b/mobile/src/session/mobile-diff-comments.ts @@ -1,4 +1,7 @@ import type { DiffComment, DiffReviewScope } from '../../../src/shared/types' +import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format' + +export { formatDiffComment, formatDiffComments } export type CreateMobileDiffCommentInput = { worktreeId: string @@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isMarkdownComment(comment: Pick): boolean { - return comment.source === 'markdown' -} - function normalizeScope(value: unknown): DiffReviewScope | undefined { return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined } -// Why: mobile Vitest/Metro run from the mobile package and cannot transform -// runtime imports from root src/shared. Keep this byte-for-byte compatible with -// the desktop shared formatter contract. -export function formatDiffComment(c: DiffComment): string { - const escaped = c.body - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - const locationLabel = - c.lineNumber === 0 - ? 'Scope: file' - : c.startLine !== undefined && c.startLine !== c.lineNumber - ? `Lines: ${c.startLine}-${c.lineNumber}` - : `Line: ${c.lineNumber}` - if (!isMarkdownComment(c)) { - return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n') - } - return [ - `File: ${c.filePath}`, - 'Source: markdown', - locationLabel, - `User comment: "${escaped}"` - ].join('\n') -} - -export function formatDiffComments(comments: readonly DiffComment[]): string { - return comments.map(formatDiffComment).join('\n\n') -} - export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string { return [ 'You are reviewing the current worktree. Address the following mobile review notes.', diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index f7ab3af1a3c..d09c0661240 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,7 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' import { WebView, type WebViewMessageEvent } from 'react-native-webview' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay, diff --git a/mobile/src/terminal/terminal-osc-link-ranges.ts b/mobile/src/terminal/terminal-osc-link-ranges.ts deleted file mode 100644 index 5f7f18f82d1..00000000000 --- a/mobile/src/terminal/terminal-osc-link-ranges.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' - -export type { TerminalOscLinkRange } - -export function isTerminalOscLinkRanges(value: unknown): value is TerminalOscLinkRange[] { - return ( - Array.isArray(value) && - value.every( - (entry) => - entry != null && - typeof entry === 'object' && - Number.isInteger((entry as TerminalOscLinkRange).row) && - Number.isInteger((entry as TerminalOscLinkRange).startCol) && - Number.isInteger((entry as TerminalOscLinkRange).endCol) && - typeof (entry as TerminalOscLinkRange).uri === 'string' - ) - ) -} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index c28fd4a5038..45b9a38c05a 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -1,6 +1,6 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' import type { StyleProp, ViewStyle } from 'react-native' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index c031363268c..35c6e991b68 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -1,5 +1,5 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' -import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges' export type TerminalWebViewCommand = | { type: 'ping'; id?: number } From 6c765d5ad3cab8827d0939ad1756a94f04b87cc3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:25:28 -0700 Subject: [PATCH 08/20] refactor(mobile): remove native chat re-export facades (#13436) --- mobile/src/session/MobileNativeChatAsk.tsx | 2 +- .../session/MobileNativeChatMessage.test.ts | 2 +- .../src/session/MobileNativeChatMessage.tsx | 27 +++++++++---------- mobile/src/session/MobileNativeChatView.tsx | 2 +- .../session/mobile-native-chat-ask.test.ts | 4 +-- mobile/src/session/mobile-native-chat-ask.ts | 2 -- .../session/mobile-native-chat-blocks.test.ts | 4 +-- .../src/session/mobile-native-chat-blocks.ts | 11 -------- .../mobile-native-chat-controller-contract.ts | 6 ++++- .../session/mobile-native-chat-diff.test.ts | 4 +-- mobile/src/session/mobile-native-chat-diff.ts | 6 ----- .../session/mobile-native-chat-merge.test.ts | 2 +- .../src/session/mobile-native-chat-merge.ts | 9 ------- .../mobile-native-chat-merger-oracle.test.ts | 2 +- .../mobile-native-chat-message-text.ts | 3 +-- .../session/mobile-native-chat-noise.test.ts | 2 +- .../src/session/mobile-native-chat-noise.ts | 1 - .../session/mobile-native-chat-render-data.ts | 4 +-- .../mobile-native-chat-stream-frame.test.ts | 2 +- .../mobile-native-chat-stream-frame.ts | 3 ++- .../mobile-native-chat-tool-summary.test.ts | 4 +-- .../mobile-native-chat-tool-summary.ts | 13 --------- ...use-mobile-native-chat-answer-send.test.ts | 2 +- .../use-mobile-native-chat-answer-send.ts | 6 ++--- ...use-mobile-native-chat-ask-dismiss.test.ts | 2 +- .../use-mobile-native-chat-ask-dismiss.ts | 2 +- .../session/use-mobile-native-chat-prompts.ts | 2 +- .../session/use-mobile-native-chat-session.ts | 2 +- 28 files changed, 44 insertions(+), 87 deletions(-) delete mode 100644 mobile/src/session/mobile-native-chat-ask.ts delete mode 100644 mobile/src/session/mobile-native-chat-blocks.ts delete mode 100644 mobile/src/session/mobile-native-chat-diff.ts delete mode 100644 mobile/src/session/mobile-native-chat-merge.ts delete mode 100644 mobile/src/session/mobile-native-chat-noise.ts delete mode 100644 mobile/src/session/mobile-native-chat-tool-summary.ts diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 68258c22820..a5ba0ef7f72 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -1,8 +1,8 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' type Props = { prompt: AskPrompt diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index bb2335c7054..10b96cc3d20 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -1,8 +1,8 @@ import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary' vi.mock('react-native', async () => { const React = await import('react') diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index d9c9f6100ca..97e92decc4d 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react' import { Image, Pressable, Text, View } from 'react-native' import * as Clipboard from 'expo-clipboard' import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native' -import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' -import { MobileMarkdown } from '../components/MobileMarkdown' -import { colors } from '../theme/mobile-theme' -import { - isImageRefBlock, - isTextBlock, - pairToolBlocks, - splitNativeChatBlocks, - type ToolPair -} from './mobile-native-chat-blocks' -import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff' -import { isRenderableImageUri } from './mobile-native-chat-image-preview' -import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' -import { nativeChatMessageText } from './mobile-native-chat-message-text' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' +import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff' +import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold' import { createToolInputDisplay, summarizeToolRun, truncateToolDetail -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' +import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileMarkdown } from '../components/MobileMarkdown' +import { colors } from '../theme/mobile-theme' +import { isRenderableImageUri } from './mobile-native-chat-image-preview' +import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { nativeChatMessageText } from './mobile-native-chat-message-text' const MAX_VISIBLE_TOOL_PAIRS = 6 const MAX_TOOL_RUN_DIFF_ROWS = 240 diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 478d36267a7..697887bfe75 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -11,6 +11,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler' import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native' +import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' @@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer' import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { MobileNativeChatMessage } from './MobileNativeChatMessage' import { MobileNativeChatAsk } from './MobileNativeChatAsk' -import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask' import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' diff --git a/mobile/src/session/mobile-native-chat-ask.test.ts b/mobile/src/session/mobile-native-chat-ask.test.ts index 6654a0e8689..47d56d68901 100644 --- a/mobile/src/session/mobile-native-chat-ask.test.ts +++ b/mobile/src/session/mobile-native-chat-ask.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildAskAnswerKeys, extractPendingAsk, formatAskAnswer, parseAskFromStatus -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage { return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' } diff --git a/mobile/src/session/mobile-native-chat-ask.ts b/mobile/src/session/mobile-native-chat-ask.ts deleted file mode 100644 index 50766b25ee4..00000000000 --- a/mobile/src/session/mobile-native-chat-ask.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift. -export * from '../../../src/shared/native-chat-ask' diff --git a/mobile/src/session/mobile-native-chat-blocks.test.ts b/mobile/src/session/mobile-native-chat-blocks.test.ts index 57351e4a20c..27b47576bfc 100644 --- a/mobile/src/session/mobile-native-chat-blocks.test.ts +++ b/mobile/src/session/mobile-native-chat-blocks.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages, pairToolBlocks, splitNativeChatBlocks -} from './mobile-native-chat-blocks' +} from '../../../src/shared/native-chat-tool-fold' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-blocks.ts b/mobile/src/session/mobile-native-chat-blocks.ts deleted file mode 100644 index 1cf293a79fd..00000000000 --- a/mobile/src/session/mobile-native-chat-blocks.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - isImageRefBlock, - isTextBlock, - NATIVE_CHAT_SOURCE_PRIORITY -} from '../../../src/shared/native-chat-types' -export { - foldToolMessages, - pairToolBlocks, - splitNativeChatBlocks, - type NativeChatToolPair as ToolPair -} from '../../../src/shared/native-chat-tool-fold' diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index a817b47b3ee..1a086783dbb 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,7 +1,11 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { + AskAnswerSelection, + AskPrompt, + parseAskFromStatus +} from '../../../src/shared/native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' diff --git a/mobile/src/session/mobile-native-chat-diff.test.ts b/mobile/src/session/mobile-native-chat-diff.test.ts index 69277dd09ae..a19bcf54b67 100644 --- a/mobile/src/session/mobile-native-chat-diff.test.ts +++ b/mobile/src/session/mobile-native-chat-diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff' +import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff' describe('diffFromToolCall', () => { it('builds del/add lines from an Edit tool call', () => { @@ -36,8 +36,6 @@ describe('diffFromText', () => { expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull() }) - // Parity guard: the mobile module re-exports the shared parser, so the -- / ++ - // content collision must stay fixed on both platforms. it('counts a removed -- comment line despite the --- prefix collision', () => { const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new') expect(lines).toEqual([ diff --git a/mobile/src/session/mobile-native-chat-diff.ts b/mobile/src/session/mobile-native-chat-diff.ts deleted file mode 100644 index 4ace06d37d0..00000000000 --- a/mobile/src/session/mobile-native-chat-diff.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - diffFromText, - diffFromToolCall, - type NativeChatDiffLine as DiffLine, - type NativeChatDiffLineKind as DiffLineKind -} from '../../../src/shared/native-chat-diff' diff --git a/mobile/src/session/mobile-native-chat-merge.test.ts b/mobile/src/session/mobile-native-chat-merge.test.ts index 61312670512..c8de9d3ff1e 100644 --- a/mobile/src/session/mobile-native-chat-merge.test.ts +++ b/mobile/src/session/mobile-native-chat-merge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { mergeNativeChatMessages } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { mergeNativeChatMessages } from './mobile-native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-merge.ts b/mobile/src/session/mobile-native-chat-merge.ts deleted file mode 100644 index 6b6ffe1781e..00000000000 --- a/mobile/src/session/mobile-native-chat-merge.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Keep the established mobile import path while executing the shared desktop/mobile merger. -export { - applyAppend, - boundNativeChatWindow, - createNativeChatMerger, - mergeNativeChatMessages, - replaceList, - type NativeChatMerger -} from '../../../src/shared/native-chat-merge' diff --git a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts index 2a034fd33f3..d21d2bc00ba 100644 --- a/mobile/src/session/mobile-native-chat-merger-oracle.test.ts +++ b/mobile/src/session/mobile-native-chat-merger-oracle.test.ts @@ -6,7 +6,7 @@ import { createNativeChatMerger, mergeNativeChatMessages, replaceList -} from './mobile-native-chat-merge' +} from '../../../src/shared/native-chat-merge' function msg(id: string, overrides: Partial = {}): NativeChatMessage { return { diff --git a/mobile/src/session/mobile-native-chat-message-text.ts b/mobile/src/session/mobile-native-chat-message-text.ts index 26aeea7680c..227488a56df 100644 --- a/mobile/src/session/mobile-native-chat-message-text.ts +++ b/mobile/src/session/mobile-native-chat-message-text.ts @@ -1,5 +1,4 @@ -import type { NativeChatBlock } from '../../../src/shared/native-chat-types' -import { isTextBlock } from './mobile-native-chat-blocks' +import { isTextBlock, type NativeChatBlock } from '../../../src/shared/native-chat-types' /** Concatenate a message's text blocks into a single copyable string. Tool * calls/results and image refs are skipped — Copy is for the agent's prose. */ diff --git a/mobile/src/session/mobile-native-chat-noise.test.ts b/mobile/src/session/mobile-native-chat-noise.test.ts index 6977b764e72..0369a609135 100644 --- a/mobile/src/session/mobile-native-chat-noise.test.ts +++ b/mobile/src/session/mobile-native-chat-noise.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise' function msg( role: NativeChatMessage['role'], diff --git a/mobile/src/session/mobile-native-chat-noise.ts b/mobile/src/session/mobile-native-chat-noise.ts deleted file mode 100644 index 1b15a7e414a..00000000000 --- a/mobile/src/session/mobile-native-chat-noise.ts +++ /dev/null @@ -1 +0,0 @@ -export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise' diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index b3d4ba1c0fd..1ab2c0b03c3 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,10 +3,10 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' +import { stripNoiseMessages } from '../../../src/shared/native-chat-noise' +import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold' import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' -import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' -import { stripNoiseMessages } from './mobile-native-chat-noise' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' /** The centered empty-state copy for a chat with no messages, mirroring the diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index b4ffe542266..1f00bc5ec15 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' function message(id: string): NativeChatMessage { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index bef3dc5ef27..a3cef3d5464 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -1,5 +1,6 @@ +import { applyAppend, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMerger } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge' export type MobileNativeChatStreamFrame = { type?: string diff --git a/mobile/src/session/mobile-native-chat-tool-summary.test.ts b/mobile/src/session/mobile-native-chat-tool-summary.test.ts index 8e65ccf1958..f7a976b92b8 100644 --- a/mobile/src/session/mobile-native-chat-tool-summary.test.ts +++ b/mobile/src/session/mobile-native-chat-tool-summary.test.ts @@ -4,7 +4,7 @@ import { describeToolInput, summarizeToolInput, toolFilePath -} from './mobile-native-chat-tool-summary' +} from '../../../src/shared/native-chat-tool-summary' describe('summarizeToolInput', () => { it('passes short strings through, collapsing whitespace', () => { @@ -59,7 +59,7 @@ describe('toolFilePath', () => { }) describe('describeToolInput', () => { - it('is re-exported and labels rows with the path or primary argument', () => { + it('labels rows with the path or primary argument', () => { expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts') expect(describeToolInput('{"cmd":"git status"}')).toBe('git status') }) diff --git a/mobile/src/session/mobile-native-chat-tool-summary.ts b/mobile/src/session/mobile-native-chat-tool-summary.ts deleted file mode 100644 index 0ee6576d767..00000000000 --- a/mobile/src/session/mobile-native-chat-tool-summary.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { - briefToolArg, - countToolCalls, - createToolInputDisplay, - describeToolInput, - formatToolInput, - isStructuredToolInput, - MAX_TOOL_DETAIL_LENGTH, - summarizeToolInput, - summarizeToolRun, - toolFilePath, - truncateToolDetail -} from '../../../src/shared/native-chat-tool-summary' diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 64fa30aecc1..82db799deed 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -1,11 +1,11 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import type { AgentType } from '../../../src/shared/native-chat-types' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' -import type { AskPrompt } from './mobile-native-chat-ask' import { isMobileNativeChatInputStale, markMobileNativeChatInputStale, diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index b893f7ae071..40925931d3c 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -1,6 +1,4 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { buildAskAnswerKeys, buildCodexAskAnswerKeys, @@ -8,7 +6,9 @@ import { hasAskAnswer, type AskAnswerSelection, type AskPrompt -} from './mobile-native-chat-ask' +} from '../../../src/shared/native-chat-ask' +import type { RpcClient } from '../transport/rpc-client' +import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping' import { openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index de9d53fb094..49d69ab4c3b 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AskPrompt } from './mobile-native-chat-ask' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' describe('useMobileNativeChatAskDismiss', () => { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index 35d6cf1c7c8..beb46b45d95 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask' +import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask' type AskDismissal = { sessionKey: string | null; askKey: string } type DetectedAsk = { sessionKey: string | null; askKey: string | null } diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 61b831dafd2..35acecbe844 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 6305d0418d9..656847079f9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -3,10 +3,10 @@ import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' -import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge' import { applyMobileNativeChatStreamFrame, type MobileNativeChatStreamFrame From ce8c3267b7bc0103f0b22089ebf4cc9f01a2ec63 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:27:01 -0700 Subject: [PATCH 09/20] feat(terminal): add anchored link action popovers (#13414) --- .../src/components/settings/BrowserPane.tsx | 16 +- .../BrowserTerminalLinkActionsSetting.tsx | 47 +++++ .../settings/browser-link-routing-copy.ts | 8 + .../settings/browser-search.test.ts | 14 +- .../src/components/settings/browser-search.ts | 43 ++++- .../TerminalLinkActionPopover.test.tsx | 170 ++++++++++++++++++ .../TerminalLinkActionPopover.tsx | 140 +++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 22 +++ .../terminal-file-link-actions.test.ts | 97 ++++++++++ .../terminal-file-link-actions.ts | 96 ++++++++++ .../terminal-handle-links.test.ts | 40 ++++- .../terminal-pane/terminal-handle-links.ts | 63 +++++-- .../terminal-http-link-activation.ts | 6 +- .../terminal-link-action-request.ts | 67 +++++++ .../terminal-link-action-routing.test.ts | 162 +++++++++++++++++ .../terminal-link-activation.test.ts | 53 ++++++ .../terminal-pane/terminal-link-activation.ts | 27 +++ .../terminal-link-handlers.test.ts | 51 ++++-- .../terminal-pane/terminal-link-handlers.ts | 46 +++-- .../terminal-link-open-hints.test.ts | 19 +- .../terminal-pane/terminal-link-open-hints.ts | 55 ++++-- .../terminal-link-pointer-gesture.test.ts | 93 ++++++++++ .../terminal-link-pointer-gesture.ts | 68 +++++++ .../terminal-link-pty-mouse-suppression.ts | 11 +- .../terminal-linkifier-click-priming.test.ts | 4 +- .../terminal-linkifier-click-priming.ts | 4 +- .../terminal-osc-link-routing.ts | 101 +++++++---- .../terminal-url-link-click.test.ts | 56 +++++- .../terminal-url-link-hit-testing.ts | 135 ++++++++++++-- .../terminal-pane/terminal-web-link-click.ts | 24 +-- .../use-terminal-pane-lifecycle.ts | 83 +++++++-- src/renderer/src/i18n/locales/en.json | 25 +++ .../src/lib/http-link-routing.test.ts | 28 +++ src/renderer/src/lib/http-link-routing.ts | 6 +- .../pane-manager/pane-dom-creation.test.ts | 23 ++- .../src/lib/pane-manager/pane-dom-creation.ts | 2 +- .../lib/pane-manager/pane-manager-types.ts | 2 +- .../src/lib/settings-navigation-types.ts | 1 + src/shared/constants.test.ts | 4 + src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 2 + 42 files changed, 1750 insertions(+), 166 deletions(-) create mode 100644 src/renderer/src/components/settings/BrowserTerminalLinkActionsSetting.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-request.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 5c8088cc9bb..1bc078cfcca 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -12,6 +12,7 @@ import { BrowserUseSetup } from './BrowserUsePane' import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting' import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting' import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting' +import { BrowserTerminalLinkActionsSetting } from './BrowserTerminalLinkActionsSetting' import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting' import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection' import { BrowserNewProfileDialog } from './BrowserNewProfileDialog' @@ -104,8 +105,11 @@ export function BrowserPane({ const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [ getBrowserPaneSearchEntries()[4] ]) - const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) - const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) + const showTerminalLinkActions = matchesSettingsSearch(searchQuery, [ + getBrowserPaneSearchEntries()[5] + ]) + const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) + const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[7]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription( @@ -254,6 +258,14 @@ export function BrowserPane({ /> ) : null} + {showTerminalLinkActions ? ( + + ) : null} + {showLocalhostLabels ? ( + isMac: boolean + updateSettings: (updates: Partial) => void +} + +export function BrowserTerminalLinkActionsSetting({ + settings, + isMac, + updateSettings +}: BrowserTerminalLinkActionsSettingProps): React.JSX.Element { + const title = translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.title', + 'Show terminal link actions' + ) + const description = getTerminalLinkActionsDescription({ isMac }) + + return ( + +
+ + updateSettings({ + terminalLinkActionPopoverEnabled: settings.terminalLinkActionPopoverEnabled === false + }) + } + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/browser-link-routing-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts index 5a4d5bac2d6..bdf005749e2 100644 --- a/src/renderer/src/components/settings/browser-link-routing-copy.ts +++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts @@ -4,6 +4,14 @@ export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean }) return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' } +export function getTerminalLinkActionsDescription(platform: { isMac: boolean }): string { + return translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.description', + 'Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click.', + { modifier: platform.isMac ? '⌘' : 'Ctrl' } + ) +} + // Why: "always" stops being true once inverting is on, so only then does the nested // row take over the chord sentence — with it off this reads exactly as it always has. // Each variant is a complete catalog entry: stitched suffixes break in locales whose diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 0df9173b28d..b9a36f148d7 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import ko from '@/i18n/locales/ko.json' import { i18n } from '@/i18n/i18n' -import { getBrowserPaneSearchEntries } from './browser-search' +import { getBrowserPaneSearchEntries, getTerminalLinkActionSearchKeywords } from './browser-search' import { getBrowserLinkRoutingDescription, getBrowserLinkRoutingShortcutLabel, @@ -47,6 +47,17 @@ describe('browser settings search copy', () => { expect(linkRoutingEntry?.description).toBe(getBrowserLinkRoutingDescription({ isMac: false })) expect(linkRoutingEntry?.keywords).toContain('ctrl') expect(linkRoutingEntry?.keywords).not.toContain('cmd') + + const terminalActionsEntry = getBrowserPaneSearchEntries({ isMac: false }).find( + (entry) => entry.title === 'Show terminal link actions' + ) + expect(terminalActionsEntry?.description).toContain('Ctrl-click') + expect(terminalActionsEntry?.description).not.toContain('Cmd/Ctrl') + expect(terminalActionsEntry?.keywords).toEqual( + getTerminalLinkActionSearchKeywords({ isMac: false }) + ) + expect(terminalActionsEntry?.keywords).toContain('browser') + expect(terminalActionsEntry?.keywords).toContain('ctrl') }) // Why: shipping the opt-in must not reword this row for anyone who never enables @@ -79,6 +90,7 @@ describe('browser link routing modifier copy', () => { 'Default Zoom', 'Link Routing', 'Hold Shift to open in Orca', + 'Show terminal link actions', 'Localhost Worktree Labels', 'Session & Cookies' ]) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 67f5506de42..ad31404e922 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -3,11 +3,12 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { getBrowserLinkRoutingDescription, + getTerminalLinkActionsDescription, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle } from './browser-link-routing-copy' -type BrowserShortcutPlatform = { +export type BrowserShortcutPlatform = { isMac: boolean } @@ -17,6 +18,38 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform { } } +export function getTerminalLinkActionSearchKeywords(platform: BrowserShortcutPlatform): string[] { + return [ + ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'), + ...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.terminal', + 'terminal' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.click', + 'click' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.actions', + 'actions' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.popover', + 'popover' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.menu', + 'menu' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.terminalLinkActions.disable', + 'disable' + ), + platform.isMac ? 'cmd' : 'ctrl' + ] +} + export function getBrowserPaneSearchEntries( platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform() ): SettingsSearchEntry[] { @@ -138,6 +171,14 @@ export function getBrowserPaneSearchEntries( platform.isMac ? 'cmd' : 'ctrl' ] }, + { + title: translate( + 'auto.components.settings.BrowserTerminalLinkActionsSetting.title', + 'Show terminal link actions' + ), + description: getTerminalLinkActionsDescription(platform), + keywords: getTerminalLinkActionSearchKeywords(platform) + }, { title: translate( 'auto.components.settings.browser.search.19ea5607cf', diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx new file mode 100644 index 00000000000..197f5da8269 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import type { TerminalLinkActionRequest } from './terminal-link-action-request' + +const mocks = vi.hoisted(() => ({ + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { + openSettingsPage: () => void + openSettingsTarget: (target: unknown) => void + }) => unknown + ) => + selector({ + openSettingsPage: mocks.openSettingsPage, + openSettingsTarget: mocks.openSettingsTarget + }) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ children }: { children: ReactNode }) => children, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ + children, + open, + onOpenChange + }: { + children: ReactNode + open: boolean + onOpenChange: (open: boolean) => void + }) => + open ? ( +
+
+ ) : null, + PopoverAnchor: () => null, + PopoverContent: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ) +})) + +import { TerminalLinkActionPopover } from './TerminalLinkActionPopover' + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('TerminalLinkActionPopover', () => { + it('shows the full destination and runs the selected action', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const focusTerminal = vi.fn() + const run = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com/full/hidden/destination?query=actual', + kind: 'url', + primary: { label: 'Open link', run }, + alternate: { external: true, label: 'System Browser', run: vi.fn() }, + focusTerminal + } + + render() + + const destination = screen.getByText(request.destination) + expect(destination.className).toContain('line-clamp-2') + expect(destination.getAttribute('title')).toBe(request.destination) + expect(screen.getByText('System Browser')).toBeTruthy() + expect(screen.getAllByText('Click')).toHaveLength(2) + const textOnlyAction = screen.getByText('Open link').closest('button') + const externalAction = screen.getByText('System Browser').closest('button') + expect(textOnlyAction?.querySelector('svg')).toBeNull() + expect(externalAction?.querySelector('svg')).toBeTruthy() + expect(externalAction?.className).toContain('has-[>svg]:px-1.5') + expect(externalAction?.className).not.toContain('has-[>svg]:px-3') + expect(destination.parentElement?.className).toContain('items-center') + expect(destination.parentElement?.className).toContain('py-0.5') + expect(destination.closest('[class*="w-max"]')).toBeTruthy() + + fireEvent.click(screen.getByText('Open link')) + expect(onClose).toHaveBeenCalledOnce() + expect(focusTerminal).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + }) + + it('identifies the dismissed request so a newer request can survive', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { label: 'Open link', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + fireEvent.click(screen.getByTestId('dismiss-popover')) + + expect(onClose).toHaveBeenCalledWith(request) + }) + + it('uses distinct icons for system and Orca browser actions', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { external: false, label: 'Orca Browser', run: vi.fn() }, + alternate: { external: true, label: 'System Browser', run: vi.fn() }, + focusTerminal: vi.fn() + } + + render() + + expect( + screen.getByText('Orca Browser').closest('button')?.querySelector('.lucide-globe') + ).toBeTruthy() + expect( + screen.getByText('System Browser').closest('button')?.querySelector('.lucide-external-link') + ).toBeTruthy() + }) + + it('opens the terminal link setting from the compact settings button', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const focusTerminal = vi.fn() + const request: TerminalLinkActionRequest = { + paneId: 1, + anchorX: 100, + anchorY: 200, + destination: 'https://example.com', + kind: 'url', + primary: { label: 'System Browser', run: vi.fn() }, + focusTerminal + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Terminal link settings' })) + + expect(onClose).toHaveBeenCalledOnce() + expect(mocks.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'browser', + repoId: null, + sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID + }) + expect(mocks.openSettingsPage).toHaveBeenCalledOnce() + expect(focusTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx new file mode 100644 index 00000000000..c313edb545a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.tsx @@ -0,0 +1,140 @@ +import { useMemo } from 'react' +import { ExternalLink, Globe, Settings } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Button } from '@/components/ui/button' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { useAppStore } from '@/store' +import type { TerminalLinkAction, TerminalLinkActionRequest } from './terminal-link-action-request' + +type TerminalLinkActionPopoverProps = { + request: TerminalLinkActionRequest | null + onClose: (dismissed?: TerminalLinkActionRequest) => void +} + +function ActionRow({ + action, + alternate, + onRun +}: { + action: TerminalLinkAction + alternate: boolean + onRun: () => void +}): React.JSX.Element { + const isMac = navigator.userAgent.includes('Mac') + const keys = alternate + ? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click'] + : [isMac ? '⌘' : 'Ctrl', 'Click'] + + return ( + + ) +} + +export function TerminalLinkActionPopover({ + request, + onClose +}: TerminalLinkActionPopoverProps): React.JSX.Element { + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) + const virtualRef = useMemo( + () => ({ + current: { + getBoundingClientRect: () => new DOMRect(request?.anchorX ?? 0, request?.anchorY ?? 0, 0, 0) + } + }), + [request?.anchorX, request?.anchorY] + ) + + const runAction = (action: TerminalLinkAction): void => { + onClose() + request?.focusTerminal() + void action.run() + } + + const settingsLabel = translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.terminalLinkSettings', + 'Terminal link settings' + ) + + const openTerminalLinkSettings = (): void => { + onClose() + openSettingsTarget({ + pane: 'browser', + repoId: null, + sectionId: BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID + }) + openSettingsPage() + } + + return ( + !open && onClose(request ?? undefined)} + > + + {request ? ( + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + onEscapeKeyDown={() => request.focusTerminal()} + > +
+ + {request.destination} + + + + + + + {settingsLabel} + + +
+ runAction(request.primary)} + /> + {request.alternate ? ( + runAction(request.alternate!)} + /> + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index e4862c5f085..d0e9dee537d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -93,6 +93,11 @@ import { import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle' +import { TerminalLinkActionPopover } from './TerminalLinkActionPopover' +import { + closeTerminalLinkActionRequest, + type TerminalLinkActionRequest +} from './terminal-link-action-request' import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu' import { detachTerminalPaneToTab, @@ -390,6 +395,14 @@ function TerminalPane( const [paneCount, setPaneCount] = useState(0) // Why: pane reorders can move panes without changing count or size, so overlay rects need an explicit layout-change render trigger. const [paneLayoutRevision, setPaneLayoutRevision] = useState(0) + const [terminalLinkActionRequest, setTerminalLinkActionRequest] = + useState(null) + const requestTerminalLinkAction = useCallback((request: TerminalLinkActionRequest) => { + setTerminalLinkActionRequest(request) + }, []) + const closeTerminalLinkActions = useCallback((dismissed?: TerminalLinkActionRequest) => { + setTerminalLinkActionRequest((current) => closeTerminalLinkActionRequest(current, dismissed)) + }, []) const [searchOpen, setSearchOpen] = useState(false) const searchOpenRef = useRef(false) searchOpenRef.current = searchOpen @@ -1382,6 +1395,7 @@ function TerminalPane( settings, settingsRef, requestOpenLinksInAppPreference, + requestTerminalLinkAction, effectiveMacOptionAsAlt, effectiveMacOptionAsAltRef: macOptionAsAltRef, initialLayoutRef, @@ -1434,6 +1448,10 @@ function TerminalPane( onExternalPaneDrop: handleExternalPaneDrop }) + useEffect(() => { + closeTerminalLinkActions() + }, [closeTerminalLinkActions, isActive, isRendererVisible, paneLayoutRevision]) + useEffect(() => { const manager = managerRef.current if (!manager || !restoredLayout.root) { @@ -3112,6 +3130,10 @@ function TerminalPane( onCopyTerminalId={() => void contextMenu.onCopyTerminalId()} onCopyPaneId={contextMenu.onCopyPaneId} /> + {/* Why: repos is a broad store slice; only subscribe while the editor is visible. */} {quickCommandEditorOpen ? ( ({ + canOpenWithSystemDefault: true, + openDetectedFilePath: vi.fn(), + worktreeRoot: false +})) + +vi.mock('./terminal-file-open-routing', () => ({ + getTerminalFileContext: () => ({}), + mapTerminalFilePath: (filePath: string) => filePath, + openDetectedFilePath: mocks.openDetectedFilePath, + shouldOpenTerminalFileWithSystemDefault: () => mocks.canOpenWithSystemDefault, + terminalLinkWslDistro: () => null +})) + +vi.mock('./terminal-worktree-path-link', () => ({ + resolveKnownWorktreeRootPathLink: () => (mocks.worktreeRoot ? { id: 'wt-2' } : null) +})) + +import { handleTerminalFileLink } from './terminal-file-link-actions' + +const deps = { worktreeId: 'wt-1', worktreePath: '/repo' } + +function plainEvent(): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 12, + clientY: 24, + preventDefault: vi.fn() + } as unknown as MouseEvent +} + +function context(request: ReturnType): TerminalLinkActionContext { + return { + paneId: 3, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request: request as TerminalLinkActionContext['request'], + focusTerminal: vi.fn() + } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + mocks.canOpenWithSystemDefault = true + mocks.worktreeRoot = false + vi.clearAllMocks() +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('terminal file link actions', () => { + it('offers Orca and system-default actions for a local file', () => { + const request = vi.fn() + expect( + handleTerminalFileLink('/repo/src/main.ts', 12, 4, plainEvent(), deps, context(request)) + ).toBe(true) + + const actionRequest = request.mock.calls[0][0] + expect(actionRequest).toEqual( + expect.objectContaining({ + destination: '/repo/src/main.ts', + kind: 'file', + primary: expect.objectContaining({ label: 'Open file' }), + alternate: expect.objectContaining({ label: 'Open with default app' }) + }) + ) + actionRequest.primary.run() + actionRequest.alternate.run() + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(1, '/repo/src/main.ts', 12, 4, deps) + expect(mocks.openDetectedFilePath).toHaveBeenNthCalledWith(2, '/repo/src/main.ts', 12, 4, { + ...deps, + openWithSystemDefault: true + }) + }) + + it('labels workspace switching and omits an impossible remote alternate', () => { + mocks.worktreeRoot = true + mocks.canOpenWithSystemDefault = false + const request = vi.fn() + handleTerminalFileLink('/repo', null, null, plainEvent(), deps, context(request)) + + const actionRequest = request.mock.calls[0][0] + expect(actionRequest).toEqual( + expect.objectContaining({ + kind: 'workspace', + primary: expect.objectContaining({ label: 'Switch workspace' }) + }) + ) + expect(actionRequest).not.toHaveProperty('alternate') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts new file mode 100644 index 00000000000..bd3247ee9d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -0,0 +1,96 @@ +import { + getTerminalFileContext, + mapTerminalFilePath, + openDetectedFilePath, + shouldOpenTerminalFileWithSystemDefault, + terminalLinkWslDistro +} from './terminal-file-open-routing' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' +import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' +import { translate } from '@/i18n/i18n' + +export type TerminalFileLinkActionDeps = { + worktreeId: string + worktreePath: string + runtimeEnvironmentId?: string | null + wslDistro?: string | null +} + +export function handleTerminalFileLink( + filePath: string, + line: number | null, + column: number | null, + event: MouseEvent | undefined, + deps: TerminalFileLinkActionDeps, + actionContext?: TerminalLinkActionContext | null, + actionDestination?: string +): boolean { + if (isTerminalLinkDirectActivation(event)) { + event?.preventDefault?.() + openDetectedFilePath(filePath, line, column, { + ...deps, + openWithSystemDefault: Boolean(event?.shiftKey) + }) + return true + } + + const mappedPath = mapTerminalFilePath( + filePath, + deps.worktreePath, + terminalLinkWslDistro(deps.wslDistro, deps.runtimeEnvironmentId) + ) + const fileContext = getTerminalFileContext( + deps.worktreeId, + deps.worktreePath, + deps.runtimeEnvironmentId + ) + const worktreeRoot = resolveKnownWorktreeRootPathLink(mappedPath) + const canOpenWithSystemDefault = shouldOpenTerminalFileWithSystemDefault(fileContext, mappedPath) + const isMac = navigator.userAgent.includes('Mac') + + return requestTerminalLinkAction(event, actionContext, { + destination: actionDestination ?? mappedPath, + kind: worktreeRoot ? 'workspace' : 'file', + primary: { + label: worktreeRoot + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.switchWorkspace', + 'Switch workspace' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openFile', + 'Open file' + ), + run: () => openDetectedFilePath(filePath, line, column, deps) + }, + ...(canOpenWithSystemDefault + ? { + alternate: { + label: worktreeRoot + ? isMac + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openInFinder', + 'Open in Finder' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openFolder', + 'Open folder' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openWithDefaultApp', + 'Open with default app' + ), + run: () => + openDetectedFilePath(filePath, line, column, { + ...deps, + openWithSystemDefault: true + }) + } + } + : {}) + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 2fb6064f814..3c042a12d24 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -11,6 +11,7 @@ import { findTerminalHandleTarget, focusRendererTerminalHandle } from './terminal-handle-links' +import type { TerminalLinkActionContext } from './terminal-link-action-request' const mocks = vi.hoisted(() => ({ activateTabAndFocusPane: vi.fn(), @@ -84,7 +85,8 @@ function setPlatform(userAgent: string): void { async function collectLinks( rows: TestBufferLine[], bufferLineNumber = 1, - runtimeEnvironmentId: string | null = null + runtimeEnvironmentId: string | null = null, + linkActionContext?: TerminalLinkActionContext ): Promise { const terminal = { buffer: { @@ -97,7 +99,8 @@ async function collectLinks( const provider = createTerminalHandleLinkProvider({ getTerminal: () => terminal as never, getRuntimeEnvironmentId: () => runtimeEnvironmentId, - linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement + linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement, + getLinkActionContext: () => linkActionContext ?? null }) return await new Promise((resolve) => { provider.provideLinks(bufferLineNumber, (links) => resolve(links ?? [])) @@ -303,6 +306,39 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('defers a plain terminal-handle click to the action popover', async () => { + const request = vi.fn() + const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, null, { + paneId: 4, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + }) + + links[0].activate( + { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 20, + clientY: 30, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + destination: 'term_worker', + kind: 'terminal', + primary: expect.objectContaining({ label: 'Switch terminal' }) + }) + ) + expect(window.api.runtime.call).not.toHaveBeenCalled() + }) + it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => { markRuntimeEnvironmentCompatible('env-1') mocks.storeState.tabsByWorktree = { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index baf2adbde90..b9b949fc83f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -11,6 +11,15 @@ import { focusRuntimeOrchestrationTask, ORCHESTRATION_TASK_PREFIX } from './terminal-orchestration-task-links' +import { + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation +} from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' +import { translate } from '@/i18n/i18n' export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links' export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links' @@ -36,6 +45,7 @@ type TerminalHandleLinkProviderDeps = { getTerminal: () => Terminal | null getRuntimeEnvironmentId: () => string | null linkTooltip: HTMLElement + getLinkActionContext?: () => TerminalLinkActionContext | null } const TERMINAL_HANDLE_PREFIX = 'term_' @@ -188,15 +198,44 @@ export function createTerminalHandleLinkProvider( range, text: parsed.text, activate: (event) => { - if (!isTerminalHandleLinkActivation(event)) { + const directActivation = isTerminalLinkDirectActivation(event) + const actionActivation = isTerminalLinkActionActivation(event) + if (!directActivation && !actionActivation) { return } - event?.preventDefault() - void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) - terminal.clearSelection() + let handled = false + if (directActivation) { + event?.preventDefault() + void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) + handled = true + } else { + handled = requestTerminalLinkAction(event, deps.getLinkActionContext?.(), { + destination: parsed.text, + kind: parsed.kind, + primary: { + label: + parsed.kind === 'terminal' + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.switchTerminal', + 'Switch terminal' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openTaskTerminal', + 'Open task terminal' + ), + run: () => activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) + } + }) + } + if (handled) { + terminal.clearSelection() + } }, hover: () => { - deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})` + const showActions = deps.getLinkActionContext + ? deps.getLinkActionContext() !== null + : true + deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint(showActions)})` deps.linkTooltip.style.display = '' }, leave: () => { @@ -252,17 +291,11 @@ function ptyIdMatchesTerminalHandle( return ptyEnvironmentId === targetEnvironmentId } -function getTerminalHandleFocusHint(): string { +function getTerminalHandleFocusHint(showActions: boolean): string { + const prefix = showActions ? 'Click for actions or ' : '' return navigator.userAgent.includes('Mac') - ? '⌘+click to switch terminal' - : 'Ctrl+click to switch terminal' -} - -function isTerminalHandleLinkActivation( - event: Pick | undefined -): boolean { - const isMac = navigator.userAgent.includes('Mac') - return isMac ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) + ? `${prefix}⌘+click to switch terminal` + : `${prefix}Ctrl+click to switch terminal` } async function focusRuntimeTerminalHandle( diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts index c9e941c874d..893ec638cbf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -1,7 +1,5 @@ -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean { - // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY, - // so plain HTTP link handling must leave those gestures to the child TUI. - return Boolean(event && !event.altKey && isTerminalLinkActivation(event)) + return isTerminalLinkDirectActivation(event) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts new file mode 100644 index 00000000000..653ab34597a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts @@ -0,0 +1,67 @@ +import type { TerminalLinkPointerGesture } from './terminal-link-pointer-gesture' +import { isTerminalLinkActionActivation } from './terminal-link-activation' + +export type TerminalLinkActionKind = 'url' | 'file' | 'workspace' | 'terminal' | 'task' + +export type TerminalLinkAction = { + external?: boolean + label: string + run: () => void | Promise +} + +export type TerminalLinkActionRequest = { + paneId: number + anchorX: number + anchorY: number + destination: string + kind: TerminalLinkActionKind + primary: TerminalLinkAction + alternate?: TerminalLinkAction + focusTerminal: () => void +} + +export type TerminalLinkActionRequester = (request: TerminalLinkActionRequest) => void + +export type TerminalLinkActionContext = { + paneId: number + pointerGesture: TerminalLinkPointerGesture + request: TerminalLinkActionRequester + focusTerminal: () => void +} + +export function closeTerminalLinkActionRequest( + current: TerminalLinkActionRequest | null, + dismissed?: TerminalLinkActionRequest +): TerminalLinkActionRequest | null { + return dismissed && current !== dismissed ? current : null +} + +type LinkActionDetails = Pick< + TerminalLinkActionRequest, + 'destination' | 'kind' | 'primary' | 'alternate' +> + +export function requestTerminalLinkAction( + event: MouseEvent | undefined, + context: TerminalLinkActionContext | null | undefined, + details: LinkActionDetails +): boolean { + if ( + !event || + !context || + !isTerminalLinkActionActivation(event) || + !context.pointerGesture.canRequestAction(event) + ) { + return false + } + + event.preventDefault() + context.request({ + ...details, + paneId: context.paneId, + anchorX: event.clientX, + anchorY: event.clientY, + focusTerminal: context.focusTerminal + }) + return true +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts new file mode 100644 index 00000000000..00cdcd3c687 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { + closeTerminalLinkActionRequest, + type TerminalLinkActionContext, + type TerminalLinkActionRequest +} from './terminal-link-action-request' +import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalHttpLink } from './terminal-url-link-hit-testing' + +const openUrl = vi.fn() +const createBrowserTab = vi.fn() +const setActiveWorktree = vi.fn() + +function plainEvent(): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + clientX: 40, + clientY: 60, + preventDefault: vi.fn() + } as unknown as MouseEvent +} + +function actionContext(request = vi.fn()): TerminalLinkActionContext { + return { + paneId: 7, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree, + createBrowserTab + })) +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal link action routing', () => { + it('keeps a replacement request when the previous popover dismisses', () => { + const first = { destination: 'https://first.example' } as TerminalLinkActionRequest + const second = { destination: 'https://second.example' } as TerminalLinkActionRequest + + expect(closeTerminalLinkActionRequest(second, first)).toBe(second) + expect(closeTerminalLinkActionRequest(first, first)).toBeNull() + }) + + it('offers system browser first and Orca second when system browser is the default', () => { + const request = vi.fn() + const event = plainEvent() + + expect( + handleTerminalHttpLink('https://example.com/path', event, { + worktreeId: 'wt-1', + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + ).toBe(true) + expect(openUrl).not.toHaveBeenCalled() + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + paneId: 7, + anchorX: 40, + anchorY: 60, + destination: 'https://example.com/path', + kind: 'url' + }) + ) + expect(request.mock.calls[0][0].primary.label).toBe('System Browser') + expect(request.mock.calls[0][0].primary.external).toBe(true) + expect(request.mock.calls[0][0].alternate.label).toBe('Orca Browser') + expect(request.mock.calls[0][0].alternate.external).toBe(false) + + request.mock.calls[0][0].primary.run() + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + + request.mock.calls[0][0].alternate.run() + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + }) + + it('offers Orca first and system browser second when Orca is the default', () => { + const request = vi.fn() + + handleTerminalHttpLink('https://example.com/path', plainEvent(), { + worktreeId: 'wt-1', + linkActionContext: actionContext(request), + actionDestinations: { primary: 'orca', alternate: 'system' } + }) + + expect(request.mock.calls[0][0].primary.label).toBe('Orca Browser') + expect(request.mock.calls[0][0].primary.external).toBe(false) + expect(request.mock.calls[0][0].alternate.label).toBe('System Browser') + expect(request.mock.calls[0][0].alternate.external).toBe(true) + + request.mock.calls[0][0].primary.run() + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + + request.mock.calls[0][0].alternate.run() + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + }) + + it('offers only the system browser for a remote link', () => { + const request = vi.fn() + + handleTerminalHttpLink('https://example.com/path', plainEvent(), { + worktreeId: 'wt-1', + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }, + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system' } + }) + + expect(request.mock.calls[0][0].primary.label).toBe('System Browser') + expect(request.mock.calls[0][0].primary.external).toBe(true) + expect(request.mock.calls[0][0].alternate).toBeUndefined() + }) + + it('uses Shift+modifier for the alternate local destination', () => { + const event = { ...plainEvent(), metaKey: true, shiftKey: true } + + handleTerminalHttpLink('https://example.com/path', event, { + worktreeId: 'wt-1', + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + + expect(createBrowserTab).toHaveBeenCalledWith('wt-1', 'https://example.com/path', { + activate: true + }) + expect(openUrl).not.toHaveBeenCalled() + }) + + it('exposes the actual hidden OSC 8 destination', () => { + const request = vi.fn() + const destination = 'https://example.com' + + expect( + handleOscLink(destination, plainEvent(), { + worktreeId: 'wt-1', + worktreePath: '/repo', + startupCwd: '/repo', + linkActionContext: actionContext(request) + }) + ).toBe(true) + expect(request.mock.calls[0][0].destination).toBe(destination) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts new file mode 100644 index 00000000000..ce88c8feeaf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation, + isTerminalOwnedLinkGesture +} from './terminal-link-activation' + +function event(overrides: Partial = {}): MouseEvent { + return { + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides + } as MouseEvent +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('terminal link activation', () => { + it('uses plain click for actions and Command-click for direct open on macOS', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalLinkActionActivation(event())).toBe(true) + expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(true) + expect(isTerminalOwnedLinkGesture(event())).toBe(true) + }) + + it('reserves macOS Control-click for the context menu', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalLinkActionActivation(event({ ctrlKey: true }))).toBe(false) + expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(false) + }) + + it('uses Ctrl-click for direct open on Windows and Linux', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + + expect(isTerminalLinkActionActivation(event())).toBe(true) + expect(isTerminalLinkDirectActivation(event({ ctrlKey: true }))).toBe(true) + expect(isTerminalLinkDirectActivation(event({ metaKey: true }))).toBe(false) + }) + + it('leaves Shift-only, Alt, and non-primary gestures unowned', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect(isTerminalOwnedLinkGesture(event({ shiftKey: true }))).toBe(false) + expect(isTerminalOwnedLinkGesture(event({ altKey: true }))).toBe(false) + expect(isTerminalOwnedLinkGesture(event({ button: 2 }))).toBe(false) + expect(isTerminalLinkDirectActivation(event({ metaKey: true, shiftKey: true }))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts index 41def2095c1..aae05daa875 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-activation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-activation.ts @@ -5,3 +5,30 @@ export function isTerminalLinkActivation( ): boolean { return isMacPlatform() ? Boolean(event?.metaKey) : Boolean(event?.ctrlKey) } + +type TerminalLinkMouseEvent = Pick & + Partial> + +export function isTerminalLinkDirectActivation(event: TerminalLinkMouseEvent | undefined): boolean { + return Boolean( + event && + (event.button === undefined || event.button === 0) && + !event.altKey && + isTerminalLinkActivation(event) + ) +} + +export function isTerminalLinkActionActivation(event: TerminalLinkMouseEvent | undefined): boolean { + return Boolean( + event && + (event.button === undefined || event.button === 0) && + !event.altKey && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey + ) +} + +export function isTerminalOwnedLinkGesture(event: TerminalLinkMouseEvent | undefined): boolean { + return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 3bb650b0e3a..74574dea02a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -505,17 +505,36 @@ describe('handleOscLink', () => { it('advertises the system default open behavior in hover hints', () => { setPlatform('Macintosh') - expect(getTerminalFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default app') - expect(getTerminalHtmlFileOpenHint()).toBe('⌘+click to open or ⇧⌘+click for default browser') - expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') - - setPlatform('Windows') - expect(getTerminalFileOpenHint()).toBe('Ctrl+click to open or Shift+Ctrl+click for default app') + expect(getTerminalFileOpenHint()).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click for default app' + ) expect(getTerminalHtmlFileOpenHint()).toBe( - 'Ctrl+click to open or Shift+Ctrl+click for default browser' + 'Click for actions, ⌘+click to open, or ⇧⌘+click for default browser' ) expect(getTerminalUrlOpenHint()).toBe( - 'Ctrl+click to open or Shift+Ctrl+click for system browser' + 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' + ) + + setPlatform('Windows') + expect(getTerminalFileOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default app' + ) + expect(getTerminalHtmlFileOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for default browser' + ) + expect(getTerminalUrlOpenHint()).toBe( + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click for system browser' + ) + }) + + it('omits plain-click actions from hover hints when the popover is disabled', () => { + setPlatform('Macintosh') + expect(getTerminalFileOpenHint(false)).toBe('⌘+click to open, or ⇧⌘+click for default app') + expect(getTerminalHtmlFileOpenHint(false)).toBe( + '⌘+click to open, or ⇧⌘+click for default browser' + ) + expect(getTerminalUrlOpenHint({ showActions: false })).toBe( + '⌘+click to open, or ⇧⌘+click for system browser' ) }) @@ -1383,7 +1402,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo/CLAUDE.md (⌘+click to open or ⇧⌘+click for default app)' + '/repo/CLAUDE.md (Click for actions, ⌘+click to open, or ⇧⌘+click for default app)' ) }) @@ -1426,7 +1445,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1448,7 +1467,7 @@ describe('createFilePathLinkProvider range bounds', () => { expect(window.api.shell.pathExists).not.toHaveBeenCalled() expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1481,7 +1500,7 @@ describe('createFilePathLinkProvider range bounds', () => { links[0]!.hover?.({} as MouseEvent, links[0]!.text) expect(linkTooltip.textContent).toBe( - '/repo (⌘+click to switch workspace or ⇧⌘+click to open in Finder)' + '/repo (Click for actions, ⌘+click to switch workspace, or ⇧⌘+click to open in Finder)' ) }) @@ -1499,7 +1518,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect(links[0]).toBeDefined() links[0]!.hover?.({} as MouseEvent, links[0]!.text) - expect(linkTooltip.textContent).toBe('/repo (Ctrl+click to switch workspace)') + expect(linkTooltip.textContent).toBe( + '/repo (Click for actions or Ctrl+click to switch workspace)' + ) }) it('shows the Orca hint for SSH file link hover', async () => { @@ -1513,7 +1534,9 @@ describe('createFilePathLinkProvider range bounds', () => { expect(links[0]).toBeDefined() links[0]!.hover?.({} as MouseEvent, links[0]!.text) - expect(linkTooltip.textContent).toBe('/repo/CLAUDE.md (⌘+click to open in Orca)') + expect(linkTooltip.textContent).toBe( + '/repo/CLAUDE.md (Click for actions or ⌘+click to open in Orca)' + ) }) it('bounds the terminal path-exists cache while preserving recent probes', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 2f4b9155a4e..b3890bb18b5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -15,7 +15,6 @@ import { getTerminalFileContext, isHtmlFilePath, mapTerminalFilePath, - openDetectedFilePath, shouldOpenTerminalFileWithSystemDefault, terminalLinkWslDistro } from './terminal-file-open-routing' @@ -38,8 +37,10 @@ import { getTerminalUrlOpenHint } from './terminal-link-open-hints' import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' +import type { TerminalLinkActionContext } from './terminal-link-action-request' +import { handleTerminalFileLink } from './terminal-file-link-actions' export { openDetectedFilePath } from './terminal-file-open-routing' export { mapTerminalFilePath } from './terminal-file-open-routing' @@ -59,6 +60,7 @@ export type LinkHandlerDeps = { terminalHomePath?: string | null wslDistro?: string | null getRuntimeEnvironmentIdForPane?: (paneId: number) => string | null + getLinkActionContext?: (paneId: number) => TerminalLinkActionContext | null } type ProvidedFileLink = { @@ -186,16 +188,23 @@ export function createFilePathLinkProvider( range, text: parsed.displayText, activate: (event) => { - if (!isTerminalLinkActivation(event)) { - return + if ( + handleTerminalFileLink( + mappedPath, + resolved.line, + resolved.column, + event, + { + worktreeId, + worktreePath, + runtimeEnvironmentId, + wslDistro: deps.wslDistro + }, + deps.getLinkActionContext?.(paneId) + ) + ) { + pane.terminal.clearSelection?.() } - openDetectedFilePath(mappedPath, resolved.line, resolved.column, { - worktreeId, - worktreePath, - runtimeEnvironmentId, - wslDistro: deps.wslDistro, - openWithSystemDefault: Boolean(event.shiftKey) - }) }, hover: () => { // Why: only local paths can offer the Shift+modifier system @@ -204,13 +213,18 @@ export function createFilePathLinkProvider( fileContext, mappedPath ) + const showActions = deps.getLinkActionContext + ? deps.getLinkActionContext(paneId) !== null + : true const hint = worktreeRootLink - ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault) + ? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions) : canOpenWithSystemDefault ? isHtmlFilePath(mappedPath) - ? getTerminalHtmlFileOpenHint() - : openLinkHint - : getTerminalOrcaFileOpenHint() + ? getTerminalHtmlFileOpenHint(showActions) + : showActions + ? openLinkHint + : getTerminalFileOpenHint(false) + : getTerminalOrcaFileOpenHint(showActions) linkTooltip.textContent = `${mappedPath} (${hint})` linkTooltip.style.display = '' }, @@ -262,7 +276,7 @@ export function installFilePathLinkClickFallback( ): IDisposable { const mouseUpListenerOptions = { capture: true } const handleMouseUp = (event: MouseEvent): void => { - if (event.button !== 0 || !isTerminalLinkActivation(event)) { + if (!isTerminalLinkDirectActivation(event)) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts index 890a8f3f4c3..801eec81c98 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -12,7 +12,9 @@ afterEach(() => { describe('getTerminalUrlOpenHint', () => { it('keeps the system-browser wording by default', () => { stubPlatform(true) - expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') + expect(getTerminalUrlOpenHint()).toBe( + 'Click for actions, ⌘+click to open, or ⇧⌘+click for system browser' + ) }) it('keeps the system-browser wording when inverting is off', () => { @@ -34,16 +36,27 @@ describe('getTerminalUrlOpenHint', () => { it('names Orca when inverting and links open externally', () => { stubPlatform(true) expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( - '⌘+click to open or ⇧⌘+click to open in Orca' + 'Click for actions, ⌘+click to open, or ⇧⌘+click to open in Orca' ) }) it('uses the Ctrl chord off macOS', () => { stubPlatform(false) expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( - 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + 'Click for actions, Ctrl+click to open, or Shift+Ctrl+click to open in Orca' ) }) + + it('omits the action-menu gesture when terminal link actions are disabled', () => { + stubPlatform(false) + expect( + getTerminalUrlOpenHint({ + openLinksInApp: false, + modifierInverts: true, + showActions: false + }) + ).toBe('Ctrl+click to open, or Shift+Ctrl+click to open in Orca') + }) }) describe('terminalUrlOpenHintOptionsFor', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index a069ad03f44..e1a5f4ff81f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -4,27 +4,36 @@ export function isMacPlatform(): boolean { return navigator.userAgent.includes('Mac') } -export function getTerminalFileOpenHint(): string { - return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for default app' - : 'Ctrl+click to open or Shift+Ctrl+click for default app' +function terminalLinkActionHintPrefix(showActions: boolean): string { + return showActions ? 'Click for actions, ' : '' } -export function getTerminalOrcaFileOpenHint(): string { - return isMacPlatform() ? '⌘+click to open in Orca' : 'Ctrl+click to open in Orca' +export function getTerminalFileOpenHint(showActions = true): string { + const prefix = terminalLinkActionHintPrefix(showActions) + return isMacPlatform() + ? `${prefix}⌘+click to open, or ⇧⌘+click for default app` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default app` } -// Why: detected local .html/.htm file paths keep the same modifier gate as -// other file-path links, with Shift+modifier as the system-browser escape hatch. -export function getTerminalHtmlFileOpenHint(): string { +export function getTerminalOrcaFileOpenHint(showActions = true): string { + const prefix = showActions ? 'Click for actions or ' : '' return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for default browser' - : 'Ctrl+click to open or Shift+Ctrl+click for default browser' + ? `${prefix}⌘+click to open in Orca` + : `${prefix}Ctrl+click to open in Orca` +} + +// Why: local HTML paths keep Shift+modifier as the system-browser shortcut. +export function getTerminalHtmlFileOpenHint(showActions = true): string { + const prefix = terminalLinkActionHintPrefix(showActions) + return isMacPlatform() + ? `${prefix}⌘+click to open, or ⇧⌘+click for default browser` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for default browser` } export type TerminalUrlOpenHintOptions = { openLinksInApp?: boolean modifierInverts?: boolean + showActions?: boolean } // Why: openHttpLink only routes to Orca when the source is local, so a remote pane @@ -55,14 +64,15 @@ export function terminalUrlOpenHintOptionsFor( // it means "the other one" — so the hint has to name the actual destination. export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string { const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true + const prefix = terminalLinkActionHintPrefix(options.showActions !== false) if (invertsToOrca) { return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click to open in Orca' - : 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + ? `${prefix}⌘+click to open, or ⇧⌘+click to open in Orca` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click to open in Orca` } return isMacPlatform() - ? '⌘+click to open or ⇧⌘+click for system browser' - : 'Ctrl+click to open or Shift+Ctrl+click for system browser' + ? `${prefix}⌘+click to open, or ⇧⌘+click for system browser` + : `${prefix}Ctrl+click to open, or Shift+Ctrl+click for system browser` } export function getTerminalUrlSystemBrowserHint(): string { @@ -75,12 +85,19 @@ export function getTerminalUrlOrcaBrowserHint(): string { return isMacPlatform() ? '⇧⌘+click to open in Orca' : 'Shift+Ctrl+click to open in Orca' } -export function getTerminalWorktreePathOpenHint(canOpenWithSystemDefault: boolean): string { +export function getTerminalWorktreePathOpenHint( + canOpenWithSystemDefault: boolean, + showActions = true +): string { + const prefix = terminalLinkActionHintPrefix(showActions) if (!canOpenWithSystemDefault) { - return isMacPlatform() ? '⌘+click to switch workspace' : 'Ctrl+click to switch workspace' + const directPrefix = showActions ? 'Click for actions or ' : '' + return isMacPlatform() + ? `${directPrefix}⌘+click to switch workspace` + : `${directPrefix}Ctrl+click to switch workspace` } return isMacPlatform() - ? '⌘+click to switch workspace or ⇧⌘+click to open in Finder' - : 'Ctrl+click to switch workspace or Shift+Ctrl+click to open folder' + ? `${prefix}⌘+click to switch workspace, or ⇧⌘+click to open in Finder` + : `${prefix}Ctrl+click to switch workspace, or Shift+Ctrl+click to open folder` } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts new file mode 100644 index 00000000000..a98486fc2ad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import type { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture' + +const activeGestures = new Set>() + +function createGesture(): { + gesture: ReturnType + element: HTMLDivElement + setSelection: (selected: boolean) => void +} { + let selected = false + const element = document.createElement('div') + document.body.appendChild(element) + const terminal = { + element, + hasSelection: () => selected + } as unknown as Terminal + const gesture = installTerminalLinkPointerGesture(terminal) + activeGestures.add(gesture) + return { + gesture, + element, + setSelection: (next) => { + selected = next + } + } +} + +function mouse(type: string, x: number, y: number): MouseEvent { + return new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y }) +} + +afterEach(() => { + for (const gesture of activeGestures) { + gesture.dispose() + } + activeGestures.clear() + document.body.replaceChildren() + vi.unstubAllGlobals() +}) + +describe('terminal link pointer gesture', () => { + it('allows a stationary plain click', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture } = createGesture() + const down = mouse('mousedown', 20, 30) + element.dispatchEvent(down) + + expect(gesture.canRequestAction(down)).toBe(true) + }) + + it('rejects a drag and a click that began with a selection', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const dragged = createGesture() + const dragDown = mouse('mousedown', 10, 10) + dragged.element.dispatchEvent(dragDown) + document.dispatchEvent(mouse('mousemove', 20, 10)) + expect(dragged.gesture.canRequestAction(dragDown)).toBe(false) + + const selected = createGesture() + selected.setSelection(true) + const selectionDown = mouse('mousedown', 10, 10) + selected.element.dispatchEvent(selectionDown) + selected.setSelection(false) + expect(selected.gesture.canRequestAction(selectionDown)).toBe(false) + }) + + it('rejects a selection created during the gesture and clears on blur', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture, setSelection } = createGesture() + const down = mouse('mousedown', 10, 10) + element.dispatchEvent(down) + setSelection(true) + expect(gesture.canRequestAction(down)).toBe(false) + + setSelection(false) + window.dispatchEvent(new Event('blur')) + expect(gesture.canRequestAction(down)).toBe(false) + }) + + it('removes its listeners on dispose', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { element, gesture } = createGesture() + gesture.dispose() + const down = mouse('mousedown', 10, 10) + + element.dispatchEvent(down) + + expect(gesture.canRequestAction(down)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts new file mode 100644 index 00000000000..ff3ac6cf945 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pointer-gesture.ts @@ -0,0 +1,68 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' + +const DRAG_THRESHOLD_PX = 4 +const CAPTURE_LISTENER_OPTIONS = { capture: true } as const + +export type TerminalLinkPointerGesture = IDisposable & { + canRequestAction: (event: MouseEvent) => boolean +} + +type PendingGesture = { + clientX: number + clientY: number + hadSelection: boolean + moved: boolean +} + +export function installTerminalLinkPointerGesture(terminal: Terminal): TerminalLinkPointerGesture { + const terminalElement = terminal.element + const ownerDocument = terminalElement?.ownerDocument + const ownerWindow = ownerDocument?.defaultView + let pending: PendingGesture | null = null + + const clear = (): void => { + pending = null + } + const handleMouseDown = (event: MouseEvent): void => { + if (!isTerminalOwnedLinkGesture(event)) { + clear() + return + } + pending = { + clientX: event.clientX, + clientY: event.clientY, + hadSelection: terminal.hasSelection(), + moved: false + } + } + const handleMouseMove = (event: MouseEvent): void => { + if ( + pending && + Math.hypot(event.clientX - pending.clientX, event.clientY - pending.clientY) > + DRAG_THRESHOLD_PX + ) { + pending.moved = true + } + } + const handleMouseUp = (): void => { + queueMicrotask(clear) + } + + terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.addEventListener('mousemove', handleMouseMove) + ownerDocument?.addEventListener('mouseup', handleMouseUp) + ownerWindow?.addEventListener('blur', clear) + + return { + canRequestAction: () => + Boolean(pending && !pending.moved && !pending.hadSelection && !terminal.hasSelection()), + dispose: () => { + clear() + terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.removeEventListener('mousemove', handleMouseMove) + ownerDocument?.removeEventListener('mouseup', handleMouseUp) + ownerWindow?.removeEventListener('blur', clear) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts index e595974bc18..a325056759b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -1,5 +1,5 @@ import type { IDisposable, Terminal } from '@xterm/xterm' -import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { isTerminalLinkDirectActivation } from './terminal-link-activation' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const @@ -31,17 +31,12 @@ export function installTerminalLinkPtyMouseSuppression( queueMicrotask(restore) } const handleMouseDown = (event: MouseEvent): void => { - if ( - event.button !== 0 || - !isTerminalHttpLinkActivation(event) || - !shouldSuppressMouseEvent(event) - ) { + if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) { return } restore() previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt) - // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware - // TUI, letting the terminal and the child process both open the URL. + // Why: an Orca-owned link gesture must not also reach a mouse-aware child TUI. terminal.options.mouseEventsRequireAlt = true ownerDocument?.addEventListener('mouseup', queueRestore) ownerWindow?.addEventListener('blur', restore) diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts index aa58f773680..4416e9ae47e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts @@ -124,7 +124,7 @@ describe('installTerminalLinkifierClickPriming', () => { expect(linkifier._activeLine).toBe(5) }) - it('ignores plain clicks and degrades safely when xterm internals are unavailable', () => { + it('primes plain action clicks and degrades safely when xterm internals are unavailable', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) const handleMouseMove = vi.fn() const present = createTerminal({ _handleMouseMove: handleMouseMove }) @@ -136,7 +136,7 @@ describe('installTerminalLinkifierClickPriming', () => { expect(() => getMouseDownHandler(absent.registrations)(modifierMouseDown({ metaKey: true })) ).not.toThrow() - expect(handleMouseMove).not.toHaveBeenCalled() + expect(handleMouseMove).toHaveBeenCalledOnce() }) it('removes its capture listener on dispose', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts index 7965fa488d4..ece9806e827 100644 --- a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts @@ -1,5 +1,5 @@ import type { IDisposable, Terminal } from '@xterm/xterm' -import { isTerminalLinkActivation } from './terminal-link-activation' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const @@ -39,7 +39,7 @@ function primeTerminalLinkifier(terminal: Terminal, event: MouseEvent): void { export function installTerminalLinkifierClickPriming(terminal: Terminal): IDisposable { const terminalElement = terminal.element const handleMouseDown = (event: MouseEvent): void => { - if (event.button !== 0 || !isTerminalLinkActivation(event)) { + if (!isTerminalOwnedLinkGesture(event)) { return } // Why: xterm snapshots its current link on mousedown but otherwise resolves diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 1f51187fd21..b041cb65f0d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -2,16 +2,32 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { LinkHandlerDeps } from './terminal-link-handlers' import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target' -import { openDetectedFilePath } from './terminal-file-open-routing' -import { isTerminalLinkActivation } from './terminal-link-activation' import { - openTerminalHttpLink, + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation +} from './terminal-link-activation' +import { + handleTerminalHttpLink, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import type { TerminalLinkActionContext } from './terminal-link-action-request' +import { handleTerminalFileLink } from './terminal-file-link-actions' type TerminalLinkEvent = Pick & - Partial> + Partial< + Pick< + MouseEvent, + | 'altKey' + | 'button' + | 'clientX' + | 'clientY' + | 'shiftKey' + | 'preventDefault' + | 'stopPropagation' + > + > function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boolean { if (!event) { @@ -22,7 +38,7 @@ function isDesktopOscLinkActivation(event: TerminalLinkEvent | undefined): boole } // Why: desktop xterm links must not open while the user is just placing the // cursor or selecting text. Mobile URL taps use a separate WebView path. - return isTerminalLinkActivation(event) + return isTerminalLinkDirectActivation(event) || isTerminalLinkActionActivation(event) } export function handleOscLink( @@ -37,20 +53,20 @@ export function handleOscLink( > & { sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations } ): boolean { if (!isDesktopOscLinkActivation(event)) { return false } - // Why: xterm renders OSC 8 links as clickable anchors. Orca must suppress - // default anchor navigation so link-routing settings can choose the target. - // Note: we intentionally do NOT stopPropagation here — xterm's - // SelectionService listens for mouseup on ownerDocument to clear the - // pending drag-select state initiated by the mousedown of the same click. - // Stopping propagation leaves SelectionService's mousemove/mouseup handlers - // attached, so returning focus to the terminal and moving the mouse (even - // without holding a button) extends a selection until the next click/Esc. - event?.preventDefault?.() + const finish = (handled: boolean): boolean => { + if (handled) { + // Why: prevent anchor navigation without blocking xterm's document-level selection cleanup. + event?.preventDefault?.() + } + return handled + } const openDetectedPathLink = (): boolean => { const resolved = resolveTerminalFileLinkText( @@ -61,11 +77,17 @@ export function handleOscLink( if (!resolved) { return false } - openDetectedFilePath(resolved.absolutePath, resolved.line, resolved.column, { - ...deps, - openWithSystemDefault: Boolean(event?.shiftKey) - }) - return true + return finish( + handleTerminalFileLink( + resolved.absolutePath, + resolved.line, + resolved.column, + event as MouseEvent, + deps, + deps.linkActionContext, + rawText + ) + ) } if ( @@ -86,17 +108,20 @@ export function handleOscLink( } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - openTerminalHttpLink(parsed.toString(), { - worktreeId: deps.worktreeId, - sourceOwner: - deps.sourceOwner ?? - (deps.runtimeEnvironmentId - ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } - : { kind: 'local' }), - modifierHeld: Boolean(event?.shiftKey), - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference - }) - return true + return finish( + handleTerminalHttpLink(parsed.toString(), event as MouseEvent, { + worktreeId: deps.worktreeId, + sourceOwner: + deps.sourceOwner ?? + (deps.runtimeEnvironmentId + ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } + : { kind: 'local' }), + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.linkActionContext, + actionDestinations: deps.actionDestinations, + actionDestination: rawText + }) + ) } if (parsed.protocol === 'file:') { @@ -113,11 +138,17 @@ export function handleOscLink( if (!resolved) { return false } - openDetectedFilePath(resolved.filePath, resolved.line, resolved.column, { - ...deps, - openWithSystemDefault: Boolean(event?.shiftKey) - }) - return true + return finish( + handleTerminalFileLink( + resolved.filePath, + resolved.line, + resolved.column, + event as MouseEvent, + deps, + deps.linkActionContext, + rawText + ) + ) } return false } diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index 4c5184cc933..0461474dedd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -134,14 +134,17 @@ function makeTerminal(options?: { } } -function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent { +function mouseEventForRow( + row: number, + options: { altKey?: boolean; plain?: boolean } = {} +): MouseEvent { let defaultPrevented = false return { button: 0, - metaKey: true, + metaKey: !options.plain, ctrlKey: false, altKey: options.altKey ?? false, - shiftKey: true, + shiftKey: !options.plain, get defaultPrevented() { return defaultPrevented }, @@ -482,4 +485,51 @@ describe('hard-wrapped terminal HTTP clicks', () => { expect(terminal.options.mouseEventsRequireAlt).toBe(false) disposable.dispose() }) + + it('owns a plain HTTP link click without opening until an action is chosen', () => { + const request = vi.fn() + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getLinkActionContext: () => ({ + paneId: 1, + pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + request, + focusTerminal: vi.fn() + }) + }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + const event = mouseEventForRow(0, { plain: true }) + + mouseDown!(event) + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + mouseUp!(event) + + expect(request).toHaveBeenCalledWith(expect.objectContaining({ kind: 'url' })) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('does not suppress a plain HTTP link when action popovers are disabled', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { + worktreeId: 'wt-1', + getLinkActionContext: () => null + }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + const event = mouseEventForRow(0, { plain: true }) + + mouseDown!(event) + mouseUp!(event) + + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index d173157ea65..c28ae97824c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -9,6 +9,12 @@ import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer- import { extractTerminalHttpLinks } from './terminal-http-url-extraction' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset' +import { translate } from '@/i18n/i18n' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' +import { + requestTerminalLinkAction, + type TerminalLinkActionContext +} from './terminal-link-action-request' export { extractTerminalHttpLinks } from './terminal-http-url-extraction' export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' @@ -18,6 +24,10 @@ type UrlLinkHitTestDeps = { sourceOwner?: HttpLinkSourceOwner modifierHeld?: boolean requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations + actionDestination?: string + forceDestination?: TerminalHttpLinkDestination } type UrlLinkClickFallbackDeps = { @@ -25,6 +35,15 @@ type UrlLinkClickFallbackDeps = { /** Resolved per click: the pane's PTY (and its runtime binding) may not exist at install time. */ getSourceOwner?: () => HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + getLinkActionContext?: () => TerminalLinkActionContext | null + getActionDestinations?: () => TerminalHttpLinkActionDestinations +} + +export type TerminalHttpLinkDestination = 'orca' | 'system' + +export type TerminalHttpLinkActionDestinations = { + primary: TerminalHttpLinkDestination + alternate?: TerminalHttpLinkDestination } export type TerminalLinkRoutingPreferenceRequester = ( @@ -35,10 +54,73 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { if (event.defaultPrevented || event.button !== 0) { return false } - // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so - // plain clicks remain available for cursor placement and selection. Mobile - // tap routing is handled separately under mobile/src/terminal. - return isTerminalHttpLinkActivation(event) + // Why: Shift-only, Alt, and non-primary clicks remain available to the terminal or child TUI. + return isTerminalOwnedLinkGesture(event) +} + +export function handleTerminalHttpLink( + url: string, + event: MouseEvent | undefined, + deps: UrlLinkHitTestDeps +): boolean { + if (isTerminalHttpLinkActivation(event)) { + const forceDestination = event?.shiftKey + ? deps.actionDestinations?.alternate + : deps.actionDestinations?.primary + openTerminalHttpLink(url, { + ...deps, + modifierHeld: forceDestination ? false : Boolean(event?.shiftKey), + forceDestination + }) + return true + } + + const actionDestinations = deps.actionDestinations + const primaryDestination = actionDestinations?.primary + const labelForDestination = (destination: TerminalHttpLinkDestination): string => + destination === 'orca' + ? translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.orcaBrowser', + 'Orca Browser' + ) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.systemBrowser', + 'System Browser' + ) + + return requestTerminalLinkAction(event, deps.linkActionContext, { + destination: deps.actionDestination ?? url, + kind: 'url', + primary: { + external: primaryDestination === 'system', + label: primaryDestination + ? labelForDestination(primaryDestination) + : translate( + 'auto.components.terminal.pane.TerminalLinkActionPopover.openLink', + 'Open link' + ), + run: () => + openTerminalHttpLink(url, { + ...deps, + modifierHeld: false, + forceDestination: primaryDestination + }) + }, + ...(actionDestinations?.alternate + ? { + alternate: { + external: actionDestinations.alternate === 'system', + label: labelForDestination(actionDestinations.alternate), + run: () => + openTerminalHttpLink(url, { + ...deps, + modifierHeld: false, + forceDestination: actionDestinations.alternate + }) + } + } + : {}) + }) } export function openHttpLinkAtTerminalMouseEvent( @@ -56,6 +138,19 @@ export function openHttpLinkAtTerminalMouseEvent( return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps) } +export function findHttpLinkAtTerminalMouseEvent( + terminal: Terminal, + event: MouseEvent +): string | null { + if (event.button !== 0 || !isTerminalOwnedLinkGesture(event)) { + return null + } + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + return position + ? findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) + : null +} + export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps @@ -74,16 +169,19 @@ export function installHttpLinkClickFallback( return } - // Why: xterm's WebLinksAddon only activates after hover state exists. This - // direct mouseup fallback preserves modifier-clicks when the hover link was - // never established, while defaultPrevented avoids duplicate opens. - const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { - worktreeId: deps.worktreeId, - sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' }, - modifierHeld: event.shiftKey, - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference - }) - if (opened) { + // Why: xterm's WebLinksAddon misses first clicks before hover state exists. + const url = findHttpLinkAtTerminalMouseEvent(terminal, event) + const handled = Boolean( + url && + handleTerminalHttpLink(url, event, { + worktreeId: deps.worktreeId, + sourceOwner: deps.getSourceOwner?.() ?? { kind: 'local' }, + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.getLinkActionContext?.(), + actionDestinations: deps.getActionDestinations?.() + }) + ) + if (handled) { event.preventDefault() terminal.clearSelection() } @@ -161,6 +259,15 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi // Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted // pane must be classified by its pane's host, not the global active runtime. const sourceOwner = deps.sourceOwner ?? { kind: 'local' } + if (deps.forceDestination) { + openHttpLink(url, { + worktreeId: deps.worktreeId, + forceInApp: deps.forceDestination === 'orca', + forceSystemBrowser: deps.forceDestination === 'system', + sourceOwner + }) + return + } if (deps.modifierHeld) { // Why: the modifier states a destination outright, so it also skips the // one-time routing prompt; openHttpLink resolves which destination it means. diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts index f6d776eb289..441ff06ce86 100644 --- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -1,12 +1,15 @@ import type { Terminal } from '@xterm/xterm' import type { LinkHandlerDeps } from './terminal-link-handlers' -import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { isTerminalOwnedLinkGesture } from './terminal-link-activation' import { handleOscLink } from './terminal-osc-link-routing' import { - openHttpLinkAtTerminalMouseEvent, + findHttpLinkAtTerminalMouseEvent, + handleTerminalHttpLink, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import type { TerminalLinkActionContext } from './terminal-link-action-request' type TerminalWebLinkClickDeps = Pick< LinkHandlerDeps, @@ -15,6 +18,8 @@ type TerminalWebLinkClickDeps = Pick< terminal: Terminal | null sourceOwner?: HttpLinkSourceOwner requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester + linkActionContext?: TerminalLinkActionContext | null + actionDestinations?: TerminalHttpLinkActionDestinations } export function handleTerminalWebLinkClick( @@ -22,28 +27,27 @@ export function handleTerminalWebLinkClick( event: MouseEvent | undefined, deps: TerminalWebLinkClickDeps ): boolean { - if (!event || !isTerminalHttpLinkActivation(event)) { + if (!event || !isTerminalOwnedLinkGesture(event)) { return false } let handled: boolean - if ( - deps.terminal && - openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { + const completeUrl = deps.terminal ? findHttpLinkAtTerminalMouseEvent(deps.terminal, event) : null + if (completeUrl) { + handled = handleTerminalHttpLink(completeUrl, event, { worktreeId: deps.worktreeId, sourceOwner: deps.sourceOwner ?? (deps.runtimeEnvironmentId ? { kind: 'runtime', runtimeEnvironmentId: deps.runtimeEnvironmentId } : { kind: 'local' }), - modifierHeld: Boolean(event.shiftKey), - requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference, + linkActionContext: deps.linkActionContext, + actionDestinations: deps.actionDestinations }) - ) { // Why: WebLinksAddon only knows the physical row; Orca's logical hit-test // preserves the complete URL rendered across hard-wrapped TUI rows. event.preventDefault() - handled = true } else { handled = handleOscLink(url, event, deps) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 631287b7217..1b666e20a80 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -39,9 +39,15 @@ import { handleOscLink } from './terminal-osc-link-routing' import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback, + type TerminalHttpLinkActionDestinations, type TerminalLinkRoutingPreferenceRequester } from './terminal-url-link-hit-testing' import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming' +import { installTerminalLinkPointerGesture } from './terminal-link-pointer-gesture' +import type { + TerminalLinkActionContext, + TerminalLinkActionRequester +} from './terminal-link-action-request' import { resolveLocalhostHttpLinkDisplayUrl, type HttpLinkSourceOwner @@ -255,6 +261,7 @@ type UseTerminalPaneLifecycleDeps = { settings: GlobalSettings | null | undefined settingsRef: React.RefObject requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester + requestTerminalLinkAction: TerminalLinkActionRequester /** Resolved Option-as-Alt: `'auto'` already mapped to `'true'|'false'` via the layout probe, which lives outside the settings store. */ effectiveMacOptionAsAlt: EffectiveMacOptionAsAlt effectiveMacOptionAsAltRef: React.RefObject @@ -594,6 +601,7 @@ export function useTerminalPaneLifecycle({ settings, settingsRef, requestOpenLinksInAppPreference, + requestTerminalLinkAction, effectiveMacOptionAsAlt, effectiveMacOptionAsAltRef, initialLayoutRef, @@ -656,6 +664,9 @@ export function useTerminalPaneLifecycle({ const linkProviderDisposablesRef = useRef(new Map()) const terminalHandleLinkDisposablesRef = useRef(new Map()) const linkifierClickPrimingDisposablesRef = useRef(new Map()) + const linkPointerGesturesRef = useRef( + new Map>() + ) const fileLinkClickFallbackDisposablesRef = useRef(new Map()) const httpLinkClickFallbackDisposablesRef = useRef(new Map()) // Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes. @@ -698,6 +709,7 @@ export function useTerminalPaneLifecycle({ const linkDisposables = linkProviderDisposablesRef.current const terminalHandleLinkDisposables = terminalHandleLinkDisposablesRef.current const linkifierClickPrimingDisposables = linkifierClickPrimingDisposablesRef.current + const linkPointerGestures = linkPointerGesturesRef.current const fileLinkClickFallbackDisposables = fileLinkClickFallbackDisposablesRef.current const httpLinkClickFallbackDisposables = httpLinkClickFallbackDisposablesRef.current const selectionDisposables = selectionDisposablesRef.current @@ -728,6 +740,32 @@ export function useTerminalPaneLifecycle({ resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) const getHttpLinkSourceOwnerForPane = (paneId: number) => resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId)) + const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => { + const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) + if (sourceOwner.kind !== 'local') { + return { primary: 'system' } + } + const options = terminalUrlOpenHintOptionsFor(settingsRef.current, sourceOwner) + return options.openLinksInApp + ? { primary: 'orca', alternate: 'system' } + : { primary: 'system', alternate: 'orca' } + } + const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => { + if (settingsRef.current?.terminalLinkActionPopoverEnabled === false) { + return null + } + const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId) + const pointerGesture = linkPointerGestures.get(paneId) + if (!pane || !pointerGesture) { + return null + } + return { + paneId, + pointerGesture, + request: requestTerminalLinkAction, + focusTerminal: () => pane.terminal.focus() + } + } // Why: lifecycle-scoped cache for cross-SSH/runtime existence probes; may hold temporarily stale entries. const pathExistsCache = new Map() const linkDeps: LinkHandlerDeps = { @@ -743,7 +781,8 @@ export function useTerminalPaneLifecycle({ getRuntimeEnvironmentIdForPane: (paneId) => { const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) return sourceOwner.kind === 'runtime' ? sourceOwner.runtimeEnvironmentId : null - } + }, + getLinkActionContext } let resizeRaf: number | null = null const queueResizeAll = (focusActive: boolean): void => { @@ -847,9 +886,13 @@ export function useTerminalPaneLifecycle({ const fileOpenLinkHint = getTerminalFileOpenHint() // Why: read settingsRef at fire time so toggling link routing applies without recreating panes. const getUrlOpenLinkHint = (paneId: number): string => - getTerminalUrlOpenHint( - terminalUrlOpenHintOptionsFor(settingsRef.current, getHttpLinkSourceOwnerForPane(paneId)) - ) + getTerminalUrlOpenHint({ + ...terminalUrlOpenHintOptionsFor( + settingsRef.current, + getHttpLinkSourceOwnerForPane(paneId) + ), + showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false + }) const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -1046,6 +1089,8 @@ export function useTerminalPaneLifecycle({ return !shouldBypass }) + const linkPointerGesture = installTerminalLinkPointerGesture(pane.terminal) + linkPointerGestures.set(pane.id, linkPointerGesture) const linkProviderDisposable = pane.terminal.registerLinkProvider( createFilePathLinkProvider(pane.id, linkDeps, pane.linkTooltip, fileOpenLinkHint) ) @@ -1057,7 +1102,8 @@ export function useTerminalPaneLifecycle({ ?.terminal ?? null, getRuntimeEnvironmentId: () => linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, - linkTooltip: pane.linkTooltip + linkTooltip: pane.linkTooltip, + getLinkActionContext: () => getLinkActionContext(pane.id) }) ) terminalHandleLinkDisposablesRef.current.set(pane.id, terminalHandleLinkDisposable) @@ -1072,7 +1118,9 @@ export function useTerminalPaneLifecycle({ const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, { ...linkDeps, getSourceOwner: () => getHttpLinkSourceOwnerForPane(pane.id), - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + getLinkActionContext: () => getLinkActionContext(pane.id), + getActionDestinations: () => getHttpLinkActionDestinations(pane.id) }) httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable) seedStartupSessionRestoredBanner(ptyDeps.startup, pane.id, onShowSessionRestoredBanner) @@ -1142,7 +1190,9 @@ export function useTerminalPaneLifecycle({ startupCwd: getPaneLinkCwd(pane.id), runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null, sourceOwner: getHttpLinkSourceOwnerForPane(pane.id), - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + linkActionContext: getLinkActionContext(pane.id), + actionDestinations: getHttpLinkActionDestinations(pane.id) }) // Why: link activation can steal focus before the click's mouseup reaches xterm, stranding its drag-select // listener (runaway selection until next click/Esc); clearSelection detaches it (SelectionService._removeMouseDownListeners). @@ -1216,6 +1266,11 @@ export function useTerminalPaneLifecycle({ linkifierClickPrimingDisposable.dispose() linkifierClickPrimingDisposablesRef.current.delete(paneId) } + const linkPointerGesture = linkPointerGestures.get(paneId) + if (linkPointerGesture) { + linkPointerGesture.dispose() + linkPointerGestures.delete(paneId) + } const fileLinkClickFallbackDisposable = fileLinkClickFallbackDisposablesRef.current.get(paneId) if (fileLinkClickFallbackDisposable) { @@ -1459,8 +1514,10 @@ export function useTerminalPaneLifecycle({ }, terminalTuiScrollSensitivity: () => normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), - onLinkClick: (event, url) => { - const activePane = managerRef.current?.getActivePane() + onLinkClick: (paneId, event, url) => { + const activePane = managerRef.current + ?.getPanes() + .find((candidate) => candidate.id === paneId) handleTerminalWebLinkClick(url, event, { ...linkDeps, terminal: activePane?.terminal ?? null, @@ -1471,7 +1528,9 @@ export function useTerminalPaneLifecycle({ sourceOwner: activePane ? getHttpLinkSourceOwnerForPane(activePane.id) : { kind: 'local' }, - requestOpenLinksInAppPreference + requestOpenLinksInAppPreference, + linkActionContext: getLinkActionContext(paneId), + actionDestinations: getHttpLinkActionDestinations(paneId) }) }, linkOpenHint: getUrlOpenLinkHint, @@ -1713,6 +1772,10 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } linkifierClickPrimingDisposables.clear() + for (const gesture of linkPointerGestures.values()) { + gesture.dispose() + } + linkPointerGestures.clear() for (const disposable of fileLinkClickFallbackDisposables.values()) { disposable.dispose() } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3e0980342eb..3184344d835 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2839,6 +2839,19 @@ "cc5a2dc706": "Edit pane title: {{value0}}", "7dbbfcbecc": "Pane title" }, + "TerminalLinkActionPopover": { + "openLink": "Open link", + "openFile": "Open file", + "switchWorkspace": "Switch workspace", + "openInFinder": "Open in Finder", + "openFolder": "Open folder", + "openWithDefaultApp": "Open with default app", + "switchTerminal": "Switch terminal", + "openTaskTerminal": "Open task terminal", + "systemBrowser": "System Browser", + "orcaBrowser": "Orca Browser", + "terminalLinkSettings": "Terminal link settings" + }, "TerminalSessionStateSaveFailureDialog": { "6bee0c8f17": "Open Disk Space Analyzer", "ae20d0ffc2": "Dismiss", @@ -8110,6 +8123,14 @@ "invert": "invert", "opposite": "opposite" }, + "terminalLinkActions": { + "terminal": "terminal", + "click": "click", + "actions": "actions", + "popover": "popover", + "menu": "menu", + "disable": "disable" + }, "72c58f7792": "webview", "82ba1c80ea": "localhost", "bea27bac4b": "links", @@ -10023,6 +10044,10 @@ "descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.", "descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead." }, + "BrowserTerminalLinkActionsSetting": { + "title": "Show terminal link actions", + "description": "Show available actions when you click a terminal link. Turn this off to require {{modifier}}-click." + }, "PluginConsentDialog": { "workerTrust": "Background worker — runs its own process", "instructionalTrust": "Instructional content — runs later under user or agent authority", diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index d0c96799978..64991900a25 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -103,6 +103,34 @@ describe('openHttpLink', () => { expect(createBrowserTabMock).not.toHaveBeenCalled() }) + it('forceInApp opens a local link in Orca when the setting is off', () => { + storeState.settings = { openLinksInApp: false } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + forceInApp: true, + sourceOwner: { kind: 'local' } + }) + + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('does not force a remote link into the Orca browser', () => { + storeState.settings = { openLinksInApp: false } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + forceInApp: true, + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + it('routes to the system browser when a remote runtime environment is active', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 86e4f91fcd5..60f53457fc7 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -10,6 +10,8 @@ export type OpenHttpLinkOptions = { worktreeId?: string | null /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean + /** Unconditional for local sources: open inside Orca regardless of settings. */ + forceInApp?: boolean /** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */ modifierHeld?: boolean sourceOwner?: HttpLinkSourceOwner @@ -91,7 +93,7 @@ export function resolveModifierRouting( } export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { - const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts + const { worktreeId, forceSystemBrowser, forceInApp, modifierHeld, sourceOwner } = opts if (sourceOwner?.kind === 'unknown') { return } @@ -109,7 +111,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void !forceSystemBrowser && !modifier.wantsSystemBrowser && Boolean(worktreeId) && - (openLinksInApp || modifier.wantsOrca) + (forceInApp || openLinksInApp || modifier.wantsOrca) if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts index 64cbdd6ada1..fd0ff5e88ec 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.test.ts @@ -4,6 +4,7 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import { createPaneDOM } from './pane-dom-creation' const webLinksAddonMock = vi.hoisted(() => ({ + handler: null as ((event: MouseEvent, uri: string) => void) | null, options: null as { hover?: (event: MouseEvent, uri: string) => void; leave?: () => void } | null })) @@ -32,7 +33,8 @@ vi.mock('@xterm/addon-unicode11', () => ({ })) vi.mock('@xterm/addon-web-links', () => ({ - WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(_handler, options) { + WebLinksAddon: vi.fn().mockImplementation(function WebLinksAddon(handler, options) { + webLinksAddonMock.handler = handler webLinksAddonMock.options = options return {} }) @@ -135,4 +137,23 @@ describe('createPaneDOM link tooltips', () => { expect(linkOpenHint).toHaveBeenCalledWith(7) expect(formatLinkTooltip).toHaveBeenCalledWith(7, 'http://localhost:5180/', 'open hint') }) + + it('identifies the clicked pane to link routing', () => { + const leafId = '11111111-1111-4111-8111-111111111111' as TerminalLeafId + const onLinkClick = vi.fn() + createPaneDOM( + 7, + leafId, + { linkOpenHint: () => 'open hint', onLinkClick }, + { active: null } as never, + {} as never, + vi.fn(), + vi.fn() + ) + const event = {} as MouseEvent + + webLinksAddonMock.handler?.(event, 'https://example.com') + + expect(onLinkClick).toHaveBeenCalledWith(7, event, 'https://example.com') + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 0163587662e..f383dfa3c53 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -70,7 +70,7 @@ export function createPaneDOM( const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks) const webLinksAddon = new WebLinksAddon( - options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined, + options.onLinkClick ? (event, uri) => options.onLinkClick!(id, event, uri) : undefined, { hover: (_event, uri) => { if (uri) { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 21956fad0ec..113f10af80d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -58,7 +58,7 @@ export type PaneManagerOptions = { onExternalPaneDrop?: PaneExternalDropHandler terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined - onLinkClick?: (event: MouseEvent | undefined, url: string) => void + onLinkClick?: (paneId: number, event: MouseEvent | undefined, url: string) => void /** Resolved per hover so link-routing setting changes apply without recreating panes. */ // Why: required so dropping the wiring is a compile error — an optional hint with a // default would silently serve stale copy that no test can distinguish. diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 1079a47f0f7..b901ffb0cce 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -60,6 +60,7 @@ const SETTINGS_NAV_INTENT_SET: ReadonlySet = new Set(SETTINGS_NAV_INTENT export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number] export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access' +export const BROWSER_TERMINAL_LINK_ACTIONS_SETTINGS_TARGET_ID = 'browser-terminal-link-actions' export type SettingsNavigationTarget = { pane: SettingsNavTarget diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index f3a6ff1b89e..ea41c8a6af9 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -69,6 +69,10 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true) }) + it('shows terminal link actions by default', () => { + expect(getDefaultSettings('/tmp').terminalLinkActionPopoverEnabled).toBe(true) + }) + it('confirms before closing pinned tabs by default', () => { expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index fcd72044911..d9a17c103f5 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -265,6 +265,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, openLinksInAppModifierInverts: false, + terminalLinkActionPopoverEnabled: true, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, nativeChatSessionOptions: {}, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 6115584fd04..bbffedaefe0 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', 'openLinksInAppModifierInverts', + 'terminalLinkActionPopoverEnabled', 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', diff --git a/src/shared/types.ts b/src/shared/types.ts index 63019fcd03d..c6dfff01d3c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2878,6 +2878,8 @@ export type GlobalSettings = { openLinksInAppPreferencePrompted: boolean /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ openLinksInAppModifierInverts?: boolean + /** Show terminal link actions on plain click; off restores modifier-click-only terminal links. */ + terminalLinkActionPopoverEnabled?: boolean /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */ From bdd763188fc79a94a3325e8e72c81e957a331bfc Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 9 Aug 2026 19:52:16 -0700 Subject: [PATCH 10/20] Fix Windows AI Vault resumes using the wrong shell (#13420) * fix: use project shell for AI Vault resumes * fix: preserve WSL cwd fallback semantics --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/ipc/pty.test.ts | 276 ++++++++++++++++++ src/main/ipc/pty.ts | 66 ++++- src/main/providers/local-pty-provider.test.ts | 25 ++ src/main/providers/local-pty-provider.ts | 4 +- src/main/wsl.test.ts | 96 +++++- src/main/wsl.ts | 62 +++- .../tab-group/AiVaultSessionDropLayer.tsx | 1 + .../src/lib/ai-vault-omp-cold-resume.test.ts | 4 +- ...ai-vault-resume-command.drop-repin.test.ts | 2 +- .../src/lib/ai-vault-resume-command.test.ts | 76 +++-- .../src/lib/ai-vault-resume-command.ts | 68 ++--- src/renderer/src/lib/ai-vault-resume-shell.ts | 72 +++++ .../src/lib/launch-ai-vault-session.test.ts | 8 +- .../src/lib/launch-ai-vault-session.ts | 7 +- 14 files changed, 658 insertions(+), 109 deletions(-) create mode 100644 src/renderer/src/lib/ai-vault-resume-shell.ts diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 05cafc81785..010f35f436e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -16,6 +16,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-a import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' +import type * as Wsl from '../wsl' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -57,6 +58,7 @@ const { piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, + wslUncDirectoryExistsAsyncMock, trackMock, classifyErrorMock, registerPtyMock, @@ -86,6 +88,7 @@ const { openCodeBuildPtyEnvMock: vi.fn(), mimoCodeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), + wslUncDirectoryExistsAsyncMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), clearAgentHookPaneStateMock: vi.fn(), @@ -183,6 +186,11 @@ vi.mock('../pwsh', () => ({ isPwshAvailableAsync: isPwshAvailableMock })) +vi.mock('../wsl', async (importOriginal) => ({ + ...(await importOriginal()), + wslUncDirectoryExistsAsync: (...args: unknown[]) => wslUncDirectoryExistsAsyncMock(...args) +})) + vi.mock('../telemetry/client', () => ({ track: trackMock })) @@ -384,6 +392,8 @@ describe('registerPtyHandlers', () => { piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockReset() + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) trackMock.mockReset() classifyErrorMock.mockReset() registerPtyMock.mockReset() @@ -12125,6 +12135,272 @@ describe('registerPtyHandlers', () => { expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) }) + it('keeps an existing POSIX startup cwd for the selected WSL runtime', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(true) + statSyncMock.mockImplementation((target: string) => { + if (target === '/home/alice/repo') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:\\Users\\alice\\repo', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + }) + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledWith( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo' + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo', shellOverride: 'wsl.exe' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('falls back when the selected WSL runtime reports a POSIX cwd missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu-24.04', + reason: 'project-override', + cacheKey: 'repo-1:wsl' + } + } + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: worktreePath })) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX WSL cwd when the distro probe is inconclusive', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-inconclusive-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(null) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a POSIX cwd when WSL owns it but no distro can be resolved', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-no-distro-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: [] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: unknown } + + expect(statSyncMock).not.toHaveBeenCalledWith('/home/alice/repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/repo' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it.each([ + { exists: true, expectedCwd: '/home/alice/repo', expectedFallback: undefined }, + { + exists: false, + expectedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + expectedFallback: { + kind: 'worktree', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + } + } + ])('resolves POSIX cwd existence for a WSL UNC workspace ($exists)', async (testCase) => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-unc-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(testCase.exists) + if (!testCase.exists) { + wslUncDirectoryExistsAsyncMock.mockResolvedValueOnce(true) + } + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: testCase.expectedCwd }) + ) + expect(result.startupCwdFallback).toEqual(testCase.expectedFallback) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('preserves a missing POSIX cwd when its WSL workspace root is also missing', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-missing-root' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + wslUncDirectoryExistsAsyncMock.mockResolvedValue(false) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/home/alice/deleted', + cwdFallback: 'worktree', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\alice' + })) as { startupCwdFallback?: unknown } + + expect(wslUncDirectoryExistsAsyncMock).toHaveBeenCalledTimes(2) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/home/alice/deleted' }) + ) + expect(result.startupCwdFallback).toBeUndefined() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('validates a /mnt drive cwd through its native Windows path', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-mnt-cwd' }) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/mnt/c/Users/alice/repo', + cwdFallback: 'worktree', + worktreeId: 'repo-1::C:/Users/alice/repo', + shellOverride: 'wsl.exe' + }) + + expect(statSyncMock).toHaveBeenCalledWith('C:\\Users\\alice\\repo') + expect(wslUncDirectoryExistsAsyncMock).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/c/Users/alice/repo' }) + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still falls back a missing Windows cwd when the selected runtime is WSL', async () => { + const originalPlatform = process.platform + const providerSpawn = vi.fn().mockResolvedValue({ id: 'pty-wsl-windows-cwd' }) + const worktreePath = 'C:/Users/alice/repo' + const missingCwd = `${worktreePath}/deleted-folder` + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + try { + installDaemonTestProvider({ spawn: providerSpawn }) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}`, + shellOverride: 'wsl.exe' + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ cwd: worktreePath, shellOverride: 'wsl.exe' }) + ) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('keeps a missing cwd unchanged without the fallback flag', async () => { registerPtyHandlers(mainWindow as never) existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 219829c619e..3ff4e36fe1d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -139,7 +139,7 @@ import { resolveTerminalStartupCwdForWorkspace, type TerminalStartupCwdMissingDirFallback } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { @@ -150,7 +150,7 @@ import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath } from '../wsl' +import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' import { PtyProducerFlowController } from './pty-producer-flow-control' @@ -5775,15 +5775,73 @@ export function registerPtyHandlers( await startupPromise } // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. - const allowMissingCwdFallback = + const requestedMissingCwdFallback = !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isWslOwnedPosixCwd = + args.cwd?.startsWith('/') === true && !/^\/[A-Za-z](?:\/|$)/.test(args.cwd) + const startupWorkspaceCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isWslOwnedPosixCwd + ? resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isWslOwnedPosixCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath( + initiallyResolvedStartupCwd, + startupWslContext.distro + ) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : localStartupCwdDirectoryExists(startupWorkspaceCwd) + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) let didFallbackToWorkspaceRootCwd = false const cwd = resolvePtySpawnStartupCwd( args.worktreeId, args.cwd, allowMissingCwdFallback ? { - directoryExists: localStartupCwdDirectoryExists, + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : localStartupCwdDirectoryExists(path), onFallbackToWorkspaceRoot: () => { didFallbackToWorkspaceRootCwd = true } diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 5567c11c957..a7b675e78da 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -978,6 +978,31 @@ describe('LocalPtyProvider', () => { expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian') }) + it('translates a POSIX cwd through the preferred WSL distro', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo-1::C:\\Users\\jin\\repo', + cwd: '/home/jin/repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + const spawnCall = spawnMock.mock.calls.at(-1)! + expect(spawnCall[0]).toBe('wsl.exe') + expect(spawnCall[1]).toEqual([ + '-d', + 'Debian', + '--', + 'sh', + '-c', + expect.stringContaining("cd '/home/jin/repo'") + ]) + expect(spawnCall[2].cwd).not.toBe('/home/jin/repo') + }) + it('resolves and persists the default distro for Windows cwd WSL terminals', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const buildSpawnEnv = vi.fn( diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6ed3c9f0f87..d1ba44e5f2e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -239,9 +239,9 @@ function getWslContextFromWorktreeId( */ function getWslContextFromPreferredDistro( distro: string | null | undefined -): { distro: string } | undefined { +): { distro: string; treatPosixCwdAsWsl: true } | undefined { const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined } /** diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index cd77d761caf..719368e3af4 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -29,7 +29,8 @@ import { parseWslPath, toLinuxPath, toWindowsWslPath, - wslUncDirectoryExists + wslUncDirectoryExists, + wslUncDirectoryExistsAsync } from './wsl' function withPlatform(value: NodeJS.Platform, fn: () => T): T { @@ -805,36 +806,39 @@ describe('wslUncDirectoryExists', () => { }) it('returns true when the distro reports the directory exists', () => { - execFileSyncMock.mockReturnValue('') + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_EXISTS__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') ) expect(result).toBe(true) expect(execFileSyncMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Ubuntu', '--', 'test', '-d', '/home/jin/repo'], + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], expect.objectContaining({ timeout: 5000 }) ) }) - it('returns false when test -d exits non-zero (directory missing)', () => { - execFileSyncMock.mockImplementation(() => { - // Why: child_process surfaces a non-zero exit as an Error with `status`. - const error = new Error('Command failed') as Error & { status: number } - error.status = 1 - throw error - }) + it('returns false when the guest reports the directory missing', () => { + execFileSyncMock.mockReturnValue('__ORCA_DIRECTORY_MISSING__') const result = withPlatform('win32', () => wslUncDirectoryExists('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') ) expect(result).toBe(false) }) - it('returns null when wsl.exe is unavailable (inconclusive)', () => { + it('returns null when wsl.exe or the distro is unavailable', () => { execFileSyncMock.mockImplementation(() => { - // No numeric `status` -> spawn failure (ENOENT), not a missing directory. - const error = new Error('spawn wsl.exe ENOENT') as Error & { code: string } - error.code = 'ENOENT' + const error = new Error('distro unavailable') as Error & { status: number } + error.status = 4294967295 throw error }) const result = withPlatform('win32', () => @@ -851,3 +855,67 @@ describe('wslUncDirectoryExists', () => { expect(execFileSyncMock).not.toHaveBeenCalled() }) }) + +describe('wslUncDirectoryExistsAsync', () => { + afterEach(() => { + execFileMock.mockReset() + }) + + it('returns true when the distro reports the directory exists', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_EXISTS__') + ) + + await expect( + withPlatformAsync('win32', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ) + ).resolves.toBe(true) + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + [ + '-d', + 'Ubuntu', + '--', + 'sh', + '-c', + expect.stringContaining('__ORCA_DIRECTORY_EXISTS__'), + 'sh', + '/home/jin/repo' + ], + expect.objectContaining({ timeout: 5000 }), + expect.any(Function) + ) + }) + + it('distinguishes a missing directory from an inconclusive probe', async () => { + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => + callback(null, '__ORCA_DIRECTORY_MISSING__') + ) + .mockImplementationOnce((_command, _args, _options, callback) => + callback(Object.assign(new Error('distro unavailable'), { code: 4294967295 }), '') + ) + + await withPlatformAsync('win32', async () => { + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') + ).resolves.toBe(false) + await expect( + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + ).resolves.toBeNull() + }) + }) + + it('returns null without spawning for paths outside WSL or off Windows', async () => { + await expect( + withPlatformAsync('win32', () => wslUncDirectoryExistsAsync('C:\\Users\\jin\\repo')) + ).resolves.toBeNull() + await expect( + withPlatformAsync('linux', () => + wslUncDirectoryExistsAsync('\\\\wsl.localhost\\Ubuntu\\home\\jin') + ) + ).resolves.toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/wsl.ts b/src/main/wsl.ts index e415a3c937f..8311170faeb 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -21,6 +21,33 @@ export type WslPathInfo = { linuxPath: string } +const WSL_DIRECTORY_EXISTS_MARKER = '__ORCA_DIRECTORY_EXISTS__' +const WSL_DIRECTORY_MISSING_MARKER = '__ORCA_DIRECTORY_MISSING__' + +function getWslDirectoryProbeArgs(info: WslPathInfo): string[] { + return [ + '-d', + info.distro, + '--', + 'sh', + '-c', + `if [ -d "$1" ]; then printf ${WSL_DIRECTORY_EXISTS_MARKER}; else printf ${WSL_DIRECTORY_MISSING_MARKER}; fi`, + 'sh', + info.linuxPath + ] +} + +function parseWslDirectoryProbeOutput(stdout: unknown): boolean | null { + const output = String(stdout) + if (output.includes(WSL_DIRECTORY_EXISTS_MARKER)) { + return true + } + if (output.includes(WSL_DIRECTORY_MISSING_MARKER)) { + return false + } + return null +} + /** * Detect if a Windows path is a WSL UNC path and extract the distro name * and equivalent Linux path. @@ -49,8 +76,8 @@ export function isWslPath(path: string): boolean { * Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...) * is unreliable for repos that live on the WSL side — it can report ENOENT for * directories that exist, which made opening a WSL worktree fail with - * "Working directory ... does not exist". `wsl.exe -d test -d` asks - * the distro directly, which is the authoritative answer. Returns null (rather + * "Working directory ... does not exist". The guest marker probe asks the + * distro directly, which is the authoritative answer. Returns null (rather * than false) when wsl.exe is unavailable or errors so callers can fall back to * the fs check instead of falsely rejecting a valid directory. */ @@ -63,22 +90,33 @@ export function wslUncDirectoryExists(uncPath: string): boolean | null { return null } try { - execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], { + const stdout = execFileSync('wsl.exe', getWslDirectoryProbeArgs(info), { stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000 + timeout: 5000, + encoding: 'utf8' }) - return true - } catch (error) { - // A non-zero exit (directory missing) surfaces as an error with a numeric - // `status`; treat that as a definitive "does not exist". Any other failure - // (wsl.exe missing, distro not running, timeout) is inconclusive -> null. - if (typeof (error as { status?: unknown })?.status === 'number') { - return false - } + return parseWslDirectoryProbeOutput(stdout) + } catch { return null } } +export function wslUncDirectoryExistsAsync(uncPath: string): Promise { + if (process.platform !== 'win32') { + return Promise.resolve(null) + } + const info = parseWslUncPath(uncPath) + if (!info) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + execFile('wsl.exe', getWslDirectoryProbeArgs(info), { timeout: 5000 }, (_error, stdout) => { + // Why: wsl.exe uses numeric exits for both guest results and host failures; only the guest marker is authoritative. + resolve(parseWslDirectoryProbeOutput(stdout)) + }) + }) +} + /** * Convert a Windows path to a Linux path for commands that will execute inside WSL. * Returns the path unchanged if it is already POSIX-style. diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 3c9ddd26369..a55aed7e886 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -260,6 +260,7 @@ export default function AiVaultSessionDropLayer({ agent: payload.agent, worktreeId, command: startup.command, + ...(payload.sessionCwd ? { cwd: payload.sessionCwd } : {}), ...(startup.env ? { env: startup.env } : {}), ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), diff --git a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts index f6e17312ae4..256858d3ad2 100644 --- a/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts +++ b/src/renderer/src/lib/ai-vault-omp-cold-resume.test.ts @@ -39,8 +39,8 @@ describe('AI Vault OMP cold resume', () => { }) expect(startup).toMatchObject({ - command: - "cd '/repo' && omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + command: "omp '--model' 'custom' --resume '/custom/omp-sessions/project/session.jsonl'", + cwd: '/repo', env: { OMP_PROFILE: 'custom' }, launchConfig: { agentCommand: "omp '--model' 'custom'", diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 6a0e64d2b04..139b4e3fd97 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -64,7 +64,7 @@ describe('buildAiVaultDropRepinStartup', () => { expect(startup).not.toBeNull() expect(startup?.command).toContain(`CODEX_HOME='${SELECTED_HOME}'`) expect(startup?.command).not.toContain(RECORDED_HOME) - expect(startup?.command).toContain("cd '/Users/ada/repo' && ") + expect(startup).toMatchObject({ cwd: '/Users/ada/repo' }) }) it('repins a payload whose session has no cwd instead of keeping the wrong-account command', () => { diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 9ad997974f5..f89f4054bf4 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -69,6 +69,30 @@ function buildQueuedAiVaultResumeCommand( } describe('ai vault resume command runtime', () => { + it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => { + const state = makeState({ + worktreePath: 'C:\\Users\\alice\\repo', + localWindowsRuntimePreference: { kind: 'windows-host' }, + terminalWindowsShell: 'wsl.exe' + }) + + expect( + buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'claude', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toMatchObject({ + command: "claude '--resume' 'session one'", + cwd: 'C:\\Users\\alice\\repo' + }) + }) + it('queues a PowerShell-valid command for the default Windows shell', () => { // Why: the queued command is typed into the live tab shell (default // PowerShell), which mis-parses the cmd `""`-doubled wrapper (#6152). @@ -85,7 +109,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('queues direct cmd syntax when the configured Windows shell is cmd.exe', () => { @@ -105,7 +129,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe('cd /d "C:\\Users\\alice\\repo" && claude "--resume" "session one"') + ).toBe('claude "--resume" "session one"') }) it('queues a POSIX command for the Git Bash Windows shell', () => { @@ -125,7 +149,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('follows the live Windows shell for non-resumable agents in the fallback path', () => { @@ -144,9 +168,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" - ) + ).toBe("cursor-agent --resume 'session one'") }) it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { @@ -166,9 +188,7 @@ describe('ai vault resume command runtime', () => { } }) - expect(command).toBe( - "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'" - ) + expect(command).toBe("omp --resume 'C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl'") expect(command).not.toContain('019f27cd-4268-7000-96e7-62f42a55c144') }) @@ -190,9 +210,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - 'cd /d "C:\\Users\\alice\\repo" && omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"' - ) + ).toBe('omp --resume "C:\\Users\\alice\\.omp\\agent\\sessions\\repo\\sess.jsonl"') }) it('copies syntax that matches the configured cmd shell', () => { @@ -337,8 +355,8 @@ describe('ai vault resume command runtime', () => { } }) ).toEqual({ - command: - "cd '/home/alice/repo' && claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: '/home/alice/repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", @@ -367,7 +385,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for SSH-owned worktrees on Windows clients', () => { @@ -386,7 +404,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/repo' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for folder workspaces with their own SSH target', () => { @@ -415,7 +433,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('uses POSIX command wrapping for WSL UNC folder workspaces on Windows clients', () => { @@ -443,7 +461,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe("cd '/home/alice/platform' && claude '--resume' 'session one'") + ).toBe("claude '--resume' 'session one'") }) it('keeps WSL UNC worktrees on POSIX command wrapping without an explicit override', () => { @@ -470,7 +488,7 @@ describe('ai vault resume command runtime', () => { codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' } }) - ).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") + ).toBe("CODEX_HOME='/home/alice/.codex' codex 'resume' 'session one'") }) it('converts WSL UNC OMP transcript paths before building Linux resume commands', () => { @@ -491,9 +509,7 @@ describe('ai vault resume command runtime', () => { codexHome: null } }) - ).toBe( - "cd '/home/alice/repo' && omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'" - ) + ).toBe("omp --resume '/home/alice/.omp/agent/sessions/repo/sess.jsonl'") }) it('deletes inherited Codex homes when resuming a real-home session', () => { @@ -511,7 +527,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "Set-Location -LiteralPath '/home/alice/repo'; codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }) }) @@ -534,7 +551,8 @@ describe('ai vault resume command runtime', () => { } }) ).toMatchObject({ - command: "cd '/home/alice/repo' && codex 'resume' 'session one'", + command: "codex 'resume' 'session one'", + cwd: '/home/alice/repo', envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], providerSession: { key: 'session_id', id: 'session one' } }) @@ -558,7 +576,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) it('copies remote real-home Codex commands with explicit environment cleanup', () => { @@ -603,7 +621,7 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && my-codex 'resume' 'session one'") + ).toBe("my-codex 'resume' 'session one'") }) it('rebuilds overridden remote commands with the recorded remote host platform', () => { @@ -629,9 +647,7 @@ describe('ai vault resume command runtime', () => { 'cmd /d /s /c "cd /d ""C:/Users/alice/repo"" && set ""CODEX_HOME=C:/Users/alice/.codex"" && codex resume ""session one"""' } }) - ).toBe( - "Set-Location -LiteralPath 'C:/Users/alice/repo'; $env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'" - ) + ).toBe("$env:CODEX_HOME='C:/Users/alice/.codex'; my-codex 'resume' 'session one'") }) it('ignores a stored resume command for local-host sessions', () => { @@ -651,6 +667,6 @@ describe('ai vault resume command runtime', () => { resumeCommand: "CODEX_HOME='/root/.codex' codex resume 'session one'" } }) - ).toBe("cd '/home/alice/repo' && codex 'resume' 'session one'") + ).toBe("codex 'resume' 'session one'") }) }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index ed13d16e8a9..e8d05f61140 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -15,13 +15,8 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { parseWslUncPath } from '../../../shared/wsl-paths' -import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' -import { - clearEnvCommand, - commandSeparator, - resolveStartupShell -} from '../../../shared/tui-agent-startup-shell' +import { clearEnvCommand, commandSeparator } from '../../../shared/tui-agent-startup-shell' import type { AppState } from '@/store/types' import type { AiVaultSessionDragPayload } from '@/lib/ai-vault-session-drag' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -29,7 +24,10 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getAiVaultResumeWorkspacePath, + resolveAiVaultResumeStartupShell +} from '@/lib/ai-vault-resume-shell' type AiVaultResumeCommandSession = Pick< AiVaultSession, @@ -41,6 +39,7 @@ type AiVaultResumeCommandSession = Pick< export type AiVaultResumeStartup = { command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -65,7 +64,7 @@ type AiVaultResumeWorktreeArgs = { } export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWorktreeArgs): string { - const command = buildAiVaultResumeForWorktree(args).command + const command = buildAiVaultResumeForWorktree(args, true).command if (args.session.agent !== 'codex' || args.session.codexHome !== null) { return command } @@ -80,7 +79,7 @@ export function buildAiVaultResumeCopyCommandForWorktree(args: AiVaultResumeWork export function buildAiVaultResumeStartupForWorktree( args: AiVaultResumeWorktreeArgs ): AiVaultResumeStartup { - return buildAiVaultResumeForWorktree(args) + return buildAiVaultResumeForWorktree(args, false) } /** @@ -120,7 +119,10 @@ export function buildAiVaultDropRepinStartup(args: { }) } -function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVaultResumeStartup { +function buildAiVaultResumeForWorktree( + args: AiVaultResumeWorktreeArgs, + embedCwd: boolean +): AiVaultResumeStartup { const providerSession = getAiVaultAgentProviderSession(args.session) if ( args.session.executionHostId && @@ -151,9 +153,11 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault const liveShell: AgentStartupShell | undefined = platform === 'win32' ? isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) + ? resolveAiVaultResumeShell(args) : 'powershell' : undefined + const cwd = embedCwd ? args.session.cwd : null + const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {} if (providerSession && isResumableTuiAgent(args.session.agent)) { const startupPlan = buildAgentResumeStartupPlan({ agent: args.session.agent, @@ -181,7 +185,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault agent: args.session.agent, sessionId: args.session.sessionId, resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: startupPlan.launchConfig.agentCommand, codexHome, @@ -189,13 +193,14 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault }) : buildAiVaultResumeShellCommand({ resumeCommand: startupPlan.launchCommand, - cwd: args.session.cwd, + cwd, platform, codexHome, shell: liveShell }), ...(startupPlan.env ? { env: startupPlan.env } : {}), ...realHomeCodexResumeEnvDeletion(args.session), + ...startupCwd, launchConfig: startupPlan.launchConfig, providerSession } @@ -210,7 +215,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // forward it too — otherwise a custom OMP_CODING_AGENT_DIR / WSL-store // session would resume by id against the default store and miss. resumeFilePath, - cwd: args.session.cwd, + cwd, platform, commandOverride: args.commandOverride, codexHome, @@ -218,6 +223,7 @@ function buildAiVaultResumeForWorktree(args: AiVaultResumeWorktreeArgs): AiVault // quote for the live Windows shell like the startup-plan branch above. shell: liveShell }), + ...startupCwd, ...realHomeCodexResumeEnvDeletion(args.session) } } @@ -231,11 +237,12 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu : getAiVaultResumePlatform(args.state, args.worktreeId) const isLocalSession = !args.session.executionHostId || args.session.executionHostId === LOCAL_EXECUTION_HOST_ID - const shell = - platform === 'win32' && isLocalSession - ? resolveWindowsShellStartupFamily(args.state.settings?.terminalWindowsShell) - : undefined - return resolveStartupShell(platform, shell) + return resolveAiVaultResumeStartupShell({ + state: args.state, + worktreeId: args.worktreeId, + platform, + isLocalSession + }) } export function getAiVaultAgentProviderSession( @@ -298,26 +305,3 @@ export function getAiVaultResumePlatform( const workspacePath = getAiVaultResumeWorkspacePath(state, targetWorktreeId) return workspacePath && parseWslUncPath(workspacePath) ? 'linux' : CLIENT_PLATFORM } - -function getAiVaultResumeWorkspacePath( - state: Pick, - worktreeId: string | null | undefined -): string | null { - if (!worktreeId) { - return null - } - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - return ( - state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) - ?.folderPath ?? null - ) - } - const targetWorktreeId = - workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId - return ( - Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null - ) -} diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts new file mode 100644 index 00000000000..d1ab726484e --- /dev/null +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -0,0 +1,72 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime' +import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell' +import { + resolveStartupShell, + type AgentStartupShell +} from '../../../shared/tui-agent-startup-shell' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWslUncPath } from '../../../shared/wsl-paths' + +type AiVaultResumeShellState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +export function resolveAiVaultResumeStartupShell(args: { + state: AiVaultResumeShellState + worktreeId?: string | null + platform: NodeJS.Platform + isLocalSession: boolean +}): AgentStartupShell { + const projectRuntime = + args.platform === 'win32' && args.isLocalSession + ? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM) + : undefined + const workspacePath = getAiVaultResumeWorkspacePath( + args.state, + args.worktreeId ?? args.state.activeWorktreeId + ) + const shellOverride = + args.platform === 'win32' && args.isLocalSession + ? resolveLocalWindowsTerminalShellOverrideForTab({ + explicitShellOverride: undefined, + defaultWindowsShell: args.state.settings?.terminalWindowsShell, + isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)), + projectRuntime + }) + : undefined + const shell = shellOverride ? resolveWindowsShellStartupFamily(shellOverride) : undefined + return resolveStartupShell(args.platform, shell) +} + +export function getAiVaultResumeWorkspacePath( + state: Pick, + worktreeId: string | null | undefined +): string | null { + if (!worktreeId) { + return null + } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + return ( + state.folderWorkspaces.find((workspace) => workspace.id === workspaceScope.folderWorkspaceId) + ?.folderPath ?? null + ) + } + const targetWorktreeId = + workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((candidate) => candidate.id === targetWorktreeId)?.path ?? null + ) +} diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 9eaee5b643f..901bdd192b5 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -98,15 +98,20 @@ describe('launchAiVaultSessionInNewTab', () => { agent: 'claude', worktreeId: 'wt-1', command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", + cwd: 'C:\\Users\\alice\\repo', env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, envToDelete: ['CODEX_HOME'], launchConfig: { agentCommand: "claude '--dangerously-skip-permissions' '--effort' 'max'", agentArgs: '--dangerously-skip-permissions --effort max', agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } - } + }, + providerSession: { key: 'session_id', id: 'session-1' } }) + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + startupCwd: 'C:\\Users\\alice\\repo' + }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', { command: "claude '--dangerously-skip-permissions' '--effort' 'max' '--resume' 'session-1'", env: { ANTHROPIC_BASE_URL: 'https://claude.example.test' }, @@ -117,6 +122,7 @@ describe('launchAiVaultSessionInNewTab', () => { agentEnv: { ANTHROPIC_BASE_URL: 'https://claude.example.test' } }, launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: 'session-1' }, telemetry: { agent_kind: 'claude', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 6e0b9a897c4..799b13ed5bd 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -22,6 +22,7 @@ export function launchAiVaultSessionInNewTab(args: { agent: AiVaultAgent worktreeId: string command: string + cwd?: string env?: Record envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig @@ -40,6 +41,7 @@ export function launchAiVaultSessionInNewTab(args: { agentSessionKind: 'resume', launchAgent: args.agent, command: args.command, + ...(args.cwd ? { cwd: args.cwd } : {}), ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), @@ -66,12 +68,15 @@ export function launchAiVaultSessionInNewTab(args: { targetGroupId } - const tab = store.createTab(args.worktreeId, targetGroupId) + const tab = args.cwd + ? store.createTab(args.worktreeId, targetGroupId, undefined, { startupCwd: args.cwd }) + : store.createTab(args.worktreeId, targetGroupId) store.queueTabStartupCommand(tab.id, { command: args.command, ...(args.env ? { env: args.env } : {}), ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), + ...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), launch_source: 'sidebar', From 6e63bbbb522987f08a83fba676031067faa01d91 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:20:36 -0700 Subject: [PATCH 11/20] fix(computer-use): prevent repeated screen recording prompts (#13427) --- .../Sources/OrcaComputerUseMacOS/main.swift | 40 +--------- .../PermissionTrustSettling.swift | 29 ------- .../ScreenCaptureProbeWindowSelection.swift | 22 ------ ...apturePermissionPreflightSafetyTests.swift | 31 ++++++++ ...ScreenCapturePermissionSettlingTests.swift | 79 ------------------- ...reenCaptureProbeWindowSelectionTests.swift | 44 ----------- 6 files changed, 32 insertions(+), 213 deletions(-) delete mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift delete mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift delete mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 61ee277156d..0c56041ee46 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1044,11 +1044,7 @@ private func screenCaptureTrusted() -> Bool { } private func screenCaptureTrustedSettled() -> Bool { - PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - probe: screenCaptureTrusted, - fallbackProbe: screenCaptureTrustedByCaptureProbe - ) + PermissionTrustSettling.settle(timeoutMs: 2_000, probe: screenCaptureTrusted).settled } private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { @@ -1058,40 +1054,6 @@ private func permissionStatusSnapshotSettled() -> PermissionStatusSnapshot { ) } -private func screenCaptureTrustedByCaptureProbe() -> Bool { - guard let infos = CGWindowListCopyWindowInfo( - [.optionOnScreenOnly], - kCGNullWindowID - ) as? [[String: Any]] else { - return false - } - let windows = infos.compactMap { info -> ScreenCaptureProbeWindow? in - guard let layer = info[kCGWindowLayer as String] as? Int, - let ownerPid = info[kCGWindowOwnerPID as String] as? NSNumber, - let number = info[kCGWindowNumber as String] as? NSNumber - else { - return nil - } - return ScreenCaptureProbeWindow( - layer: layer, - ownerPid: ownerPid.int32Value, - windowId: number.uint32Value - ) - } - guard let windowId = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: ProcessInfo.processInfo.processIdentifier, - windows: windows - ) else { - return false - } - return CGWindowListCreateImage( - .null, - [.optionIncludingWindow], - CGWindowID(windowId), - [.boundsIgnoreFraming] - ) != nil -} - private func requestScreenCaptureAccess() -> Bool { CGRequestScreenCaptureAccess() } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift index 80612664454..0f7d0bd8cf6 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/PermissionTrustSettling.swift @@ -41,33 +41,4 @@ public enum PermissionTrustSettling { waitedMs += interval } } - - public static func settleWithFallback( - initialTimeoutMs: Int = defaultTimeoutMs, - finalTimeoutMs: Int, - intervalMs: Int = defaultIntervalMs, - sleepMs: (Int) -> Void = { interval in - Thread.sleep(forTimeInterval: TimeInterval(interval) / 1_000) - }, - probe: () -> Bool, - fallbackProbe: () -> Bool - ) -> Bool { - if settle( - timeoutMs: initialTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled { - return true - } - if fallbackProbe() { - return true - } - return settle( - timeoutMs: finalTimeoutMs, - intervalMs: intervalMs, - sleepMs: sleepMs, - probe: probe - ).settled - } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift deleted file mode 100644 index 36b4346a8bc..00000000000 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/ScreenCaptureProbeWindowSelection.swift +++ /dev/null @@ -1,22 +0,0 @@ -public struct ScreenCaptureProbeWindow: Equatable { - public let layer: Int - public let ownerPid: Int32 - public let windowId: UInt32 - - public init(layer: Int, ownerPid: Int32, windowId: UInt32) { - self.layer = layer - self.ownerPid = ownerPid - self.windowId = windowId - } -} - -public enum ScreenCaptureProbeWindowSelection { - public static func firstCrossProcessNormalWindow( - ownPid: Int32, - windows: [ScreenCaptureProbeWindow] - ) -> UInt32? { - windows.first { window in - window.layer == 0 && window.ownerPid != ownPid - }?.windowId - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift new file mode 100644 index 00000000000..95deec57a04 --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionPreflightSafetyTests.swift @@ -0,0 +1,31 @@ +import XCTest + +final class ScreenCapturePermissionPreflightSafetyTests: XCTestCase { + func testPermissionPreflightCannotRequestOrCaptureTheScreen() throws { + let source = try computerUseSource() + let start = try XCTUnwrap(source.range(of: "private func screenCaptureTrusted()")) + let end = try XCTUnwrap( + source.range(of: "private func requestScreenCaptureAccess()", range: start.upperBound.. String { + let testFile = URL(fileURLWithPath: #filePath) + let packageRoot = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let mainPath = packageRoot + .appendingPathComponent("Sources") + .appendingPathComponent("OrcaComputerUseMacOS") + .appendingPathComponent("main.swift") + return try String(contentsOf: mainPath, encoding: .utf8) + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift deleted file mode 100644 index d53991d36d0..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCapturePermissionSettlingTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen Capture permission settling") -struct ScreenCapturePermissionSettlingTests { - @Test("initial preflight success skips fallback") - func initialSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - finalTimeoutMs: 500, - sleepMs: { _ in }, - probe: { true }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 0) - } - - @Test("capture fallback can establish trust") - func fallbackSuccess() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 200, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return true - } - ) - - #expect(trusted) - #expect(fallbackCalls == 1) - } - - @Test("preflight is retried after fallback failure") - func finalPreflightSuccess() { - var probeCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 200, - intervalMs: 100, - sleepMs: { _ in }, - probe: { - probeCalls += 1 - return probeCalls == 4 - }, - fallbackProbe: { false } - ) - - #expect(trusted) - #expect(probeCalls == 4) - } - - @Test("persistent denial remains denied") - func persistentDenial() { - var fallbackCalls = 0 - let trusted = PermissionTrustSettling.settleWithFallback( - initialTimeoutMs: 100, - finalTimeoutMs: 100, - intervalMs: 100, - sleepMs: { _ in }, - probe: { false }, - fallbackProbe: { - fallbackCalls += 1 - return false - } - ) - - #expect(!trusted) - #expect(fallbackCalls == 1) - } -} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift deleted file mode 100644 index 96afd6fbccb..00000000000 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/ScreenCaptureProbeWindowSelectionTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Testing -@testable import OrcaComputerUseMacOSCore - -@Suite("Screen capture probe window selection") -struct ScreenCaptureProbeWindowSelectionTests { - @Test("own process windows cannot prove cross-app capture permission") - func excludesOwnWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("non-normal windows are skipped") - func excludesNonNormalWindows() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 1, ownerPid: 84, windowId: 1), - .init(layer: 0, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == 2) - } - - @Test("no cross-process normal window returns nil") - func noCandidate() { - let selected = ScreenCaptureProbeWindowSelection.firstCrossProcessNormalWindow( - ownPid: 42, - windows: [ - .init(layer: 0, ownerPid: 42, windowId: 1), - .init(layer: 1, ownerPid: 84, windowId: 2) - ] - ) - - #expect(selected == nil) - } -} From 9ccec550c35ca44ad106dd3ca699ad952c37a622 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:25:15 -0700 Subject: [PATCH 12/20] Add emoji picker to workspace editing and search (#13429) * Add emoji picker to workspace editing and search * fix(sidebar): scope sticky headers by host * fix: address emoji picker review findings --- .../WorktreeJumpPalette.recent-tabs.test.tsx | 2 + .../components/WorktreeJumpPalette.test.tsx | 48 ++++-- .../src/components/WorktreeJumpPalette.tsx | 25 ++- .../new-workspace/SmartWorkspaceNameField.tsx | 2 +- .../sidebar/WorktreeDisplayNameField.tsx | 79 +++++++++ .../sidebar/WorktreeMetaDialog.test.tsx | 11 ++ .../components/sidebar/WorktreeMetaDialog.tsx | 33 ++-- ...eeTitleInlineRename.begin-editing.test.tsx | 22 +++ .../sidebar/WorktreeTitleInlineRename.tsx | 126 +++++++++----- .../worktree-list-virtual-rows.test.ts | 42 ++++- .../sidebar/worktree-list-virtual-rows.ts | 2 +- .../WorkspaceEmojiSuggestionPopover.tsx | 18 +- .../useWorkspaceEmojiShortcodeInput.test.tsx | 122 ++++++++++++++ .../useWorkspaceEmojiShortcodeInput.ts | 158 ++++++++++++++++++ ...jump-palette-interleaved-sections.test.tsx | 2 + tests/e2e/workspace-emoji-picker.spec.ts | 88 ++++++++++ 16 files changed, 688 insertions(+), 92 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx rename src/renderer/src/components/{new-workspace => workspace-emoji}/WorkspaceEmojiSuggestionPopover.tsx (82%) create mode 100644 src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx create mode 100644 src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts create mode 100644 tests/e2e/workspace-emoji-picker.spec.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 68de1317688..e263a0db926 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -69,6 +69,8 @@ vi.mock('@/lib/workspace-tab-palette-activation', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, // Why the commandProps passthrough: cmdk resolves Enter against its `value`, so the controlled // value is the only honest stand-in for "what would Enter activate" without mounting real cmdk. CommandDialog: ({ diff --git a/src/renderer/src/components/WorktreeJumpPalette.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.test.tsx index cd1149f7224..47215a8c941 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.test.tsx @@ -2,6 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { fireEvent } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as ReactI18Next from 'react-i18next' import { useAppStore } from '@/store' @@ -74,25 +75,40 @@ vi.mock('@/components/ui/command', async () => { ) : null }, - CommandInput: ({ - value, - onValueChange, - placeholder - }: { - value?: string - onValueChange?: (next: string) => void - placeholder?: string - }) => { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandInput: React.forwardRef(function CommandInput( + { + value, + onValueChange, + placeholder, + onClick, + onSelect, + onKeyDown + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + onClick?: React.MouseEventHandler + onSelect?: React.ReactEventHandler + onKeyDown?: React.KeyboardEventHandler + }, + ref: React.ForwardedRef + ) { setCommandQuery = onValueChange ?? null return ( onValueChange?.(event.currentTarget.value)} + onClick={onClick} + onSelect={onSelect} + onKeyDown={onKeyDown} /> ) - }, + }), CommandList: React.forwardRef(function CommandList( { children }: { children: React.ReactNode }, ref: React.ForwardedRef @@ -337,4 +353,16 @@ describe('WorktreeJumpPalette', () => { expect(testContainer.textContent).toContain('Feature workspace') }) + + it('replaces a completed emoji shortcode in the search query', async () => { + await renderPalette({ worktreesByRepo: { 'repo-1': [] } }) + const input = testContainer.querySelector('[data-command-input="true"]') + expect(input).not.toBeNull() + + await act(async () => { + fireEvent.change(input!, { target: { value: ':wink:', selectionStart: 6 } }) + }) + + expect(input?.value).toBe('😉') + }) }) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 89068eb489b..a8adbec77a9 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -169,6 +169,8 @@ import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-foc import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, @@ -1893,6 +1895,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { setSelectedItemId('') listRef.current?.scrollTo(0, 0) }, []) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: handleQueryChange, + value: query + }) const cancelFallbackFocusFrames = useCallback((): void => { if (fallbackFocusOuterFrameRef.current !== null) { @@ -2455,7 +2462,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { 'Search chats, terminals, worktrees, settings, and actions...' )} value={query} - onValueChange={handleQueryChange} + onValueChange={emojiInput.handleValueChange} + onClick={(event) => emojiInput.syncCursor(event.currentTarget)} + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => emojiInput.handleKeyDown(event)} wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]" iconClassName="mr-2.5 h-4 w-4 text-muted-foreground/60" className="h-12 text-[14px] placeholder:text-muted-foreground/75" @@ -2471,6 +2481,19 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } /> + !open && emojiInput.close()} + portalContainer={dialogElement} + side="bottom" + contentClassName="w-80" + /> ['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] diff --git a/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx new file mode 100644 index 00000000000..715c9976e17 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDisplayNameField.tsx @@ -0,0 +1,79 @@ +import { useId, type RefObject } from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' +import { translate } from '@/i18n/i18n' + +type WorktreeDisplayNameFieldProps = { + disabled: boolean + inputRef: RefObject + onEnter: () => void | Promise + onValueChange: (value: string) => void + portalContainer: HTMLElement | null + value: string +} + +export function WorktreeDisplayNameField({ + disabled, + inputRef, + onEnter, + onValueChange, + portalContainer, + value +}: WorktreeDisplayNameFieldProps): React.JSX.Element { + const inputId = useId() + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled, + inputRef, + onValueChange, + value + }) + + return ( +
+ + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (emojiInput.handleKeyDown(event) || event.key !== 'Enter') { + return + } + event.preventDefault() + void onEnter() + }} + placeholder={translate( + 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', + 'Custom display name...' + )} + className="h-8 text-xs" + /> + !open && emojiInput.close()} + portalContainer={portalContainer} + side="bottom" + /> +

+ {translate( + 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', + 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx index b45585a4628..66de27601e5 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -225,6 +225,17 @@ describe('WorktreeMetaDialog issue link row', () => { expect(issueInput().value).toBe('42') }) + it('replaces a completed emoji shortcode in the display name', () => { + openDialog() + const displayNameInput = screen.getByRole('textbox', { name: 'Display Name' }) + + fireEvent.change(displayNameInput, { + target: { value: 'Feature :wink:', selectionStart: 14 } + }) + + expect((displayNameInput as HTMLInputElement).value).toBe('Feature 😉') + }) + it('seeds the chip and value from a Linear link', () => { openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 4445520fd72..0482a644221 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -30,6 +30,7 @@ import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input' +import { WorktreeDisplayNameField } from './WorktreeDisplayNameField' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' @@ -93,6 +94,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT) + const [dialogElement, setDialogElement] = useState(null) const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ worktreeId, @@ -282,6 +284,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { return ( { e.preventDefault() @@ -312,28 +315,14 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
-
- - setDisplayNameInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.7f21e0464f', - 'Custom display name...' - )} - className="h-8 text-xs" - /> -

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.459ad7f650', - 'Only changes the name shown in the sidebar — the folder on disk stays the same. Leave blank to use the branch or folder name.' - )} -

-
+ ({ } })) +vi.mock('@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover', () => ({ + WorkspaceEmojiSuggestionPopover: () => null +})) + +vi.mock('@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput', () => ({ + useWorkspaceEmojiShortcodeInput: ({ + onValueChange + }: { + onValueChange: (value: string) => void + }) => ({ + close: vi.fn(), + commandValue: '', + handleKeyDown: () => false, + handleValueChange: (value: string) => onValueChange(value), + onCommandValueChange: vi.fn(), + open: false, + selectSuggestion: vi.fn(), + suggestions: [], + syncCursor: vi.fn() + }) +})) + type ReactElementLike = { type: unknown props: Record diff --git a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx index 679460541a0..ec670c3c506 100644 --- a/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx +++ b/src/renderer/src/components/sidebar/WorktreeTitleInlineRename.tsx @@ -3,6 +3,8 @@ import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { WorkspaceEmojiSuggestionPopover } from '@/components/workspace-emoji/WorkspaceEmojiSuggestionPopover' +import { useWorkspaceEmojiShortcodeInput } from '@/components/workspace-emoji/useWorkspaceEmojiShortcodeInput' import { cn } from '@/lib/utils' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' @@ -68,10 +70,17 @@ export function WorktreeTitleInlineRename({ const titleElementRef = useRef(null) const titleResizeObserverRef = useRef(null) const removeTitleResizeListenerRef = useRef<(() => void) | null>(null) + const inputElementRef = useRef(null) const [editing, setEditing] = useState(false) const [value, setValue] = useState(displayName) const [saving, setSaving] = useState(false) const [titleTruncated, setTitleTruncated] = useState(false) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + disabled: saving, + inputRef: inputElementRef, + onValueChange: setValue, + value + }) const measureTitleTruncated = useCallback((element: HTMLSpanElement | null) => { const nextTruncated = element ? isWorktreeTitleTruncated(element) : false @@ -141,6 +150,7 @@ export function WorktreeTitleInlineRename({ ) const handleInputRef = useCallback((input: HTMLInputElement | null) => { + inputElementRef.current = input if (!input) { return } @@ -182,9 +192,10 @@ export function WorktreeTitleInlineRename({ ) const cancelRename = useCallback(() => { + emojiInput.close() setValue(displayName) setEditingMode(false) - }, [displayName, setEditingMode]) + }, [displayName, emojiInput, setEditingMode]) const commitRename = useCallback(async () => { if (savingRef.current) { @@ -226,6 +237,9 @@ export function WorktreeTitleInlineRename({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { event.stopPropagation() + if (emojiInput.handleKeyDown(event)) { + return + } // Why: an Enter that only confirms a CJK IME candidate must not commit the // rename; wait for a non-composition Enter. if (isImeCompositionKeyDown(event)) { @@ -239,61 +253,81 @@ export function WorktreeTitleInlineRename({ cancelRename() } }, - [cancelRename, commitRename] + [cancelRename, commitRename, emojiInput] ) if (editing) { return ( - + <> - setValue(event.target.value)} - onBlur={() => void commitRename()} - onClick={stopCardEvent} - onDoubleClick={stopCardEvent} - onPointerDown={stopCardEvent} - onKeyDown={handleKeyDown} + key={`editing:${titleElementKey}`} + ref={handleRootRef} className={cn( - 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', - editingInputClassName, - saving && savingInputClassName, - inputClassName + 'relative grid min-w-0 truncate leading-tight text-foreground', + showUnreadEmphasis ? 'font-semibold' : 'font-normal', + className, + editingClassName )} - /> - {saving ? ( - + + + emojiInput.handleValueChange(event.target.value, event.target.selectionStart) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onBlur={() => void commitRename()} + onClick={stopCardEvent} + onDoubleClick={stopCardEvent} + onPointerDown={stopCardEvent} + onKeyDown={handleKeyDown} className={cn( - 'pointer-events-none absolute top-1/2 size-3 -translate-y-1/2 animate-spin text-muted-foreground', - savingSpinnerClassName + 'col-start-1 row-start-1 min-w-0 select-text truncate text-foreground outline-none', + editingInputClassName, + saving && savingInputClassName, + inputClassName )} /> - ) : null} - + {saving ? ( + + ) : null} + + !open && emojiInput.close()} + side="right" + contentClassName="w-56" + /> + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts index 04d2857ed64..67b6147765e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { HOST_STICKY_PINNED_HEIGHT, buildLineageRowRekeyMap, @@ -11,11 +12,11 @@ import { type RenderRow } from './worktree-list-virtual-rows' -function hostRow(hostId: string): RenderRow { +function hostRow(hostId: ExecutionHostId): RenderRow { return { type: 'host-header', key: `host:${hostId}`, - hostId: hostId as never, + hostId, kind: 'ssh', label: hostId, detail: 'SSH', @@ -25,8 +26,15 @@ function hostRow(hostId: string): RenderRow { } } -function groupRow(key: string): RenderRow { - return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' } +function groupRow(key: string, hostId?: ExecutionHostId): RenderRow { + return { + type: 'header', + key, + label: key, + count: 1, + tone: 'text-foreground', + ...(hostId ? { hostId } : {}) + } } function itemStub(id: string): RenderRow { @@ -39,11 +47,11 @@ function virtualItem(index: number, start: number): VirtualItem { // rows: [host-a, group-a1, item, item, host-b, group-b1, item] const rows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), itemStub('wt-2'), - hostRow('b'), + hostRow('ssh:b'), groupRow('b1'), itemStub('wt-3') ] @@ -51,6 +59,23 @@ const stickyHeaderIndexes = getStickyHeaderIndexes(rows) // Geometry: each row 100px tall for easy math. const virtualItems = rows.map((_, index) => virtualItem(index, index * 100)) +describe('getRenderRowKey', () => { + it('scopes repeated group headers to their host section', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'local'))).toBe( + 'hdr:local:workspace-status:in-progress' + ) + expect(getRenderRowKey(groupRow('workspace-status:in-progress', 'ssh:builder'))).toBe( + 'hdr:ssh:builder:workspace-status:in-progress' + ) + }) + + it('preserves unsectioned group header keys', () => { + expect(getRenderRowKey(groupRow('workspace-status:in-progress'))).toBe( + 'hdr:workspace-status:in-progress' + ) + }) +}) + describe('getActiveStickyIndexesForScroll', () => { it('pins the host and its inner group while scrolled inside a section', () => { expect( @@ -149,7 +174,7 @@ describe('getActiveStickyIndexesForScroll', () => { it('keeps the previous mounted Project sticky when the next group is unmounted', () => { const multiGroupRows: RenderRow[] = [ - hostRow('a'), + hostRow('ssh:a'), groupRow('a1'), itemStub('wt-1'), groupRow('a2'), @@ -263,7 +288,8 @@ describe('buildLineageRowRekeyMap', () => { it('contributes nothing for non-lineage row types', () => { expect( - buildLineageRowRekeyMap([hostRow('a'), groupRow('a1'), hostRow('b'), groupRow('b1')]).size + buildLineageRowRekeyMap([hostRow('ssh:a'), groupRow('a1'), hostRow('ssh:b'), groupRow('b1')]) + .size ).toBe(0) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index 1ec2e07a8a1..69b8c71c010 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -20,7 +20,7 @@ export function getRenderRowKey(row: RenderRow): string { return `host:${row.hostId}` } if (row.type === 'header') { - return `hdr:${row.key}` + return row.hostId ? `hdr:${row.hostId}:${row.key}` : `hdr:${row.key}` } if (row.type === 'lineage-group') { return `lineage-group:${row.key}` diff --git a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx similarity index 82% rename from src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx rename to src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx index 209fd72311c..7497f080a75 100644 --- a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx +++ b/src/renderer/src/components/workspace-emoji/WorkspaceEmojiSuggestionPopover.tsx @@ -1,27 +1,34 @@ -import type { RefObject } from 'react' +import type { ComponentProps, RefObject } from 'react' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes' type WorkspaceEmojiSuggestionPopoverProps = { anchorRef: RefObject commandValue: string + contentClassName?: string heading: string onCommandValueChange: (value: string) => void onOpenChange: (open: boolean) => void onSelect: (suggestion: WorkspaceEmojiSuggestion) => void open: boolean + portalContainer?: HTMLElement | null + side?: ComponentProps['side'] suggestions: readonly WorkspaceEmojiSuggestion[] } export function WorkspaceEmojiSuggestionPopover({ anchorRef, commandValue, + contentClassName, heading, onCommandValueChange, onOpenChange, onSelect, open, + portalContainer, + side = 'top', suggestions }: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element { return ( @@ -30,11 +37,16 @@ export function WorkspaceEmojiSuggestionPopover({ event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} onPointerDownOutside={(event) => { if (anchorRef.current?.contains(event.target as Node)) { event.preventDefault() diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx new file mode 100644 index 00000000000..d750bd3df9a --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom + +import { useRef, useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useWorkspaceEmojiShortcodeInput } from './useWorkspaceEmojiShortcodeInput' + +function EmojiInputHarness({ + initialValue, + onUnhandledKeyDown +}: { + initialValue: string + onUnhandledKeyDown?: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + const emojiInput = useWorkspaceEmojiShortcodeInput({ + inputRef, + onValueChange: setValue, + value + }) + + return ( +
+ + emojiInput.handleValueChange( + event.currentTarget.value, + event.currentTarget.selectionStart + ) + } + onSelect={(event) => emojiInput.syncCursor(event.currentTarget)} + onKeyDown={(event) => { + if (!emojiInput.handleKeyDown(event)) { + onUnhandledKeyDown?.(event) + } + }} + /> + {emojiInput.suggestions.map((suggestion) => ( + + ))} +
+ ) +} + +describe('useWorkspaceEmojiShortcodeInput', () => { + afterEach(cleanup) + + it('replaces a completed shortcode without discarding trailing text', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { + target: { value: 'Launch :wink: experiment', selectionStart: 13 } + }) + + expect((input as HTMLInputElement).value).toBe('Launch 😉 experiment') + }) + + it('opens suggestions and accepts the highlighted emoji with Enter', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + + expect(screen.getByText(':wink:')).toBeTruthy() + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('true') + fireEvent.keyDown(input, { key: 'Enter' }) + expect((input as HTMLInputElement).value).toBe('Launch 😉 ') + }) + + it('dismisses suggestions with Escape without clearing the query', () => { + render() + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + fireEvent.change(input, { target: { value: 'Launch :wink', selectionStart: 12 } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect((input as HTMLInputElement).value).toBe('Launch :wink') + expect(input.parentElement?.dataset.emojiMenuOpen).toBe('false') + }) + + it('leaves composing arrow navigation to the IME', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'ArrowDown', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + }) + + it('does not accept or submit on a composing Enter', () => { + const onUnhandledKeyDown = vi.fn() + render( + + ) + const input = screen.getByRole('textbox', { name: 'Workspace name' }) + + ;(input as HTMLInputElement).setSelectionRange(12, 12) + fireEvent.select(input) + const handled = fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + + expect(handled).toBe(true) + expect(onUnhandledKeyDown).not.toHaveBeenCalled() + expect((input as HTMLInputElement).value).toBe('Launch :wink') + }) +}) diff --git a/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts new file mode 100644 index 00000000000..567e3ed8bfb --- /dev/null +++ b/src/renderer/src/components/workspace-emoji/useWorkspaceEmojiShortcodeInput.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes, + type WorkspaceEmojiReplacement, + type WorkspaceEmojiSuggestion +} from '@/lib/workspace-emoji-shortcodes' + +type WorkspaceEmojiShortcodeInputOptions = { + disabled?: boolean + inputRef: RefObject + onValueChange: (value: string) => void + value: string +} + +export function useWorkspaceEmojiShortcodeInput({ + disabled = false, + inputRef, + onValueChange, + value +}: WorkspaceEmojiShortcodeInputOptions) { + const [cursor, setCursor] = useState(null) + const [commandValue, setCommandValue] = useState('') + const focusFrameRef = useRef(null) + const activeShortcode = useMemo( + () => getActiveWorkspaceEmojiShortcode(value, cursor), + [cursor, value] + ) + const suggestions = useMemo( + () => (activeShortcode ? searchWorkspaceEmojiShortcodes(activeShortcode.query) : []), + [activeShortcode] + ) + const open = !disabled && activeShortcode !== null && suggestions.length > 0 + const resolvedCommandValue = suggestions.some( + (suggestion) => `emoji:${suggestion.shortcode}` === commandValue + ) + ? commandValue + : suggestions[0] + ? `emoji:${suggestions[0].shortcode}` + : '' + const selectedSuggestion = + suggestions.find((suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue) ?? + null + + const cancelFocusFrame = useCallback(() => { + if (focusFrameRef.current !== null) { + cancelAnimationFrame(focusFrameRef.current) + focusFrameRef.current = null + } + }, []) + + useEffect(() => cancelFocusFrame, [cancelFocusFrame]) + + const applyReplacement = useCallback( + (replacement: WorkspaceEmojiReplacement) => { + onValueChange(replacement.value) + setCursor(null) + cancelFocusFrame() + focusFrameRef.current = requestAnimationFrame(() => { + focusFrameRef.current = null + inputRef.current?.focus({ preventScroll: true }) + inputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor) + }) + }, + [cancelFocusFrame, inputRef, onValueChange] + ) + + const handleValueChange = useCallback( + ( + nextValue: string, + nextCursor: number | null = inputRef.current?.selectionStart ?? nextValue.length + ) => { + const completedEmoji = replaceCompletedWorkspaceEmojiShortcode(nextValue, nextCursor) + if (completedEmoji) { + applyReplacement(completedEmoji) + return + } + onValueChange(nextValue) + setCursor(nextCursor) + }, + [applyReplacement, inputRef, onValueChange] + ) + + const syncCursor = useCallback( + (input = inputRef.current) => setCursor(input?.selectionStart ?? null), + [inputRef] + ) + + const close = useCallback(() => setCursor(null), []) + + const selectSuggestion = useCallback( + (suggestion: WorkspaceEmojiSuggestion) => { + if (!activeShortcode) { + return + } + applyReplacement(applyWorkspaceEmojiSuggestion(value, activeShortcode, suggestion)) + }, + [activeShortcode, applyReplacement, value] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): boolean => { + if (!open) { + return false + } + if (isImeCompositionKeyDown(event)) { + event.stopPropagation() + return true + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + const selectedIndex = suggestions.findIndex( + (suggestion) => `emoji:${suggestion.shortcode}` === resolvedCommandValue + ) + const direction = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = (selectedIndex + direction + suggestions.length) % suggestions.length + setCommandValue(`emoji:${suggestions[nextIndex].shortcode}`) + return true + } + const acceptsSuggestion = + (event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey) || + (event.key === 'Tab' && !event.shiftKey) + if (acceptsSuggestion && selectedSuggestion) { + event.preventDefault() + event.stopPropagation() + selectSuggestion(selectedSuggestion) + return true + } + if (event.key === 'Escape') { + event.stopPropagation() + close() + return true + } + return false + }, + [close, open, resolvedCommandValue, selectSuggestion, selectedSuggestion, suggestions] + ) + + return { + close, + commandValue: resolvedCommandValue, + handleKeyDown, + handleValueChange, + onCommandValueChange: setCommandValue, + open, + selectSuggestion, + suggestions, + syncCursor + } +} diff --git a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx index 95b1db5e5ab..aad685c5666 100644 --- a/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-interleaved-sections.test.tsx @@ -52,6 +52,8 @@ vi.mock('@/components/cmd-j/palette-host-badge', () => ({ vi.mock('@/components/ui/command', async () => { const React = await import('react') return { + Command: ({ children }: { children: React.ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => open ?
{children}
: null, CommandInput: ({ diff --git a/tests/e2e/workspace-emoji-picker.spec.ts b/tests/e2e/workspace-emoji-picker.spec.ts new file mode 100644 index 00000000000..f16499a22bc --- /dev/null +++ b/tests/e2e/workspace-emoji-picker.spec.ts @@ -0,0 +1,88 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +async function captureProof(page: Page, testInfo: TestInfo, name: string): Promise { + if (process.env.ORCA_E2E_RECORD_VIDEO === '1') { + return + } + const screenshotPath = testInfo.outputPath(name) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Workspace emoji picker', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await orcaPage.waitForTimeout(750) + }) + + test('inserts emoji in sidebar rename, worktree details, and Cmd+J', async ({ + orcaPage + }, testInfo) => { + const title = orcaPage.locator('[data-worktree-title-inline-rename=""]').first() + await expect(title).toBeVisible() + await title.dblclick() + + const inlineInput = orcaPage.locator('[data-worktree-title-rename-input="true"]') + await expect(inlineInput).toBeVisible() + await inlineInput.fill('Sidebar proof') + await captureProof(orcaPage, testInfo, 'sidebar-rename-before.png') + await inlineInput.pressSequentially(' :wink', { delay: 60 }) + const inlineSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') + await expect(inlineSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'sidebar-rename-picker.png') + await inlineInput.press('Enter') + await expect(inlineInput).toHaveValue('Sidebar proof 😉 ') + await inlineInput.press('Enter') + await expect(orcaPage.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((candidate) => candidate.id === state.activeWorktreeId) + if (!worktree) { + throw new Error('Active worktree not found') + } + state.openModal('edit-meta', { + worktreeId: worktree.id, + repoId: worktree.repoId, + currentDisplayName: worktree.displayName, + currentComment: worktree.comment, + focus: 'displayName' + }) + }) + + const detailsDialog = orcaPage.getByRole('dialog', { name: 'Edit Worktree Details' }) + const displayNameInput = detailsDialog.getByPlaceholder('Custom display name...') + await expect(displayNameInput).toBeFocused() + await displayNameInput.fill('Details proof') + await captureProof(orcaPage, testInfo, 'worktree-details-before.png') + await displayNameInput.pressSequentially(' :wink', { delay: 60 }) + const detailsSuggestions = detailsDialog.locator('[data-workspace-emoji-suggestions="true"]') + await expect(detailsSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'worktree-details-picker.png') + await displayNameInput.press('Enter') + await expect(displayNameInput).toHaveValue('Details proof 😉 ') + await detailsDialog.getByRole('button', { name: 'Cancel' }).click() + + await orcaPage.evaluate(() => window.__store!.getState().openModal('worktree-palette')) + const palette = orcaPage.getByRole('dialog', { name: 'Jump to...' }) + const paletteInput = palette.getByPlaceholder( + 'Search chats, terminals, worktrees, settings, and actions...' + ) + await expect(paletteInput).toBeFocused() + await captureProof(orcaPage, testInfo, 'cmd-j-before.png') + await paletteInput.pressSequentially(':wink', { delay: 60 }) + const paletteSuggestions = palette.locator('[data-workspace-emoji-suggestions="true"]') + await expect(paletteSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await captureProof(orcaPage, testInfo, 'cmd-j-picker.png') + await paletteInput.press('Enter') + await expect(paletteInput).toHaveValue('😉 ') + await expect(palette.getByText('Sidebar proof 😉', { exact: true }).first()).toBeVisible() + await orcaPage.waitForTimeout(750) + }) +}) From 4cca31f36fa5980d14046b2aea180e5d62577a3c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:21:18 -0700 Subject: [PATCH 13/20] fix(quick-open): compact Cmd+P file rows (#13454) --- src/renderer/src/components/QuickOpen.tsx | 6 ++++-- tests/e2e/quick-open-file-paths.spec.ts | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index ae46f4dc0a7..05e2613ce0d 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -119,6 +119,7 @@ export default function QuickOpen(): React.JSX.Element | null { placeholder={translate('auto.components.QuickOpen.1cb6ef47b7', 'Go to file...')} value={query} onValueChange={setQuery} + className="!h-9 !py-2" /> {loading ? ( @@ -155,13 +156,14 @@ export default function QuickOpen(): React.JSX.Element | null { key={item.path} value={item.path} onSelect={() => handleSelect(item.path)} - className="min-w-0 p-0" + // Why: CommandDialog's descendant rule otherwise adds 24px of vertical padding. + className="min-w-0 !p-0" > {/* Why: the trigger is this inner element, not the CommandItem. cmdk sets its own onPointerMove after spreading props, which drops the one Radix needs to open the tooltip. */} -
+
{/* shrink-0 + max-w-full: the directory gives up all of its width before the filename loses a character. */} diff --git a/tests/e2e/quick-open-file-paths.spec.ts b/tests/e2e/quick-open-file-paths.spec.ts index 879320e4e67..bb9703bc5cb 100644 --- a/tests/e2e/quick-open-file-paths.spec.ts +++ b/tests/e2e/quick-open-file-paths.spec.ts @@ -25,12 +25,18 @@ test('cmd+p quick open prioritizes the filename and reveals the full path on hov }) const dialog = orcaPage.getByRole('dialog', { name: 'Go to file' }) await expect(dialog).toBeVisible() + const inputBox = await dialog.locator('[data-cmdk-input-wrapper]').boundingBox() + expect(inputBox).not.toBeNull() + expect(inputBox!.height).toBeLessThanOrEqual(45) const input = dialog.locator('input[placeholder="Go to file..."]') await input.fill('QuickOpenTarget') const row = dialog.getByRole('option').filter({ hasText: 'QuickOpenTarget.tsx' }).first() await expect(row).toBeVisible() await expect(row).toContainText('packages/orca/src/renderer/src/components/navigation/') + const rowBox = await row.boundingBox() + expect(rowBox).not.toBeNull() + expect(rowBox!.height).toBeLessThanOrEqual(29) const rowText = await row.textContent() expect(rowText?.indexOf('QuickOpenTarget.tsx')).toBeLessThan( rowText?.indexOf('packages/orca/src/renderer/src/components/navigation/') ?? -1 From d9e84d88ef04341d3f91db9bec16b277e3cfce4b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:39:24 -0700 Subject: [PATCH 14/20] fix(persistence): salvage corrupt workspace session entries (#13431) --- src/main/persistence.test.ts | 162 ++++++- src/main/persistence.ts | 55 ++- src/renderer/src/store/slices/browser.ts | 34 +- .../slices/store-session-cascades.test.ts | 39 ++ .../slices/tab-group-reference-repair.ts | 78 ++++ .../tabs-hydration-group-validation.test.ts | 118 +++++ .../src/store/slices/tabs-hydration.ts | 12 +- .../workspace-session-browser-schema.ts | 83 ++++ src/shared/workspace-session-salvage.test.ts | 427 ++++++++++++++++++ src/shared/workspace-session-salvage.ts | 34 ++ src/shared/workspace-session-schema.test.ts | 56 ++- src/shared/workspace-session-schema.ts | 329 +++++++------- .../workspace-session-sleeping-agents.ts | 26 +- .../workspace-session-terminal-schema.test.ts | 7 +- src/shared/zod-salvage.ts | 132 ++++++ ...kspace-session-corrupt-tab-salvage.spec.ts | 137 ++++++ 16 files changed, 1505 insertions(+), 224 deletions(-) create mode 100644 src/renderer/src/store/slices/tab-group-reference-repair.ts create mode 100644 src/shared/workspace-session-browser-schema.ts create mode 100644 src/shared/workspace-session-salvage.test.ts create mode 100644 src/shared/workspace-session-salvage.ts create mode 100644 src/shared/zod-salvage.ts create mode 100644 tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index b59ca6f5986..0e507090c1f 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -12142,21 +12142,177 @@ describe('Store host-partitioned workspace sessions', () => { expect(session.terminalTopologyRevisionByRepoId?.['repo-gone']).toBeUndefined() }) - it('drops a corrupt host partition to defaults without failing the others', async () => { + it('resets only the corrupt required field of a host partition, not the partition', async () => { + const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. - 'runtime:bad': { ...makeHostSession('x'), activeRepoId: 123 } + 'runtime:bad': { + ...makeHostSession('x'), + activeRepoId: 123, + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'bad-host-tab', worktreeId })] } + } } }) const store = await createStore() expect(store.getWorkspaceSession('runtime:good').activeRepoId).toBe('good-repo') - // Bad partition collapses to defaults rather than poisoning the map. + // The unsalvageable field falls back to its default; the partition's tabs survive. expect(store.getWorkspaceSession('runtime:bad').activeRepoId).toBeNull() + expect( + store.getWorkspaceSession('runtime:bad').tabsByWorktree[worktreeId]?.map((tab) => tab.id) + ).toEqual(['bad-host-tab']) + }) + + it('keeps every other worktree when the local session has a corrupt required field', async () => { + const worktreeId = 'repo-1::/worktree' + writeDataFile({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + // A projected/truncated write can leave a top-level field the wrong type; + // that must not cost every worktree's tabs the way a full reset did. + activeTabId: 42, + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: 'local-keep', worktreeId })] + } + } + }) + + const store = await createStore() + + const session = store.getWorkspaceSession('local') + expect(session.activeTabId).toBeNull() + expect(session.tabsByWorktree[worktreeId]?.map((tab) => tab.id)).toEqual(['local-keep']) + }) + + type PersistedSessionsFile = { + workspaceSession?: { + tabsByWorktree?: Record + sleepingAgentSessionsByPaneKey?: Record + } + workspaceSessionsByHostId?: Record< + string, + { tabsByWorktree?: Record } + > + } + + // Why: flush() writes whatever the state hash says is dirty, so it passes even + // when nothing scheduled a save — it cannot see the repair write at all. Loading + // once first canonicalizes the profile (a second load of a canonical file + // schedules nothing), so a later rewrite proves the salvage scheduled it. + async function loadAndAwaitScheduledSave(): Promise { + vi.useFakeTimers() + try { + const store = await createStore() + vi.advanceTimersByTime(10_000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + } + + async function canonicalize(fixture: Record): Promise { + writeDataFile(fixture) + await loadAndAwaitScheduledSave() + const canonical = readFileSync(dataFile(), 'utf-8') + // Why: the save assertions below are only meaningful if a clean load schedules + // nothing. Prove that here rather than assume it — a future migration that + // dirtied every load would otherwise leave those tests silently vacuous. + await loadAndAwaitScheduledSave() + expect(readFileSync(dataFile(), 'utf-8')).toBe(canonical) + return JSON.parse(canonical) as PersistedSessionsFile + } + + it('schedules a save for a salvaged local session instead of re-salvaging every launch', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } + } + }) + const tabs = profile.workspaceSession?.tabsByWorktree?.[worktreeId] + expect(tabs).toBeDefined() + tabs!.push({ id: 'tab-corrupt' }) + writeDataFile(profile) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await loadAndAwaitScheduledSave() + expect(warn).toHaveBeenCalledWith( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + { count: 1, fields: ['tabsByWorktree'], detailsTruncated: false } + ) + expect(JSON.stringify(warn.mock.calls)).not.toContain(worktreeId) + } finally { + warn.mockRestore() + } + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual([ + 'tab-keep' + ]) + }) + + it('schedules a save for salvaged host partitions', async () => { + const worktreeId = 'repo-1::/worktree' + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSessionsByHostId: { + 'runtime:env-a': { + ...makeHostSession('runtime-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'runtime-keep', worktreeId })] } + }, + 'ssh:target-b': { + ...makeHostSession('ssh-repo'), + tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'ssh-keep', worktreeId })] } + } + } + }) + const partitions = profile.workspaceSessionsByHostId + const runtimeTabs = partitions?.['runtime:env-a']?.tabsByWorktree?.[worktreeId] + const sshTabs = partitions?.['ssh:target-b']?.tabsByWorktree?.[worktreeId] + expect(runtimeTabs).toBeDefined() + expect(sshTabs).toBeDefined() + runtimeTabs!.push({ id: 'runtime-corrupt' }) + sshTabs!.push({ id: 'ssh-corrupt' }) + const mutablePartitions = partitions as Record + mutablePartitions['runtime:broken'] = 'not a session' + writeDataFile(profile) + await loadAndAwaitScheduledSave() + + const persisted = (readDataFile() as PersistedSessionsFile).workspaceSessionsByHostId + expect( + persisted?.['runtime:env-a']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id) + ).toEqual(['runtime-keep']) + expect(persisted?.['ssh:target-b']?.tabsByWorktree?.[worktreeId]?.map((tab) => tab.id)).toEqual( + ['ssh-keep'] + ) + expect(persisted).not.toHaveProperty('runtime:broken') + }) + + it('writes back sleeping-agent records dropped during salvage', async () => { + const profile = await canonicalize({ + schemaVersion: 1, + workspaceSession: { + ...makeHostSession('local-repo'), + sleepingAgentSessionsByPaneKey: {} + } + }) + profile.workspaceSession!.sleepingAgentSessionsByPaneKey = { + 'tab-bad:leaf': { paneKey: 'different:leaf' } + } + writeDataFile(profile) + + await loadAndAwaitScheduledSave() + + const persisted = readDataFile() as PersistedSessionsFile + expect(persisted.workspaceSession?.sleepingAgentSessionsByPaneKey).toBeUndefined() }) }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 12856638d29..c2e36d3ce9d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -134,7 +134,7 @@ import { ONBOARDING_FLOW_VERSION, ONBOARDING_FINAL_STEP } from '../shared/constants' -import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage' import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' @@ -555,15 +555,27 @@ function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatc ) } +function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ function parseWorkspaceSessionsByHostId( raw: unknown, defaults: WorkspaceSessionState -): Partial> { +): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return {} + return { partitions: {}, repaired: raw !== undefined } } + let repaired = false const partitions: Partial> = {} for (const [key, value] of Object.entries(raw as Record)) { const hostId = normalizeExecutionHostId(key) @@ -571,17 +583,25 @@ function parseWorkspaceSessionsByHostId( if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { continue } - const result = parseWorkspaceSession(value) + const result = parseWorkspaceSessionSalvaging(value) if (!result.ok) { + repaired = true console.error( `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, result.error ) continue } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } partitions[hostId] = { ...defaults, ...result.value } } - return partitions + return { partitions, repaired } } function backupPath(dataFile: string, index: number): string { @@ -3651,7 +3671,7 @@ export class Store { if (parsed.workspaceSession === undefined) { return defaults.workspaceSession } - const result = parseWorkspaceSession(parsed.workspaceSession) + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) if (!result.ok) { console.error( '[persistence] Corrupt workspace session, using defaults:', @@ -3659,13 +3679,28 @@ export class Store { ) return defaults.workspaceSession } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } return { ...defaults.workspaceSession, ...result.value } })(), // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ), + workspaceSessionsByHostId: (() => { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + this.loadNeedsSave = true + } + return partitions + })(), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter( diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 787fd320920..78a7a31b358 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -1651,21 +1651,25 @@ export const createBrowserSlice: StateCreator = } const hydratedTabs: BrowserWorkspace[] = [] for (const tab of tabs) { - const persistedPages = persistedPagesByWorkspace[tab.id] ?? [ - { - id: createBrowserUuid(), - workspaceId: tab.id, - worktreeId, - url: normalizeUrl(tab.url), - title: tab.title, - loading: false, - faviconUrl: tab.faviconUrl ?? null, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward, - loadError: tab.loadError ?? null, - createdAt: tab.createdAt - } satisfies BrowserPage - ] + // Salvage can leave an empty page array; hydrate it like a missing array. + const storedPages = persistedPagesByWorkspace[tab.id] + const persistedPages = storedPages?.length + ? storedPages + : [ + { + id: createBrowserUuid(), + workspaceId: tab.id, + worktreeId, + url: normalizeUrl(tab.url), + title: tab.title, + loading: false, + faviconUrl: tab.faviconUrl ?? null, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + loadError: tab.loadError ?? null, + createdAt: tab.createdAt + } satisfies BrowserPage + ] const nextPages = persistedPages.map((page) => { // Why: in-memory hydration callers can bypass the persistence schema's unknown-key stripping. const { allowWindowClose: _legacyAllowWindowClose, ...persistedPage } = diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 3d9a759e1d2..06cfa17bfc8 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -724,6 +724,45 @@ describe('hydrateBrowserSession', () => { expect(s.activeBrowserTabId).toBe('browser-1') }) + it('synthesizes a page for a browser workspace whose persisted page list is empty', () => { + // Why: session salvage drops a corrupt page by rebuilding the array, so the + // key survives holding []. Treating that as "has pages" restores a workspace + // with no page at all — a dead about:blank tab nothing prunes or reloads. + const store = createTestStore() + const validWt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: validWt + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: validWt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [validWt]: [ + makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' }) + ] + }, + browserPagesByWorkspace: { 'browser-1': [] } + }) + + const s = store.getState() + expect(s.browserPagesByWorkspace['browser-1']).toHaveLength(1) + expect(s.browserPagesByWorkspace['browser-1'][0].url).toBe('https://example.com') + expect(s.browserTabsByWorktree[validWt][0].activePageId).toBe( + s.browserPagesByWorkspace['browser-1'][0].id + ) + }) + it('drops legacy window close bypass state during hydration', () => { const store = createTestStore() const validWt = 'repo1::/path/wt1' diff --git a/src/renderer/src/store/slices/tab-group-reference-repair.ts b/src/renderer/src/store/slices/tab-group-reference-repair.ts new file mode 100644 index 00000000000..42a742cc724 --- /dev/null +++ b/src/renderer/src/store/slices/tab-group-reference-repair.ts @@ -0,0 +1,78 @@ +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' +import { createBrowserUuid } from '@/lib/browser-uuid' + +function collectLayoutGroupIds(node: TabGroupLayoutNode, groupIds: Set): void { + if (node.type === 'leaf') { + groupIds.add(node.groupId) + return + } + collectLayoutGroupIds(node.first, groupIds) + collectLayoutGroupIds(node.second, groupIds) +} + +export function layoutSpanningGroups( + groups: readonly TabGroup[], + existing?: TabGroupLayoutNode | null +): TabGroupLayoutNode { + const first = existing ?? { type: 'leaf', groupId: groups[0].id } + const laidOutGroupIds = new Set() + collectLayoutGroupIds(first, laidOutGroupIds) + return groups + .filter((group) => !laidOutGroupIds.has(group.id)) + .reduce( + (first, group) => ({ + type: 'split', + direction: 'horizontal', + first, + second: { type: 'leaf', groupId: group.id } + }), + first + ) +} + +/** Re-home tabs stranded by a dropped group so hydration cannot render a blank workspace. */ +export function adoptGrouplessTabs( + tabsByWorktree: Record, + groupsByWorktree: Record, + activeGroupIdByWorktree: Record, + layoutByWorktree: Record +): void { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const groups = groupsByWorktree[worktreeId] ?? [] + const owningGroupIdByTabId = new Map() + for (const group of groups) { + for (const tabId of group.tabOrder) { + owningGroupIdByTabId.set(tabId, owningGroupIdByTabId.get(tabId) ?? group.id) + } + } + const orphanIds = tabs.filter((tab) => !owningGroupIdByTabId.has(tab.id)).map((tab) => tab.id) + const host: TabGroup = groups[0] ?? { + id: createBrowserUuid(), + worktreeId, + activeTabId: null, + tabOrder: [], + recentTabIds: [] + } + const adopted = orphanIds.length + ? { + ...host, + tabOrder: [...host.tabOrder, ...orphanIds], + activeTabId: host.activeTabId ?? orphanIds[0] + } + : host + for (const tabId of orphanIds) { + owningGroupIdByTabId.set(tabId, adopted.id) + } + tabsByWorktree[worktreeId] = tabs.map((tab) => { + const owningGroupId = owningGroupIdByTabId.get(tab.id) + return owningGroupId && tab.groupId !== owningGroupId + ? { ...tab, groupId: owningGroupId } + : tab + }) + if (orphanIds.length) { + groupsByWorktree[worktreeId] = [adopted, ...groups.slice(1)] + activeGroupIdByWorktree[worktreeId] ??= adopted.id + layoutByWorktree[worktreeId] ??= { type: 'leaf', groupId: adopted.id } + } + } +} diff --git a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts index f927db73b8d..dca253f4733 100644 --- a/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts +++ b/src/renderer/src/store/slices/tabs-hydration-group-validation.test.ts @@ -96,3 +96,121 @@ describe('buildHydratedTabState group validation', () => { expect(group.tabOrder).toEqual(['t1']) }) }) + +describe('buildHydratedTabState referential repair', () => { + const tab = (id: string, groupId: string, sortOrder: number) => ({ + id, + entityId: id, + groupId, + worktreeId: 'w1', + contentType: 'terminal' as const, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + }) + + function leafGroupIds(node: unknown): string[] { + const layout = node as + | { type: 'leaf'; groupId: string } + | { type: 'split'; first: unknown; second: unknown } + return layout.type === 'leaf' + ? [layout.groupId] + : [...leafGroupIds(layout.first), ...leafGroupIds(layout.second)] + } + + it('keeps tabs reachable when a worktree lost every tab group', () => { + // Why: session salvage drops a corrupt group record and leaves its tabs + // behind. Groupless tabs render nowhere yet still count as renderable, so the + // auto-create rescue never fires and the worktree body comes back blank. + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g1', 1)] }, + tabGroups: { w1: [] } + }, + new Set(['w1']) + ) + + const groups = result.groupsByWorktree.w1 + expect(groups).toHaveLength(1) + expect(groups[0].tabOrder).toEqual(['t1', 't2']) + expect(groups[0].activeTabId).toBe('t1') + expect(result.activeGroupIdByWorktree.w1).toBe(groups[0].id) + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual([groups[0].id]) + expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual([ + groups[0].id, + groups[0].id + ]) + }) + + it('adopts tabs stranded by one dropped group into a surviving group', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] } + }, + new Set(['w1']) + ) + + expect(result.groupsByWorktree.w1.map((group) => group.id)).toEqual(['g1']) + expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1', 't2']) + expect(result.unifiedTabsByWorktree.w1.map((t) => t.groupId)).toEqual(['g1', 'g1']) + }) + + it('repairs a tab groupId when a surviving group already owns the tab', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g-dropped', 0)] }, + tabGroups: { w1: [{ id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }] } + }, + new Set(['w1']) + ) + + expect(result.groupsByWorktree.w1[0].tabOrder).toEqual(['t1']) + expect(result.unifiedTabsByWorktree.w1[0].groupId).toBe('g1') + }) + + it('spans every surviving group when the persisted layout is gone', () => { + // Why: salvage can drop a corrupt tabGroupLayouts entry while both groups + // survive; a fallback leaf naming only the first hides the second group and + // every tab in it, with nothing downstream to notice. + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) + + it('adds surviving groups omitted by a partial persisted layout', () => { + const result = buildHydratedTabState( + { + ...makeBaseSession(), + unifiedTabs: { w1: [tab('t1', 'g1', 0), tab('t2', 'g2', 1)] }, + tabGroups: { + w1: [ + { id: 'g1', worktreeId: 'w1', activeTabId: 't1', tabOrder: ['t1'] }, + { id: 'g2', worktreeId: 'w1', activeTabId: 't2', tabOrder: ['t2'] } + ] + }, + tabGroupLayouts: { w1: { type: 'leaf', groupId: 'g1' } } + }, + new Set(['w1']) + ) + + expect(leafGroupIds(result.layoutByWorktree.w1)).toEqual(['g1', 'g2']) + }) +}) diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index e6b1ae2474e..2fb85ab3808 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -6,6 +6,7 @@ import type { } from '../../../../shared/types' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { createBrowserUuid } from '@/lib/browser-uuid' +import { adoptGrouplessTabs, layoutSpanningGroups } from './tab-group-reference-repair' import { dedupeTabOrder, getPersistedEditFileIdsByWorktree, @@ -176,15 +177,12 @@ function hydrateUnifiedFormat( const hydratedLayout = session.tabGroupLayouts?.[worktreeId] ? pruneTabGroupLayoutForGroups(session.tabGroupLayouts[worktreeId], hydratedGroupIds) : null - layoutByWorktree[worktreeId] = hydratedLayout ?? { - type: 'leaf', - // Why: if transient-only groups were removed during hydration, the - // persisted split tree can collapse to a single surviving group. The - // fallback leaf keeps restore aligned with the remaining real tabs. - groupId: hydratedGroups[0].id - } + // A partial layout must still render every surviving group. + layoutByWorktree[worktreeId] = layoutSpanningGroups(hydratedGroups, hydratedLayout) } + adoptGrouplessTabs(tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree, layoutByWorktree) + return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, diff --git a/src/shared/workspace-session-browser-schema.ts b/src/shared/workspace-session-browser-schema.ts new file mode 100644 index 00000000000..19aaeb0ae82 --- /dev/null +++ b/src/shared/workspace-session-browser-schema.ts @@ -0,0 +1,83 @@ +/* Why: the browser slice of the persisted workspace session. Split out of + * workspace-session-schema.ts to keep that file inside its line budget; the + * schemas themselves are unchanged apart from the per-entry tolerance the + * session schema now declares everywhere. */ +import { z } from 'zod' +import type { BrowserWorkspace } from './types' +import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' +import { salvagingArray } from './zod-salvage' + +const browserLoadErrorSchema = z.object({ + code: z.number(), + description: z.string(), + validatedUrl: z.string() +}) + +const browserViewportPresetIdSchema = z.enum([ + 'mobile-s', + 'mobile-m', + 'mobile-l', + 'tablet', + 'laptop', + 'laptop-l', + 'desktop' +]) + +// Why: the z.ZodType cast only aligns the static type — it +// does NOT let new fields survive parsing. z.object strips unknown keys, so +// every additive field must be listed below (optional+nullable) or it is +// dropped on restore. +export const browserWorkspaceSchema: z.ZodType = z.object({ + id: z.string(), + worktreeId: z.string(), + label: z.string().optional(), + sessionProfileId: z.string().nullable().optional(), + // Why: optional+nullable so pre-field sessions still validate; without this + // zod strips the persisted partition on restore, and an isolated tab whose + // profile mirror is stale at startup would silently fall back to the shared + // default partition — reopening the storage leak (#6923) across restarts. + sessionPartition: z.string().nullable().optional(), + activePageId: z.string().nullable().optional(), + pageIds: z.array(z.string()).optional(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number() +}) + +export const browserPageSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + worktreeId: z.string(), + url: z.string(), + title: z.string(), + loading: z.boolean(), + faviconUrl: z.string().nullable(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + loadError: browserLoadErrorSchema.nullable(), + createdAt: z.number(), + // Why: explicit null marks a browser page as client-local even when its + // worktree is remote-owned; older sessions omit it and keep inferred runtime. + browserRuntimeEnvironmentId: z.string().nullable().optional(), + // Why: optional+nullable so sessions persisted before viewport presets were + // added still validate; without this, zod would strip the field during + // restore and reset the user's chosen preset on every app restart. + viewportPresetId: browserViewportPresetIdSchema.nullable().optional() +}) + +const browserHistoryEntrySchema = z.object({ + url: z.string(), + normalizedUrl: z.string(), + title: z.string(), + lastVisitedAt: z.number(), + visitCount: z.number() +}) + +export const browserHistoryEntriesSchema = salvagingArray(browserHistoryEntrySchema).transform( + (entries) => normalizeBrowserHistoryEntries(entries) +) diff --git a/src/shared/workspace-session-salvage.test.ts b/src/shared/workspace-session-salvage.test.ts new file mode 100644 index 00000000000..876f8bb7305 --- /dev/null +++ b/src/shared/workspace-session-salvage.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { parseWorkspaceSessionSalvaging } from './workspace-session-salvage' +import { collectSalvageDrops, salvagingArray } from './zod-salvage' + +const WT = 'repo-1::/home/user/project' + +function terminalTab(id: string, overrides: Record = {}): Record { + return { + id, + ptyId: null, + worktreeId: WT, + title: 'Terminal', + defaultTitle: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000, + ...overrides + } +} + +function baseSession(overrides: Record = {}): Record { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides + } +} + +describe('parseWorkspaceSessionSalvaging', () => { + it('restores outer diagnostics after a nested collection', () => { + const schema = salvagingArray(z.string()) + const outer = collectSalvageDrops(() => { + schema.parse([1]) + const inner = collectSalvageDrops(() => schema.parse([2])) + schema.parse([3]) + return inner + }) + + expect(outer.droppedCount).toBe(2) + expect(outer.value.droppedCount).toBe(1) + }) + + it('returns a valid session unchanged with nothing dropped', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ tabsByWorktree: { [WT]: [terminalTab('tab-1')] } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([]) + expect(result.value.tabsByWorktree[WT]).toHaveLength(1) + } + }) + + it('drops a tab record missing required fields and keeps the rest of the session', () => { + const truncated = { + id: 'tab-bad', + ptyId: null, + worktreeId: WT, + title: 'Terminal', + sortOrder: 0, + generation: 3, + startupCwd: '/home/user/project' + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1'), terminalTab('tab-2'), truncated] }, + sleepingAgentSessionsByPaneKey: {} + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabsByWorktree.${WT}.2`]) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2']) + } + }) + + it('reports sleeping-agent records removed during normalization', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + sleepingAgentSessionsByPaneKey: { + 'tab-bad:leaf': { paneKey: 'different:leaf' } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['sleepingAgentSessionsByPaneKey.tab-bad:leaf']) + expect(result.value.sleepingAgentSessionsByPaneKey).toBeUndefined() + } + }) + + it('drops only the corrupt leaf pty mapping and keeps the rest of the tab layout', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1')] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': 'pty-1', 'leaf-2': 42 } + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-1.ptyIdsByLeafId.leaf-2']) + const layout = result.value.terminalLayoutsByTabId['tab-1'] + expect(layout?.ptyIdsByLeafId).toEqual({ 'leaf-1': 'pty-1' }) + expect(layout?.root).toEqual({ type: 'leaf', leafId: 'leaf-1' }) + } + }) + + it('drops the containing entry when the corruption sits in one of its required fields', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalLayoutsByTabId: { + 'tab-bad': { + root: { type: 'leaf', leafId: 42 }, + activeLeafId: null, + expandedLeafId: null + }, + 'tab-good': { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + // Why: the entry is the smallest self-contained unit, so dropped counts + // reflect distinct corrupt records rather than symptoms. + expect(result.droppedPaths).toEqual(['terminalLayoutsByTabId.tab-bad']) + expect(result.value.terminalLayoutsByTabId['tab-bad']).toBeUndefined() + expect(result.value.terminalLayoutsByTabId['tab-good']?.root).toEqual({ + type: 'leaf', + leafId: 'leaf-1' + }) + } + }) + + it('salvages systemic single-field corruption without inflating the dropped count', () => { + const layouts: Record = {} + for (let i = 0; i < 20; i += 1) { + layouts[`tab-${i}`] = { + root: { type: 'leaf', leafId: i }, + activeLeafId: null, + expandedLeafId: null + } + } + const result = parseWorkspaceSessionSalvaging(baseSession({ terminalLayoutsByTabId: layouts })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.terminalLayoutsByTabId).toEqual({}) + expect(result.droppedPaths).toHaveLength(20) + } + }) + + it('reports one drop when a record raises both a bad-field and a missing-field issue', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalSurfaceTombstonesByPaneKey: { 'tab-1:leaf-1': { worktreeId: 42 } } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1']) + expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({}) + } + }) + + it('reports one drop when two fields of the same record are corrupt', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalSurfaceTombstonesByPaneKey: { + 'tab-1:leaf-1': { + worktreeId: WT, + parentTabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + incarnationId: '', + retiredAt: -5 + } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalSurfaceTombstonesByPaneKey.tab-1:leaf-1']) + expect(result.value.terminalSurfaceTombstonesByPaneKey).toEqual({}) + } + }) + + it('reports a dotted map key and its same-named nested path independently', () => { + // Why: map keys are user data, so 'a.root' and the nested path a → root are + // different entries that read alike once joined. + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalLayoutsByTabId: { + 'a.root': 'not-an-object', + a: { root: 42, activeLeafId: null, expandedLeafId: null } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths.toSorted()).toEqual([ + 'terminalLayoutsByTabId.a', + 'terminalLayoutsByTabId.a.root' + ]) + expect(result.value.terminalLayoutsByTabId).toEqual({}) + } + }) + + it('drops an invalid unified tab entry without touching sibling worktrees', () => { + const goodUnified = { + id: 'tab-1', + entityId: 'tab-1', + groupId: 'group-1', + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000 + } + const missingCustomLabel = { ...goodUnified, id: 'tab-2', entityId: 'tab-2' } as Record< + string, + unknown + > + delete missingCustomLabel.customLabel + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1')] }, + unifiedTabs: { [WT]: [goodUnified, missingCustomLabel], 'repo-2::/other': [] } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`unifiedTabs.${WT}.1`]) + expect(result.value.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['tab-1']) + expect(result.value.unifiedTabs?.['repo-2::/other']).toEqual([]) + } + }) + + it('drops a corrupt map value by its key', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1', 'tab-2:leaf-2': 123 } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalPtyIncarnationsByPaneKey.tab-2:leaf-2']) + expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' }) + } + }) + + it('salvages multiple corrupt entries across different maps in one load', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-1'), { id: 'tab-bad' }] }, + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 42 } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toHaveLength(2) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-1']) + } + }) + + it('salvages rather than throwing on a payload large enough to overflow the validator', () => { + // Why: this parse runs in the Store constructor, so an escaping RangeError is + // an unrecoverable launch failure. Per-entry validation never accumulates the + // issue list that used to overflow. + const worktreeId = 'repo-1::/huge' + const tabs = Array.from({ length: 200_000 }, (_, i) => ({ id: `bad-${i}` })) + const result = parseWorkspaceSessionSalvaging( + baseSession({ tabsByWorktree: { [worktreeId]: tabs } }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedCount).toBe(200_000) + expect(result.droppedPaths).toHaveLength(100) + expect(result.value.tabsByWorktree[worktreeId]).toEqual([]) + } + }) + + it('fails for a payload that is not an object', () => { + expect(parseWorkspaceSessionSalvaging('not a session').ok).toBe(false) + expect(parseWorkspaceSessionSalvaging(null).ok).toBe(false) + }) + + it('drops an optional top-level field whose value is the wrong type', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalTopologyRevisionByRepoId: 'nope' }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual(['terminalTopologyRevisionByRepoId']) + expect(result.value.terminalTopologyRevisionByRepoId).toBeUndefined() + // Why: an explicit undefined key would shadow the caller's default in the + // `{ ...defaults, ...value }` spread both call sites do. + expect(Object.hasOwn(result.value, 'terminalTopologyRevisionByRepoId')).toBe(false) + } + }) + + it('salvages systemic corruption far larger than any single-entry budget', () => { + // Why: the reported failure was one bad record, but a bad writer projects the + // same wrong shape across every entry it touches. The dropped count is + // unbounded so that case stays a salvage instead of a full-session reset. + const incarnations: Record = {} + for (let i = 0; i < 400; i += 1) { + incarnations[`tab-${i}:leaf-${i}`] = i % 2 === 0 ? i : `inc-${i}` + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ terminalPtyIncarnationsByPaneKey: incarnations }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedCount).toBe(200) + expect(result.droppedPaths).toHaveLength(100) + expect(Object.keys(result.value.terminalPtyIncarnationsByPaneKey ?? {})).toHaveLength(200) + } + }) + + it('drops many corrupt tab records across worktrees in a single session load', () => { + const tabsByWorktree: Record = {} + for (let w = 0; w < 20; w += 1) { + const worktreeId = `repo-1::/w${w}` + tabsByWorktree[worktreeId] = [ + terminalTab(`good-${w}`, { worktreeId }), + { id: `bad-a-${w}`, worktreeId }, + terminalTab(`good2-${w}`, { worktreeId }), + { id: `bad-b-${w}`, worktreeId } + ] + } + const result = parseWorkspaceSessionSalvaging(baseSession({ tabsByWorktree })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toHaveLength(40) + expect(result.value.tabsByWorktree['repo-1::/w7']?.map((tab) => tab.id)).toEqual([ + 'good-7', + 'good2-7' + ]) + } + }) + + it('keeps the rest of the session when a required top-level field is unsalvageable', () => { + // Why: without a fallback, one bad legacy `tabsByWorktree` would still cost + // every worktree's unified tabs, groups and layouts. + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: 'nope', + activeRepoId: 42, + terminalPtyIncarnationsByPaneKey: { 'tab-1:leaf-1': 'inc-1' } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths.toSorted()).toEqual(['activeRepoId', 'tabsByWorktree']) + expect(result.value.tabsByWorktree).toEqual({}) + expect(result.value.activeRepoId).toBeNull() + expect(result.value.terminalPtyIncarnationsByPaneKey).toEqual({ 'tab-1:leaf-1': 'inc-1' }) + } + }) + + it('still rejects a foreign object payload rather than posing as a repaired session', () => { + // Why: a fallback repairs a field we could not read, it must never manufacture + // a session out of an unrelated JSON blob that simply lacks every field. + expect(parseWorkspaceSessionSalvaging({ unrelated: 'payload', count: 3 }).ok).toBe(false) + }) + + it('drops a whole layout entry when the corruption sits inside a recursive union', () => { + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabGroupLayouts: { + [WT]: { + type: 'split', + direction: 'row', + first: { type: 'split', direction: 'column', first: { type: 'leaf', groupId: 42 } } + }, + 'repo-2::/other': { type: 'leaf', groupId: 'group-ok' } + } + }) + ) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`]) + expect(result.value.tabGroupLayouts?.[WT]).toBeUndefined() + expect(result.value.tabGroupLayouts?.['repo-2::/other']).toBeDefined() + } + }) + + it('drops a recursive entry whose validator overflows instead of resetting the session', () => { + let layout: Record = { type: 'leaf', groupId: 'group-deep' } + for (let depth = 0; depth < 3_000; depth += 1) { + layout = { + type: 'split', + direction: 'horizontal', + first: layout, + second: { type: 'leaf', groupId: `group-${depth}` } + } + } + const result = parseWorkspaceSessionSalvaging( + baseSession({ + tabsByWorktree: { [WT]: [terminalTab('tab-keep')] }, + tabGroupLayouts: { [WT]: layout } + }) + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.droppedPaths).toEqual([`tabGroupLayouts.${WT}`]) + expect(result.value.tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(['tab-keep']) + expect(result.value.tabGroupLayouts).toEqual({}) + } + }) +}) diff --git a/src/shared/workspace-session-salvage.ts b/src/shared/workspace-session-salvage.ts new file mode 100644 index 00000000000..66b6a6374e9 --- /dev/null +++ b/src/shared/workspace-session-salvage.ts @@ -0,0 +1,34 @@ +import type { WorkspaceSessionState } from './types' +import { + describeWorkspaceSessionError, + safeParseWorkspaceSession, + WORKSPACE_SESSION_UNVALIDATABLE +} from './workspace-session-schema' +import { collectSalvageDrops } from './zod-salvage' + +export type SalvagedWorkspaceSession = + | { ok: true; value: WorkspaceSessionState; droppedPaths: string[]; droppedCount: number } + | { ok: false; error: string } + +/** Remove undefined keys that would shadow caller defaults during object spread. */ +function withoutSalvagedAwayFields(session: WorkspaceSessionState): WorkspaceSessionState { + return Object.fromEntries( + Object.entries(session).filter(([, value]) => value !== undefined) + ) as WorkspaceSessionState +} + +/** Validate a session, preserving valid entries and reporting bounded repair diagnostics. */ +export function parseWorkspaceSessionSalvaging(raw: unknown): SalvagedWorkspaceSession { + const { + value: result, + droppedPaths, + droppedCount + } = collectSalvageDrops(() => safeParseWorkspaceSession(raw)) + if (!result) { + return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE } + } + if (!result.success) { + return { ok: false, error: describeWorkspaceSessionError(result.error) } + } + return { ok: true, value: withoutSalvagedAwayFields(result.data), droppedPaths, droppedCount } +} diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 72c7f40d2a5..9a1291a9d67 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -40,7 +40,7 @@ describe('parseWorkspaceSession', () => { } }) - it('rejects blank external SSH file ownership', () => { + it('drops an open file with blank external SSH ownership, keeping the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: 'wt', @@ -60,7 +60,10 @@ describe('parseWorkspaceSession', () => { } }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.openFilesByWorktree?.wt).toEqual([]) + } }) it('accepts a fully populated session with optional fields', () => { @@ -194,7 +197,7 @@ describe('parseWorkspaceSession', () => { } }) - it('rejects a session where ptyId is a number (schema drift)', () => { + it('drops a tab where ptyId is a number (schema drift) without failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: null, @@ -215,9 +218,9 @@ describe('parseWorkspaceSession', () => { }, terminalLayoutsByTabId: {} }) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error).toContain('ptyId') + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt).toEqual([]) } }) @@ -406,6 +409,47 @@ describe('parseWorkspaceSession', () => { expect(parseWorkspaceSession(42).ok).toBe(false) }) + it('drops one truncated tab without discarding other persisted worktrees', () => { + const validTab = { + id: 'tab-good', + ptyId: null, + worktreeId: 'worktree-good', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_700_000_000_000 + } + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'worktree-good', + activeTabId: 'tab-good', + tabsByWorktree: { + 'worktree-good': [validTab], + 'worktree-corrupt': [ + { + id: 'tab-truncated', + ptyId: null, + worktreeId: 'worktree-corrupt', + title: 'Terminal', + sortOrder: 0, + generation: 3, + startupCwd: '/workspace' + } + ] + }, + terminalLayoutsByTabId: {} + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree).toEqual({ + 'worktree-good': [validTab], + 'worktree-corrupt': [] + }) + } + }) + it('drops bad lastVisitedAtByWorktreeId entries rather than failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 7a24ad57505..446e19270da 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -5,12 +5,14 @@ * "reject and fall back to defaults" point so garbage never reaches React. * * Policy: be tolerant of extra fields (future builds may add more) but strict - * about the types of fields we actually read. Unknown enum values, wrong types, - * and wrong shapes all collapse to "use defaults" — never throw into main. + * about the types of fields we actually read. Where a field holds a collection + * of independent records, tolerance is declared on the field itself (see + * ./zod-salvage): a corrupt entry is dropped and the rest of the session + * survives, because one bad tab record must not cost every worktree its state. + * Only a payload that is not a session at all falls back to defaults. */ import { z } from 'zod' import type { - BrowserWorkspace, TabGroupLayoutNode, TerminalPaneLayoutNode, TuiAgent, @@ -20,9 +22,14 @@ import type { import { isValidTerminalTabId } from './terminal-tab-id' import { parseExecutionHostId, type ExecutionHostId } from './execution-host' import { isTuiAgent } from './tui-agent-config' -import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history' import { isWorkspaceKey } from './workspace-scope' +import { + browserHistoryEntriesSchema, + browserPageSchema, + browserWorkspaceSchema +} from './workspace-session-browser-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -53,14 +60,16 @@ const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(( ]) ) +const leafStringsSchema = salvagingRecord(z.string(), z.string()) + const terminalLayoutSnapshotSchema = z.object({ root: terminalPaneLayoutNodeSchema.nullable(), activeLeafId: z.string().nullable(), expandedLeafId: z.string().nullable(), - ptyIdsByLeafId: z.record(z.string(), z.string()).optional(), - buffersByLeafId: z.record(z.string(), z.string()).optional(), - scrollbackRefsByLeafId: z.record(z.string(), z.string()).optional(), - titlesByLeafId: z.record(z.string(), z.string()).optional() + ptyIdsByLeafId: salvagedOptional('ptyIdsByLeafId', leafStringsSchema), + buffersByLeafId: salvagedOptional('buffersByLeafId', leafStringsSchema), + scrollbackRefsByLeafId: salvagedOptional('scrollbackRefsByLeafId', leafStringsSchema), + titlesByLeafId: salvagedOptional('titlesByLeafId', leafStringsSchema) }) // ─── Terminal tab (legacy) ────────────────────────────────────────── @@ -187,172 +196,170 @@ const persistedOpenFileSchema = z.object({ liveTail: z.boolean().optional() }) -// ─── Browser ──────────────────────────────────────────────────────── - -const browserLoadErrorSchema = z.object({ - code: z.number(), - description: z.string(), - validatedUrl: z.string() -}) - -const browserViewportPresetIdSchema = z.enum([ - 'mobile-s', - 'mobile-m', - 'mobile-l', - 'tablet', - 'laptop', - 'laptop-l', - 'desktop' -]) - -// Why: the z.ZodType cast only aligns the static type — it -// does NOT let new fields survive parsing. z.object strips unknown keys, so -// every additive field must be listed below (optional+nullable) or it is -// dropped on restore. -const browserWorkspaceSchema: z.ZodType = z.object({ - id: z.string(), - worktreeId: z.string(), - label: z.string().optional(), - sessionProfileId: z.string().nullable().optional(), - // Why: optional+nullable so pre-field sessions still validate; without this - // zod strips the persisted partition on restore, and an isolated tab whose - // profile mirror is stale at startup would silently fall back to the shared - // default partition — reopening the storage leak (#6923) across restarts. - sessionPartition: z.string().nullable().optional(), - activePageId: z.string().nullable().optional(), - pageIds: z.array(z.string()).optional(), - url: z.string(), - title: z.string(), - loading: z.boolean(), - faviconUrl: z.string().nullable(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loadError: browserLoadErrorSchema.nullable(), - createdAt: z.number() -}) - -const browserPageSchema = z.object({ - id: z.string(), - workspaceId: z.string(), - worktreeId: z.string(), - url: z.string(), - title: z.string(), - loading: z.boolean(), - faviconUrl: z.string().nullable(), - canGoBack: z.boolean(), - canGoForward: z.boolean(), - loadError: browserLoadErrorSchema.nullable(), - createdAt: z.number(), - // Why: explicit null marks a browser page as client-local even when its - // worktree is remote-owned; older sessions omit it and keep inferred runtime. - browserRuntimeEnvironmentId: z.string().nullable().optional(), - // Why: optional+nullable so sessions persisted before viewport presets were - // added still validate; without this, zod would strip the field during - // restore and reset the user's chosen preset on every app restart. - viewportPresetId: browserViewportPresetIdSchema.nullable().optional() -}) - -const browserHistoryEntrySchema = z.object({ - url: z.string(), - normalizedUrl: z.string(), - title: z.string(), - lastVisitedAt: z.number(), - visitCount: z.number() -}) - -const browserHistoryEntriesSchema = z - .array(browserHistoryEntrySchema) - .transform((entries) => normalizeBrowserHistoryEntries(entries)) - // ─── Workspace session ────────────────────────────────────────────── +const terminalSurfaceTombstoneSchema = z.object({ + worktreeId: z.string(), + parentTabId: terminalTabIdSchema, + leafId: z.string(), + ptyId: z.string(), + incarnationId: z.string().min(1).max(128), + retiredAt: z.number().finite().nonnegative() +}) + +const worktreeIdSchema = z.string() + export const workspaceSessionStateSchema: z.ZodType = z.object({ - activeRepoId: z.string().nullable(), - activeWorkspaceKey: workspaceKeySchema.nullable().optional(), - activeWorkspaceExecutionHostId: z - .custom( - (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) - ) - .nullable() - .optional(), - activeWorktreeId: z.string().nullable(), - activeTabId: z.string().nullable(), - tabsByWorktree: z.record(z.string(), z.array(terminalTabSchema)), - terminalLayoutsByTabId: z.record(terminalTabIdSchema, terminalLayoutSnapshotSchema), - activeWorktreeIdsOnShutdown: z.array(z.string()).optional(), - openFilesByWorktree: z.record(z.string(), z.array(persistedOpenFileSchema)).optional(), - activeFileIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - markdownFrontmatterVisible: z.record(z.string(), z.boolean()).optional(), - browserTabsByWorktree: z.record(z.string(), z.array(browserWorkspaceSchema)).optional(), - browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(), - activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(), - browserUrlHistory: browserHistoryEntriesSchema.optional(), - activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(), - unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(), - tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(), - tabGroupLayouts: z.record(z.string(), tabGroupLayoutNodeSchema).optional(), - activeGroupIdByWorktree: z.record(z.string(), z.string()).optional(), - activeConnectionIdsAtShutdown: z.array(z.string()).optional(), - remoteSessionIdsByTabId: z.record(terminalTabIdSchema, z.string()).optional(), - // Why: the sort comparator in order-empty-query-worktrees.ts would produce - // NaN (undefined sort order) if a corrupted session file carried NaN or - // Infinity here. Parse leniently: drop individual bad entries rather than - // failing the entire session. A strict record() rejection here would cause - // parseWorkspaceSession to fall back to defaults for the ENTIRE session - // (terminals, editors, browsers, layouts) on a single corrupted timestamp - // — a blast radius far larger than "Cmd+J falls back to activity recency", - // which is all this field gates. - lastVisitedAtByWorktreeId: z - .preprocess( - (raw) => { - if (raw == null || typeof raw !== 'object') { - return raw - } - const cleaned: Record = {} - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof v === 'number' && Number.isFinite(v) && v >= 0) { - cleaned[k] = v - } - } - return cleaned - }, - z.record(z.string(), z.number().finite().nonnegative()) - ) - .optional(), - defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(), - sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema, - terminalPtyIncarnationsByPaneKey: z.record(z.string(), z.string().min(1).max(128)).optional(), - terminalTopologyRevisionByRepoId: z.record(z.string(), z.number().int().nonnegative()).optional(), - terminalSurfaceTombstonesByPaneKey: z - .record( - z.string(), - z.object({ - worktreeId: z.string(), - parentTabId: terminalTabIdSchema, - leafId: z.string(), - ptyId: z.string(), - incarnationId: z.string().min(1).max(128), - retiredAt: z.number().finite().nonnegative() - }) - ) - .optional() + activeRepoId: salvagedField('activeRepoId', z.string().nullable(), () => null), + activeWorkspaceKey: salvagedOptional('activeWorkspaceKey', workspaceKeySchema.nullable()), + activeWorkspaceExecutionHostId: salvagedOptional( + 'activeWorkspaceExecutionHostId', + z + .custom( + (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) + ) + .nullable() + ), + activeWorktreeId: salvagedField('activeWorktreeId', z.string().nullable(), () => null), + activeTabId: salvagedField('activeTabId', z.string().nullable(), () => null), + tabsByWorktree: salvagedField( + 'tabsByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(terminalTabSchema)), + () => ({}) + ), + terminalLayoutsByTabId: salvagedField( + 'terminalLayoutsByTabId', + salvagingRecord(terminalTabIdSchema, terminalLayoutSnapshotSchema), + () => ({}) + ), + activeWorktreeIdsOnShutdown: salvagedOptional( + 'activeWorktreeIdsOnShutdown', + salvagingArray(worktreeIdSchema) + ), + openFilesByWorktree: salvagedOptional( + 'openFilesByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(persistedOpenFileSchema)) + ), + activeFileIdByWorktree: salvagedOptional( + 'activeFileIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + markdownFrontmatterVisible: salvagedOptional( + 'markdownFrontmatterVisible', + salvagingRecord(z.string(), z.boolean()) + ), + browserTabsByWorktree: salvagedOptional( + 'browserTabsByWorktree', + salvagingRecord(worktreeIdSchema, salvagingArray(browserWorkspaceSchema)) + ), + browserPagesByWorkspace: salvagedOptional( + 'browserPagesByWorkspace', + salvagingRecord(z.string(), salvagingArray(browserPageSchema)) + ), + activeBrowserTabIdByWorktree: salvagedOptional( + 'activeBrowserTabIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + activeTabTypeByWorktree: salvagedOptional( + 'activeTabTypeByWorktree', + salvagingRecord(worktreeIdSchema, workspaceVisibleTabTypeSchema) + ), + browserUrlHistory: salvagedOptional('browserUrlHistory', browserHistoryEntriesSchema), + activeTabIdByWorktree: salvagedOptional( + 'activeTabIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string().nullable()) + ), + unifiedTabs: salvagedOptional( + 'unifiedTabs', + salvagingRecord(worktreeIdSchema, salvagingArray(tabSchema)) + ), + tabGroups: salvagedOptional( + 'tabGroups', + salvagingRecord(worktreeIdSchema, salvagingArray(tabGroupSchema)) + ), + tabGroupLayouts: salvagedOptional( + 'tabGroupLayouts', + salvagingRecord(worktreeIdSchema, tabGroupLayoutNodeSchema) + ), + activeGroupIdByWorktree: salvagedOptional( + 'activeGroupIdByWorktree', + salvagingRecord(worktreeIdSchema, z.string()) + ), + activeConnectionIdsAtShutdown: salvagedOptional( + 'activeConnectionIdsAtShutdown', + salvagingArray(z.string()) + ), + remoteSessionIdsByTabId: salvagedOptional( + 'remoteSessionIdsByTabId', + salvagingRecord(terminalTabIdSchema, z.string()) + ), + // Why: the sort comparator in order-empty-query-worktrees.ts would produce NaN + // (undefined sort order) from a NaN or Infinity persisted here. + lastVisitedAtByWorktreeId: salvagedOptional( + 'lastVisitedAtByWorktreeId', + salvagingRecord(worktreeIdSchema, z.number().finite().nonnegative()) + ), + defaultTerminalTabsAppliedByWorktreeId: salvagedOptional( + 'defaultTerminalTabsAppliedByWorktreeId', + salvagingRecord(worktreeIdSchema, z.literal(true)) + ), + sleepingAgentSessionsByPaneKey: salvagedOptional( + 'sleepingAgentSessionsByPaneKey', + sleepingAgentSessionsByPaneKeySchema + ), + terminalPtyIncarnationsByPaneKey: salvagedOptional( + 'terminalPtyIncarnationsByPaneKey', + salvagingRecord(z.string(), z.string().min(1).max(128)) + ), + terminalTopologyRevisionByRepoId: salvagedOptional( + 'terminalTopologyRevisionByRepoId', + salvagingRecord(z.string(), z.number().int().nonnegative()) + ), + terminalSurfaceTombstonesByPaneKey: salvagedOptional( + 'terminalSurfaceTombstonesByPaneKey', + salvagingRecord(z.string(), terminalSurfaceTombstoneSchema) + ) }) export type ParsedWorkspaceSession = | { ok: true; value: WorkspaceSessionState } | { ok: false; error: string } +/** Why: keep the error compact — a zod issue dump is noisy and most of the time + * only the first divergent field is actionable for debugging. */ +export function describeWorkspaceSessionError(error: z.ZodError): string { + const firstIssue = error.issues[0] + const path = firstIssue?.path.join('.') || '' + return `${path}: ${firstIssue?.message ?? 'invalid session'}` +} + +export const WORKSPACE_SESSION_UNVALIDATABLE = ': session could not be validated' + +/** safeParse, or null when the validator itself could not run. + * Why: safeParse is documented not to throw, but a payload holding hundreds of + * thousands of bad records overflows the stack while zod materializes an issue + * per field. This parse runs in the Store constructor, so an escaping RangeError + * is a launch failure the user cannot recover from without deleting their + * profile — exactly the "never throw into main" contract at the top of this file. */ +export function safeParseWorkspaceSession( + raw: unknown +): ReturnType | null { + try { + return workspaceSessionStateSchema.safeParse(raw) + } catch { + return null + } +} + /** Validate raw JSON as a WorkspaceSessionState. Returns a discriminated union * so callers can fall back to defaults on failure without a try/catch. */ export function parseWorkspaceSession(raw: unknown): ParsedWorkspaceSession { - const result = workspaceSessionStateSchema.safeParse(raw) + const result = safeParseWorkspaceSession(raw) + if (!result) { + return { ok: false, error: WORKSPACE_SESSION_UNVALIDATABLE } + } if (result.success) { return { ok: true, value: result.data } } - // Why: keep the error compact — a zod issue dump is noisy and most of the - // time only the first divergent field is actionable for debugging. - const firstIssue = result.error.issues[0] - const path = firstIssue?.path.join('.') || '' - return { ok: false, error: `${path}: ${firstIssue?.message ?? 'invalid session'}` } + return { ok: false, error: describeWorkspaceSessionError(result.error) } } diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index eb48de31e4b..4ee620efa82 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -5,6 +5,7 @@ import { RESUMABLE_TUI_AGENTS } from './agent-session-resume' import { isValidTerminalTabId } from './terminal-tab-id' +import { salvagingRecord } from './zod-salvage' const terminalTabIdSchema = z .string() @@ -108,23 +109,8 @@ const sleepingAgentSessionRecordSchema = z { message: 'provider session is not resumable for this agent', path: ['providerSession'] } ) -export const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => { - if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { - return undefined - } - - const cleaned: Record> = Object.create( - null - ) - for (const [paneKey, value] of Object.entries(raw as Record)) { - if (isUnsafeObjectKey(paneKey)) { - continue - } - const parsed = sleepingAgentSessionRecordSchema.safeParse(value) - if (parsed.success && parsed.data.paneKey === paneKey) { - cleaned[paneKey] = parsed.data - } - } - - return Object.keys(cleaned).length > 0 ? { ...cleaned } : undefined -}, z.record(z.string(), sleepingAgentSessionRecordSchema).optional()) +export const sleepingAgentSessionsByPaneKeySchema = salvagingRecord( + z.string().refine((paneKey) => !isUnsafeObjectKey(paneKey)), + sleepingAgentSessionRecordSchema, + (paneKey, record) => record.paneKey === paneKey +).transform((records) => (Object.keys(records).length > 0 ? records : undefined)) diff --git a/src/shared/workspace-session-terminal-schema.test.ts b/src/shared/workspace-session-terminal-schema.test.ts index 367ee953f78..5878b70a1b9 100644 --- a/src/shared/workspace-session-terminal-schema.test.ts +++ b/src/shared/workspace-session-terminal-schema.test.ts @@ -43,7 +43,7 @@ describe('parseWorkspaceSession terminal fields', () => { } }) - it('rejects empty terminal startup cwd values', () => { + it('drops a tab with an empty startup cwd instead of failing the session', () => { const result = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: 'wt', @@ -67,6 +67,9 @@ describe('parseWorkspaceSession terminal fields', () => { terminalLayoutsByTabId: {} }) - expect(result.ok).toBe(false) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt).toEqual([]) + } }) }) diff --git a/src/shared/zod-salvage.ts b/src/shared/zod-salvage.ts new file mode 100644 index 00000000000..64860eab10a --- /dev/null +++ b/src/shared/zod-salvage.ts @@ -0,0 +1,132 @@ +// Zod transforms lack paths, so salvage combinators track bounded diagnostics during parsing. +import { z } from 'zod' + +const MAX_REPORTED_SALVAGE_PATHS = 100 + +type DropCollector = { paths: string[]; count: number } + +let dropCollector: DropCollector | null = null +const dropPath: (string | number)[] = [] + +/** Run a synchronous parse while collecting its salvage count and example paths. */ +export function collectSalvageDrops(parse: () => T): { + value: T + droppedPaths: string[] + droppedCount: number +} { + const previousCollector = dropCollector + const previousPath = [...dropPath] + const collector: DropCollector = { paths: [], count: 0 } + dropCollector = collector + dropPath.length = 0 + try { + const value = parse() + return { value, droppedPaths: collector.paths, droppedCount: collector.count } + } finally { + dropCollector = previousCollector + dropPath.splice(0, dropPath.length, ...previousPath) + } +} + +function reportDrop(segment: string | number): void { + if (!dropCollector) { + return + } + dropCollector.count += 1 + if (dropCollector.paths.length < MAX_REPORTED_SALVAGE_PATHS) { + dropCollector.paths.push([...dropPath, segment].join('.')) + } +} + +function inEntry(segment: string | number, parse: () => T): T { + dropPath.push(segment) + try { + return parse() + } finally { + dropPath.pop() + } +} + +function parseEntry( + schema: T, + raw: unknown +): { success: true; data: z.output } | { success: false } { + try { + const parsed = schema.safeParse(raw) + return parsed.success ? { success: true, data: parsed.data as z.output } : { success: false } + } catch { + return { success: false } + } +} + +/** Array that drops the elements it cannot parse instead of failing. */ +export function salvagingArray(item: T): z.ZodType[], unknown> { + return z.array(z.unknown()).transform((values) => + values.flatMap((value, index) => { + const parsed = inEntry(index, () => parseEntry(item, value)) + if (parsed.success) { + return [parsed.data] + } + reportDrop(index) + return [] + }) + ) +} + +/** Record that drops entries with invalid keys or values instead of failing. */ +export function salvagingRecord, V extends z.ZodType>( + key: K, + value: V, + accepts?: (key: string, value: z.output) => boolean +): z.ZodType>, unknown> { + return z.record(z.string(), z.unknown()).transform((entries) => { + // Why: null prototype so a persisted '__proto__' key cannot poison the result. + const kept: Record> = Object.create(null) + for (const [entryKey, entryValue] of Object.entries(entries)) { + const parsed = parseEntry(key, entryKey).success + ? inEntry(entryKey, () => parseEntry(value, entryValue)) + : null + if (parsed?.success && (!accepts || accepts(entryKey, parsed.data))) { + kept[entryKey] = parsed.data + continue + } + reportDrop(entryKey) + } + return { ...kept } + }) +} + +function salvaged(name: string, schema: z.ZodType, fallback: () => unknown): z.ZodType { + return z.unknown().transform((raw, ctx) => { + if (raw === undefined) { + ctx.addIssue({ code: 'custom', message: 'required', input: raw }) + return z.NEVER + } + const parsed = inEntry(name, () => parseEntry(schema, raw)) + if (parsed.success) { + return parsed.data + } + reportDrop(name) + return fallback() + }) +} + +/** Fall back for an invalid required field; absence stays fatal for foreign payloads. */ +export function salvagedField( + name: string, + schema: T, + fallback: () => z.output +): z.ZodType, unknown> { + return salvaged(name, schema, fallback) as z.ZodType, unknown> +} + +/** Drop an invalid optional field without reporting legitimate absence. */ +export function salvagedOptional( + name: string, + schema: T +): z.ZodType | undefined, unknown> { + return salvaged(name, schema, () => undefined).optional() as z.ZodType< + z.output | undefined, + unknown + > +} diff --git a/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts new file mode 100644 index 00000000000..50c3d153101 --- /dev/null +++ b/tests/e2e/workspace-session-corrupt-tab-salvage.spec.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { test, expect } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, getActiveWorktreeId, waitForSessionReady } from './helpers/store' +import { TEST_REPO_PATH_FILE } from './global-setup' + +const FIRST_SURVIVOR_TITLE = 'STA-3604 survivor one' +const SECOND_SURVIVOR_TITLE = 'STA-3604 survivor two' +const CORRUPT_TAB_ID = 'sta-3604-corrupt-tab' + +type PersistedData = { + workspaceSession?: { + tabsByWorktree?: Record[]> + unifiedTabs?: Record[]> + } +} + +function persistedDataPath(userDataDir: string): string { + return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') +} + +function injectTruncatedTab(userDataDir: string, worktreeId: string, startupCwd: string): void { + const dataPath = persistedDataPath(userDataDir) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData + const tabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] + if (!tabs) { + throw new Error('Persisted terminal tabs were unavailable for corruption seeding') + } + tabs.push({ + id: CORRUPT_TAB_ID, + ptyId: null, + worktreeId, + title: 'Truncated terminal', + sortOrder: 999, + generation: 3, + startupCwd + }) + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +function persistedSessionEvidence( + userDataDir: string, + worktreeId: string +): { corruptLegacyTabPresent: boolean; unifiedTabIds: string[] } { + const data = JSON.parse(readFileSync(persistedDataPath(userDataDir), 'utf8')) as PersistedData + const legacyTabs = data.workspaceSession?.tabsByWorktree?.[worktreeId] ?? [] + const unifiedTabs = data.workspaceSession?.unifiedTabs?.[worktreeId] ?? [] + return { + corruptLegacyTabPresent: legacyTabs.some((tab) => tab.id === CORRUPT_TAB_ID), + unifiedTabIds: unifiedTabs + .map((tab) => tab.id) + .filter((id): id is string => typeof id === 'string') + } +} + +async function expectSurvivingTabsVisible(page: Page): Promise { + for (const title of [FIRST_SURVIVOR_TITLE, SECOND_SURVIVOR_TITLE]) { + await expect( + page.locator('[data-testid="sortable-tab"]').filter({ hasText: title }) + ).toBeVisible({ timeout: 15_000 }) + } +} + +test('keeps valid terminal tabs visible after a corrupt sibling record on restart', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Seeded E2E repository is unavailable') + + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const first = await session.launch() + firstApp = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await ensureTerminalVisible(first.page) + + const survivorIds = await first.page.evaluate( + ({ worktreeId, firstTitle, secondTitle }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const state = store.getState() + const firstTab = state.tabsByWorktree[worktreeId]?.[0] + if (!firstTab) { + throw new Error('Initial terminal tab unavailable') + } + state.setTabCustomTitle(firstTab.id, firstTitle) + const secondTab = state.createTab(worktreeId, undefined, undefined, { activate: false }) + state.setTabCustomTitle(secondTab.id, secondTitle) + return [firstTab.id, secondTab.id] + }, + { worktreeId, firstTitle: FIRST_SURVIVOR_TITLE, secondTitle: SECOND_SURVIVOR_TITLE } + ) + await expectSurvivingTabsVisible(first.page) + + await session.close(firstApp) + firstApp = null + injectTruncatedTab(session.userDataDir, worktreeId, repoPath) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + await expect.poll(() => getActiveWorktreeId(second.page), { timeout: 15_000 }).toBe(worktreeId) + await ensureTerminalVisible(second.page) + + await expectSurvivingTabsVisible(second.page) + await expect(second.page.locator('.xterm').first()).toBeVisible({ timeout: 15_000 }) + await expect + .poll(() => persistedSessionEvidence(session.userDataDir, worktreeId), { + timeout: 30_000 + }) + .toEqual({ corruptLegacyTabPresent: false, unifiedTabIds: survivorIds }) + + await testInfo.attach('sta-3604-valid-tabs-after-corrupt-session-restart.png', { + body: await second.page.screenshot(), + contentType: 'image/png' + }) + await expectSurvivingTabsVisible(second.page) + } finally { + for (const app of [secondApp, firstApp]) { + if (app) { + await session.close(app).catch(() => {}) + } + } + await session.dispose() + } +}) From 158212b8b3bc6a9bf54c4c7efb94211684d4d7f0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:11:54 -0700 Subject: [PATCH 15/20] feat(github): add PR comment reactions (#13470) * feat(github): add PR comment reactions * fix(github): harden comment reaction updates --------- Co-authored-by: Jinwoo-H --- .../github/client-pr-local-runtime.test.ts | 18 +- src/main/github/client.test.ts | 108 +++++++++++ src/main/github/client.ts | 73 +++++++- src/main/github/comment-reactions.test.ts | 15 +- src/main/github/comment-reactions.ts | 34 +++- src/main/github/issues.test.ts | 6 +- src/main/github/issues.ts | 2 + src/main/ipc/github.test.ts | 21 +++ src/main/ipc/github.ts | 32 ++++ src/main/runtime/orca-runtime.ts | 21 +++ src/main/runtime/rpc/methods/github.test.ts | 25 +++ src/main/runtime/rpc/methods/github.ts | 19 ++ src/preload/api-types.ts | 10 ++ src/preload/index.ts | 11 ++ .../components/github/CommentReactions.tsx | 168 ++++++++++++++++-- .../components/right-sidebar/ChecksPanel.tsx | 43 +++++ .../right-sidebar/checks-panel-content.tsx | 52 +++++- src/renderer/src/i18n/locales/en.json | 8 +- .../src/lib/pr-comment-reactions.test.ts | 71 ++++++++ src/renderer/src/lib/pr-comment-reactions.ts | 83 +++++++++ src/renderer/src/store/slices/github.test.ts | 149 ++++++++++++++++ src/renderer/src/store/slices/github.ts | 119 +++++++++++++ src/renderer/src/web/web-preload-api.test.ts | 12 ++ src/renderer/src/web/web-preload-api.ts | 8 + src/shared/types.ts | 3 + .../helpers/pr-comments-sidebar-fixture.ts | 8 +- .../helpers/source-control-ai-generation.ts | 20 ++- tests/e2e/pr-comments-sidebar-cards.spec.ts | 33 ++++ 28 files changed, 1120 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/lib/pr-comment-reactions.test.ts create mode 100644 src/renderer/src/lib/pr-comment-reactions.ts diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index a2aa0595c74..0e7d4013d2e 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -225,10 +225,14 @@ describe('GitHub PR local runtime routing', () => { return { stdout: '[]' } } if (endpoint.endsWith('/pulls/7/comments/11/replies')) { - return { stdout: JSON.stringify({ id: 12, user: null, body: 'Reply' }) } + return { + stdout: JSON.stringify({ id: 12, node_id: 'PRRC_reply_12', user: null, body: 'Reply' }) + } } if (endpoint.endsWith('/pulls/7/comments')) { - return { stdout: JSON.stringify({ id: 13, user: null, body: 'Inline' }) } + return { + stdout: JSON.stringify({ id: 13, node_id: 'PRRC_inline_13', user: null, body: 'Inline' }) + } } return { stdout: '', stderr: '' } }) @@ -250,7 +254,10 @@ describe('GitHub PR local runtime routing', () => { prRepo, localGitOptions ) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_reply_12' } + }) await expect( addPRReviewComment({ repoPath: '/repo-root', @@ -263,7 +270,10 @@ describe('GitHub PR local runtime routing', () => { path: 'src/app.ts', line: 10 }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'PRRC_inline_13' } + }) await expect( updatePRTitle('/repo-root', 7, 'New title', null, prRepo, localGitOptions) ).resolves.toBe(true) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 4b5f1375aa8..eef3aace0e7 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -178,6 +178,7 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRCommentReaction, setPRAutoMerge, updatePRState, updatePRTitle, @@ -4082,6 +4083,113 @@ describe('GitHub GraphQL rate-limit guard', () => { ) }) + it('returns GraphQL reaction subjects and viewer state for PR comments', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + comments: { + nodes: [ + { + id: 'IC_1', + databaseId: 10, + author: { login: 'octo', avatarUrl: '', __typename: 'User' }, + body: 'Issue comment', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://example.test/issue-comment', + reactionGroups: [ + { + content: 'THUMBS_UP', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + }, + reviewThreads: { + nodes: [ + { + id: 'PRRT_1', + isResolved: false, + line: 4, + startLine: null, + originalLine: 4, + originalStartLine: null, + comments: { + nodes: [ + { + id: 'PRRC_1', + databaseId: 11, + author: { login: 'reviewer', avatarUrl: '', __typename: 'User' }, + body: 'Inline comment', + createdAt: '2026-04-01T00:01:00Z', + url: 'https://example.test/review-comment', + path: 'src/app.ts', + reactionGroups: [ + { + content: 'EYES', + viewerHasReacted: false, + reactors: { totalCount: 1 } + } + ] + } + ] + } + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + const comments = await getPRComments('/repo-root', 7, { + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } + }) + + expect(comments).toEqual([ + expect.objectContaining({ + id: 10, + reactionSubjectId: 'IC_1', + reactions: [{ content: '+1', count: 2, viewerHasReacted: true }] + }), + expect.objectContaining({ + id: 11, + reactionSubjectId: 'PRRC_1', + reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }] + }) + ]) + }) + + it('adds and removes PR comment reactions through GraphQL', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' }) + + await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true) + await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction') + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']), + expect.any(Object) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction') + expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 07472e5c096..46ea1180e24 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -12,6 +12,7 @@ import type { PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, + GitHubReactionContent, GitHubPRReviewCommentInput, PRComment, GitHubViewer, @@ -110,7 +111,11 @@ import { mapPRState, deriveCheckStatus } from './mappers' -import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './comment-reactions' +import { + mapGraphQLReactionGroups, + toGraphQLReactionContent, + type GitHubGraphQLReactionGroup +} from './comment-reactions' import { getRateLimit, noteRepositoryRateLimitSpend, @@ -4117,6 +4122,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { originalStartLine comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4125,6 +4131,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { path reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -4135,6 +4142,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { } comments(first: 100) { nodes { + id databaseId author { __typename login avatarUrl(size: 48) } body @@ -4142,6 +4150,7 @@ query($owner: String!, $repo: String!, $pr: Int!) { url reactionGroups { content + viewerHasReacted reactors { totalCount } @@ -4255,6 +4264,7 @@ export async function getPRComments( originalStartLine: number | null comments: { nodes: { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4266,6 +4276,7 @@ export async function getPRComments( } } type GQLIssueComment = { + id: string databaseId: number author: { __typename?: string; login: string; avatarUrl: string } | null body: string @@ -4295,6 +4306,7 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups) }) ) @@ -4313,6 +4325,7 @@ export async function getPRComments( createdAt: c.createdAt, url: c.url, isBot: c.author?.__typename === 'Bot', + reactionSubjectId: c.id, reactions: mapGraphQLReactionGroups(c.reactionGroups), path: c.path, threadId: thread.id, @@ -4393,6 +4406,62 @@ export async function getPRComments( } } +export async function setPRCommentReaction( + repoPath: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + connectionId?: string | null, + prRepo?: GitHubApiRepository | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const mutation = reacted ? 'addReaction' : 'removeReaction' + const query = `mutation($subjectId: ID!, $content: ReactionContent!) { + ${mutation}(input: { subjectId: $subjectId, content: $content }) { + subject { id } + } + }` + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + return false + } + const guard = repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions) + if (guard.blocked) { + console.warn( + `${mutation} skipped: GitHub GraphQL rate limit nearly exhausted (${guard.remaining}/${guard.limit})` + ) + return false + } + await acquire() + try { + noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) + await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `subjectId=${reactionSubjectId}`, + '-f', + `content=${toGraphQLReactionContent(content)}` + ], + ghOptions + ) + return true + } catch (err) { + console.warn(`${mutation} failed:`, err) + return false + } finally { + release() + } +} + /** * Mark or unmark a PR file as viewed via GitHub's GraphQL API. */ @@ -4492,6 +4561,7 @@ export async function resolveReviewThread( function mapReviewCommentResponse( data: { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -4507,6 +4577,7 @@ function mapReviewCommentResponse( ): PRComment { return { id: data.id ?? Date.now(), + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/github/comment-reactions.test.ts b/src/main/github/comment-reactions.test.ts index 4478214076f..4c0366fda3f 100644 --- a/src/main/github/comment-reactions.test.ts +++ b/src/main/github/comment-reactions.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest' -import { mapGraphQLReactionGroups } from './comment-reactions' +import { mapGraphQLReactionGroups, toGraphQLReactionContent } from './comment-reactions' describe('mapGraphQLReactionGroups', () => { it('normalizes GraphQL reaction groups into GitHub comment reactions', () => { expect( mapGraphQLReactionGroups([ - { content: 'EYES', reactors: { totalCount: 1 } }, - { content: 'THUMBS_UP', reactors: { totalCount: 2 } }, + { content: 'EYES', reactors: { totalCount: 1 }, viewerHasReacted: false }, + { content: 'THUMBS_UP', reactors: { totalCount: 2 }, viewerHasReacted: true }, { content: 'HEART', reactors: { totalCount: 0 } }, { content: 'UNKNOWN', reactors: { totalCount: 9 } } ]) ).toEqual([ - { content: '+1', count: 2 }, - { content: 'eyes', count: 1 } + { content: '+1', count: 2, viewerHasReacted: true }, + { content: 'eyes', count: 1, viewerHasReacted: false } ]) }) @@ -22,4 +22,9 @@ describe('mapGraphQLReactionGroups', () => { undefined ) }) + + it('maps REST reaction content to the GraphQL enum', () => { + expect(toGraphQLReactionContent('+1')).toBe('THUMBS_UP') + expect(toGraphQLReactionContent('hooray')).toBe('HOORAY') + }) }) diff --git a/src/main/github/comment-reactions.ts b/src/main/github/comment-reactions.ts index d93ae551c26..1bb831f30ca 100644 --- a/src/main/github/comment-reactions.ts +++ b/src/main/github/comment-reactions.ts @@ -13,6 +13,7 @@ type GitHubGraphQLReactionContent = export type GitHubGraphQLReactionGroup = { content?: string | null reactors?: { totalCount?: number | null } | null + viewerHasReacted?: boolean | null } const GRAPHQL_REACTION_CONTENT: Record = { @@ -37,10 +38,27 @@ const REACTION_ORDER: GitHubReactionContent[] = [ 'eyes' ] +const REACTION_CONTENT_TO_GRAPHQL: Record = { + '+1': 'THUMBS_UP', + '-1': 'THUMBS_DOWN', + laugh: 'LAUGH', + confused: 'CONFUSED', + heart: 'HEART', + hooray: 'HOORAY', + rocket: 'ROCKET', + eyes: 'EYES' +} + +export function toGraphQLReactionContent( + content: GitHubReactionContent +): GitHubGraphQLReactionContent { + return REACTION_CONTENT_TO_GRAPHQL[content] +} + export function mapGraphQLReactionGroups( groups?: GitHubGraphQLReactionGroup[] | null ): GitHubReaction[] | undefined { - const counts = new Map() + const reactionsByContent = new Map() for (const group of groups ?? []) { const content = group.content && group.content in GRAPHQL_REACTION_CONTENT @@ -50,12 +68,20 @@ export function mapGraphQLReactionGroups( if (!content || count <= 0) { continue } - counts.set(content, (counts.get(content) ?? 0) + count) + const existing = reactionsByContent.get(content) + const viewerHasReacted = Boolean(existing?.viewerHasReacted || group.viewerHasReacted) + reactionsByContent.set(content, { + content, + count: (existing?.count ?? 0) + count, + ...(existing?.viewerHasReacted !== undefined || group.viewerHasReacted != null + ? { viewerHasReacted } + : {}) + }) } const reactions = REACTION_ORDER.flatMap((content) => { - const count = counts.get(content) ?? 0 - return count > 0 ? [{ content, count }] : [] + const reaction = reactionsByContent.get(content) + return reaction ? [reaction] : [] }) return reactions.length > 0 ? reactions : undefined } diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index e9b8a10282c..23a7242810c 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -195,6 +195,7 @@ describe('issue source operations', () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 9, + node_id: 'IC_enterprise_9', user: { login: 'octo', avatar_url: '', type: 'User' }, body: 'Enterprise comment', created_at: '2026-07-16T00:00:00.000Z', @@ -208,7 +209,10 @@ describe('issue source operations', () => { repo: 'orca', host: 'github.acme-corp.com' }) - ).resolves.toMatchObject({ ok: true }) + ).resolves.toMatchObject({ + ok: true, + comment: { reactionSubjectId: 'IC_enterprise_9' } + }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ diff --git a/src/main/github/issues.ts b/src/main/github/issues.ts index 3ffa6dc662a..e2a4dd52677 100644 --- a/src/main/github/issues.ts +++ b/src/main/github/issues.ts @@ -473,6 +473,7 @@ export async function addIssueComment( ) const data = JSON.parse(stdout) as { id?: number + node_id?: string | null user: { login: string; avatar_url: string; type?: string } | null body?: string created_at?: string @@ -483,6 +484,7 @@ export async function addIssueComment( } const comment: PRComment = { id: data.id, + reactionSubjectId: data.node_id?.trim() || undefined, author: data.user?.login ?? 'You', authorAvatarUrl: data.user?.avatar_url ?? '', body: data.body ?? body, diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bbd26f84d6a..c137c399480 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -22,6 +22,7 @@ const { getPRChecksMock, getPRCheckDetailsMock, getPRCommentsMock, + setPRCommentReactionMock, resolveReviewThreadMock, setPRFileViewedMock, addPRReviewCommentMock, @@ -64,6 +65,7 @@ const { getPRChecksMock: vi.fn(), getPRCheckDetailsMock: vi.fn(), getPRCommentsMock: vi.fn(), + setPRCommentReactionMock: vi.fn(), resolveReviewThreadMock: vi.fn(), setPRFileViewedMock: vi.fn(), addPRReviewCommentMock: vi.fn(), @@ -123,6 +125,7 @@ vi.mock('../github/client', () => ({ getPRChecks: getPRChecksMock, getPRCheckDetails: getPRCheckDetailsMock, getPRComments: getPRCommentsMock, + setPRCommentReaction: setPRCommentReactionMock, resolveReviewThread: resolveReviewThreadMock, setPRFileViewed: setPRFileViewedMock, addPRReviewComment: addPRReviewCommentMock, @@ -212,6 +215,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockReset() getPRCheckDetailsMock.mockReset() getPRCommentsMock.mockReset() + setPRCommentReactionMock.mockReset() resolveReviewThreadMock.mockReset() setPRFileViewedMock.mockReset() addPRReviewCommentMock.mockReset() @@ -907,6 +911,7 @@ describe('registerGitHubHandlers', () => { getPRChecksMock.mockResolvedValue([]) getPRCheckDetailsMock.mockResolvedValue(null) getPRCommentsMock.mockResolvedValue([]) + setPRCommentReactionMock.mockResolvedValue(true) resolveReviewThreadMock.mockResolvedValue(true) setPRFileViewedMock.mockResolvedValue(true) addPRReviewCommentReplyMock.mockResolvedValue({ ok: true }) @@ -964,6 +969,13 @@ describe('registerGitHubHandlers', () => { prRepo, noCache: true }) + await handlers['gh:setPRCommentReaction'](null, { + repoPath: '/workspace/repo', + reactionSubjectId: ' IC_1 ', + content: '+1', + reacted: true, + prRepo + }) await handlers['gh:resolveReviewThread'](null, { repoPath: '/workspace/repo', threadId: 'thread-1', @@ -1124,6 +1136,15 @@ describe('registerGitHubHandlers', () => { null, localGitOptions ) + expect(setPRCommentReactionMock).toHaveBeenCalledWith( + '/workspace/repo', + 'IC_1', + '+1', + true, + null, + prRepo, + localGitOptions + ) expect(resolveReviewThreadMock).toHaveBeenCalledWith( '/workspace/repo', 'thread-1', diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 6fcc53bab71..91cfc94f061 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -13,6 +13,7 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, + GitHubReactionContent, PRRefreshOutcome, GitHubPRFile } from '../../shared/types' @@ -39,6 +40,7 @@ import { getPRChecks, getPRCheckDetails, getPRComments, + setPRCommentReaction, resolveReviewThread, setPRFileViewed, addPRReviewComment, @@ -667,6 +669,36 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + repoConnectionId(repo), + args.prRepo ?? null, + ...localGitOptionArgs(store, repo) + ) + } + ) + ipcMain.handle( 'gh:resolveReviewThread', async ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 526acea823b..33de03a4a09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -228,6 +228,7 @@ import type { GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput, + GitHubReactionContent, GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabProjectRef, @@ -591,6 +592,7 @@ import { getPRCheckDetails, rerunPRChecks, getPRComments, + setPRCommentReaction, getIssue, resolveReviewThread, setPRFileViewed, @@ -20147,6 +20149,25 @@ export class OrcaRuntimeService { ) } + async setRepoPRCommentReaction( + repoSelector: string, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRCommentReaction( + repo.path, + reactionSubjectId, + content, + reacted, + repo.connectionId ?? null, + prRepo ?? null, + ...this.getLocalGitExecutionOptionArgs(repo) + ) + } + async getRepoPRFileContents( repoSelector: string, args: { diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 06cc04d917e..0a283b52072 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -219,6 +219,31 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: [] }) }) + it('sets a PR comment reaction on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRCommentReaction: vi.fn().mockResolvedValue(true) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRCommentReaction', { + repo: 'repo-1', + reactionSubjectId: 'IC_1', + content: 'heart', + reacted: true, + prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' } + }) + ) + + expect(runtime.setRepoPRCommentReaction).toHaveBeenCalledWith('repo-1', 'IC_1', 'heart', true, { + owner: 'acme', + repo: 'widgets', + host: 'github.acme.test' + }) + expect(response).toMatchObject({ ok: true, result: true }) + }) + it('fetches PR file contents on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 858d22b72bb..ed715e4e0e8 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -73,6 +73,13 @@ const PullRequest = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const PRCommentReaction = RepoSelector.extend({ + reactionSubjectId: requiredString('Missing reaction subject ID'), + content: z.enum(['+1', '-1', 'laugh', 'confused', 'heart', 'hooray', 'rocket', 'eyes']), + reacted: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const PullRequestChecks = PullRequest.extend({ headSha: OptionalString }) @@ -459,6 +466,18 @@ export const GITHUB_METHODS: RpcMethod[] = [ noCache: params.noCache }) }), + defineMethod({ + name: 'github.setPRCommentReaction', + params: PRCommentReaction, + handler: async (params, { runtime }) => + runtime.setRepoPRCommentReaction( + params.repo, + params.reactionSubjectId, + params.content, + params.reacted, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.prFileContents', params: PullRequestFileContents, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index bc6abd8cc03..87c8fce5901 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -226,6 +226,7 @@ import type { MarkdownDocument, FloatingTerminalCwdRequest, GitHubIssueUpdate, + GitHubReactionContent, GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, @@ -1882,6 +1883,15 @@ export type PreloadApi = { prRepo?: GitHubOwnerRepo | null noCache?: boolean }) => Promise + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b192819496..2cf88323b23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -75,6 +75,7 @@ import type { GitHubCommentResult, GitHubCreateIssueResult, GitHubOwnerRepo, + GitHubReactionContent, GitHubWorkItem, JiraProjectStatusOrder, GitPushTarget, @@ -1470,6 +1471,16 @@ const api = { noCache?: boolean }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index 9c362b6520d..bbdca0fbe3d 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -1,6 +1,12 @@ import React from 'react' +import { SmilePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import type { GitHubReaction } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import { GITHUB_REACTION_ORDER } from '@/lib/pr-comment-reactions' +import type { GitHubReaction, GitHubReactionContent } from '../../../../shared/types' const REACTION_EMOJI: Record = { '+1': '👍', @@ -13,36 +19,160 @@ const REACTION_EMOJI: Record = { eyes: '👀' } +const REACTION_LABEL: Record = { + '+1': 'thumbs up', + '-1': 'thumbs down', + laugh: 'laugh', + confused: 'confused', + heart: 'heart', + hooray: 'hooray', + rocket: 'rocket', + eyes: 'eyes' +} + export function CommentReactions({ - reactions + reactions, + className, + onReactionChange }: { reactions?: GitHubReaction[] + className?: string + onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise | void }): React.JSX.Element | null { const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) - if (visibleReactions.length === 0) { + const [open, setOpen] = React.useState(false) + const [pendingContent, setPendingContent] = React.useState(null) + if (visibleReactions.length === 0 && !onReactionChange) { return null } + const changeReaction = async ( + content: GitHubReactionContent, + reacted: boolean + ): Promise => { + if (!onReactionChange || pendingContent) { + return + } + setPendingContent(content) + setOpen(false) + try { + await onReactionChange(content, reacted) + } finally { + setPendingContent(null) + } + } + + const pickerLabel = translate( + 'auto.components.github.CommentReactions.addReaction', + 'Add reaction' + ) + return ( -
+
{visibleReactions.map((reaction) => ( - + {onReactionChange ? ( + + ) : ( + + + {reaction.count} + )} - > - - {reaction.count} - + ))} + {onReactionChange ? ( + !pendingContent && setOpen(nextOpen)}> + + + + + + + + {pickerLabel} + + + +
+ {GITHUB_REACTION_ORDER.map((content) => { + const reaction = reactions?.find((candidate) => candidate.content === content) + const reacted = Boolean(reaction?.viewerHasReacted) + return ( + + ) + })} +
+
+
+ ) : null}
) } diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 028abac821c..805a125d565 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -25,6 +25,7 @@ import { useActiveWorktree, useRepoById } from '@/store/selectors' import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree' import { cn } from '@/lib/utils' import { openHttpLink } from '@/lib/http-link-routing' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' import { Button } from '@/components/ui/button' import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' import { @@ -64,6 +65,7 @@ import type { PRCheckDetail, PRCheckRunDetails, PRComment, + GitHubReactionContent, PRRefreshErrorType } from '../../../../shared/types' import { getConnectionId } from '@/lib/connection-context' @@ -496,6 +498,7 @@ export default function ChecksPanel(): React.JSX.Element { const fetchPRComments = useAppStore((s) => s.fetchPRComments) const addPRConversationComment = useAppStore((s) => s.addPRConversationComment) const addPRReviewCommentReply = useAppStore((s) => s.addPRReviewCommentReply) + const setPRCommentReaction = useAppStore((s) => s.setPRCommentReaction) const resolveReviewThread = useAppStore((s) => s.resolveReviewThread) const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) const remoteDetectedAgentIds = useAppStore((s) => { @@ -2941,6 +2944,45 @@ export default function ChecksPanel(): React.JSX.Element { [pr?.prRepo, confirm] ) + const handleSetReaction = useCallback( + async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise => { + const reactionSubjectId = comment.reactionSubjectId + if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) { + return + } + const requestKey = checksPanelAsyncResultKey( + prCacheKey, + branch, + prNumber, + pr.prRepo, + pr.headSha + ) + const previousReaction = comment.reactions?.find((reaction) => reaction.content === content) + setComments((current) => setReactionOnSubject(current, reactionSubjectId, content, reacted)) + const ok = await setPRCommentReaction( + repo.path, + prNumber, + reactionSubjectId, + content, + reacted, + { repoId: repo.id, prRepo: pr.prRepo } + ) + if (!isCurrentAsyncResult(requestKey) || ok) { + return + } + setComments((current) => + restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) + ) + toast.error( + translate( + 'auto.components.right.sidebar.ChecksPanel.updateReactionFailed', + 'Failed to update reaction.' + ) + ) + }, + [branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction] + ) + const handleReplyToComment = useCallback( async (comment: PRComment, body: string, options: { notifyOnFailure?: boolean } = {}) => { const notifyOnFailure = options.notifyOnFailure !== false @@ -4506,6 +4548,7 @@ export default function ChecksPanel(): React.JSX.Element { onResolve={pr || activeGitLabReview ? handleResolve : undefined} onEditComment={pr ? handleEditComment : undefined} onDeleteComment={pr ? handleDeleteComment : undefined} + onSetReaction={canTargetPRComments ? handleSetReaction : undefined} /> void onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment, botAuthorOverrides) @@ -2007,13 +2015,17 @@ function CommentRow({
) : ( - +
+ + onSetReaction(comment, content, reacted) + : undefined + } + /> +
)}
@@ -2036,6 +2048,7 @@ function PRCommentGroupView({ onReply, onEditComment, onDeleteComment, + onSetReaction, onQueueForAgent }: { group: PRCommentGroup @@ -2053,6 +2066,11 @@ function PRCommentGroupView({ onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { // Reply targets a specific comment id so any comment in a thread — root or @@ -2099,6 +2117,7 @@ function PRCommentGroupView({ onResolve, onEditComment, onDeleteComment, + onSetReaction, onQueueForAgent } @@ -2181,7 +2200,8 @@ function ResolvedCommentGroupsSection({ onCancelReply, onReply, onEditComment, - onDeleteComment + onDeleteComment, + onSetReaction }: { groups: PRCommentGroup[] botAuthorOverrides: ReadonlySet @@ -2195,6 +2215,11 @@ function ResolvedCommentGroupsSection({ onReply?: (comment: PRComment, body: string) => Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element | null { if (groups.length === 0) { return null @@ -2230,6 +2255,7 @@ function ResolvedCommentGroupsSection({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} /> ))} @@ -2296,7 +2322,8 @@ export function PRCommentsList({ onReply, onResolve, onEditComment, - onDeleteComment + onDeleteComment, + onSetReaction }: { comments: PRComment[] commentsLoading: boolean @@ -2313,6 +2340,11 @@ export function PRCommentsList({ onResolve?: (threadId: string, resolve: boolean) => boolean | Promise onEditComment?: (comment: PRComment, body: string) => Promise onDeleteComment?: (comment: PRComment) => void | Promise + onSetReaction?: ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ) => Promise }): React.JSX.Element { const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('all') @@ -2435,6 +2467,7 @@ export function PRCommentsList({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} onQueueForAgent={canQueue ? () => addGroupToSelection(groupId) : undefined} /> ) @@ -2765,6 +2798,7 @@ export function PRCommentsList({ onReply={onReply} onEditComment={onEditComment} onDeleteComment={onDeleteComment} + onSetReaction={onSetReaction} /> )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3184344d835..baadf663b56 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2412,6 +2412,11 @@ "label": "Close as duplicate", "description": "Duplicate of another issue" } + }, + "CommentReactions": { + "addReaction": "Add reaction", + "removeNamedReaction": "Remove {{value0}} reaction", + "addNamedReaction": "Add {{value0}} reaction" } }, "linear": { @@ -10566,7 +10571,8 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "Could not resolve the GitHub PR to reply on." + "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", + "updateReactionFailed": "Failed to update reaction." }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", diff --git a/src/renderer/src/lib/pr-comment-reactions.test.ts b/src/renderer/src/lib/pr-comment-reactions.test.ts new file mode 100644 index 00000000000..d742fde85d8 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { PRComment } from '../../../shared/types' +import { + restoreCommentReaction, + setCommentReaction, + setReactionOnSubject +} from './pr-comment-reactions' + +function comment(overrides: Partial = {}): PRComment { + return { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'Looks good', + createdAt: '2026-08-09T12:00:00Z', + url: 'https://github.com/acme/orca/pull/1#issuecomment-1', + ...overrides + } +} + +describe('setCommentReaction', () => { + it('adds a viewer reaction in GitHub order', () => { + expect( + setCommentReaction( + comment({ reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] }), + '+1', + true + ).reactions + ).toEqual([ + { content: '+1', count: 1, viewerHasReacted: true }, + { content: 'heart', count: 2, viewerHasReacted: false } + ]) + }) + + it('removes the final viewer reaction', () => { + expect( + setCommentReaction( + comment({ reactions: [{ content: 'rocket', count: 1, viewerHasReacted: true }] }), + 'rocket', + false + ).reactions + ).toBeUndefined() + }) + + it('updates only the targeted comment', () => { + const target = comment({ reactionSubjectId: 'IC_1' }) + const other = comment({ id: 2, reactionSubjectId: 'IC_2' }) + const result = setReactionOnSubject([target, other], 'IC_1', 'eyes', true) + + expect(result[0].reactions).toEqual([{ content: 'eyes', count: 1, viewerHasReacted: true }]) + expect(result[1]).toBe(other) + }) + + it('restores only the failed reaction while preserving concurrent updates', () => { + const result = restoreCommentReaction( + comment({ + reactions: [ + { content: 'heart', count: 3, viewerHasReacted: true }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ] + }), + 'heart', + { content: 'heart', count: 2, viewerHasReacted: false } + ) + + expect(result.reactions).toEqual([ + { content: 'heart', count: 2, viewerHasReacted: false }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ]) + }) +}) diff --git a/src/renderer/src/lib/pr-comment-reactions.ts b/src/renderer/src/lib/pr-comment-reactions.ts new file mode 100644 index 00000000000..f124f082d17 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-reactions.ts @@ -0,0 +1,83 @@ +import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../shared/types' + +export const GITHUB_REACTION_ORDER: readonly GitHubReactionContent[] = [ + '+1', + '-1', + 'laugh', + 'confused', + 'heart', + 'hooray', + 'rocket', + 'eyes' +] + +function sortReactions(reactions: GitHubReaction[]): GitHubReaction[] { + return reactions.sort( + (left, right) => + GITHUB_REACTION_ORDER.indexOf(left.content) - GITHUB_REACTION_ORDER.indexOf(right.content) + ) +} + +export function setCommentReaction( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean +): PRComment { + const reactions = comment.reactions ?? [] + const current = reactions.find((reaction) => reaction.content === content) + if (Boolean(current?.viewerHasReacted) === reacted) { + return comment + } + + const nextCount = Math.max(0, (current?.count ?? 0) + (reacted ? 1 : -1)) + const nextReaction: GitHubReaction = { + content, + count: nextCount, + viewerHasReacted: reacted + } + const nextReactions = sortReactions( + reactions + .filter((reaction) => reaction.content !== content) + .concat(nextCount > 0 ? nextReaction : []) + ) + + return { ...comment, reactions: nextReactions.length > 0 ? nextReactions : undefined } +} + +export function restoreCommentReaction( + comment: PRComment, + content: GitHubReactionContent, + previousReaction?: GitHubReaction +): PRComment { + const reactions = sortReactions([ + ...(comment.reactions ?? []).filter((reaction) => reaction.content !== content), + ...(previousReaction ? [previousReaction] : []) + ]) + return { ...comment, reactions: reactions.length > 0 ? reactions : undefined } +} + +export function setReactionOnSubject( + comments: readonly PRComment[], + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean +): PRComment[] { + return comments.map((comment) => + comment.reactionSubjectId === reactionSubjectId + ? setCommentReaction(comment, content, reacted) + : comment + ) +} + +export function restoreReactionOnSubject( + comments: readonly PRComment[], + reactionSubjectId: string, + content: GitHubReactionContent, + previousReaction?: GitHubReaction +): PRComment[] { + return comments.map((comment) => + comment.reactionSubjectId === reactionSubjectId + ? restoreCommentReaction(comment, content, previousReaction) + : comment + ) +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c3947d58c28..cadbc5acb8f 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -45,6 +45,7 @@ const mockApi = { prComments: vi.fn().mockResolvedValue([]), addIssueComment: vi.fn(), addPRReviewCommentReply: vi.fn(), + setPRCommentReaction: vi.fn(), resolveReviewThread: vi.fn(), listWorkItems: vi.fn(), countWorkItems: vi.fn().mockResolvedValue(0), @@ -1503,6 +1504,7 @@ describe('createGitHubSlice PR comment mutations', () => { url: '' } }) + mockApi.gh.setPRCommentReaction.mockResolvedValue(true) }) it('deduplicates merged PR comments and preserves existing thread metadata', () => { @@ -1666,6 +1668,153 @@ describe('createGitHubSlice PR comment mutations', () => { ).toMatchObject({ body: 'reply', threadId: 'PRRT_1', path: 'src/a.ts', line: 8 }) }) + it('updates cached reactions through local GitHub IPC', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const cacheKey = `${repoId}::pr-comments::acme/widgets::12` + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { + [cacheKey]: { + data: [ + { + id: 10, + reactionSubjectId: 'IC_10', + author: 'reviewer', + authorAvatarUrl: '', + body: 'Nice', + createdAt: '2026-03-28T00:00:00Z', + url: '' + } + ], + fetchedAt: 1 + } + } + } as unknown as Partial) + + await store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(mockApi.gh.setPRCommentReaction).toHaveBeenCalledWith({ + repoPath, + repoId, + reactionSubjectId: 'IC_10', + content: 'heart', + reacted: true, + prRepo: { owner: 'Acme', repo: 'Widgets' }, + sourceContext: undefined + }) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([ + { content: 'heart', count: 1, viewerHasReacted: true } + ]) + }) + + it('rolls back an optimistic reaction when GitHub rejects it', async () => { + let resolveReaction!: (ok: boolean) => void + mockApi.gh.setPRCommentReaction.mockImplementationOnce( + () => new Promise((resolve) => (resolveReaction = resolve)) + ) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + const cacheKey = `${repoId}::pr-comments::acme/widgets::12` + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + commentsCache: { + [cacheKey]: { + data: [ + { + id: 10, + reactionSubjectId: 'IC_10', + author: 'reviewer', + authorAvatarUrl: '', + body: 'Nice', + createdAt: '2026-03-28T00:00:00Z', + url: '', + reactions: [{ content: 'heart', count: 2, viewerHasReacted: false }] + } + ], + fetchedAt: 1 + } + } + } as unknown as Partial) + + const pending = store.getState().setPRCommentReaction(repoPath, 12, 'IC_10', 'heart', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + const optimisticEntry = store.getState().commentsCache[cacheKey] + store.setState({ + commentsCache: { + ...store.getState().commentsCache, + [cacheKey]: { + ...optimisticEntry, + data: (optimisticEntry?.data ?? []).map((comment) => ({ + ...comment, + reactions: [ + ...(comment.reactions ?? []), + { content: 'eyes' as const, count: 1, viewerHasReacted: false } + ] + })) + } + } + }) + resolveReaction(false) + const ok = await pending + + expect(ok).toBe(false) + expect(store.getState().commentsCache[cacheKey]?.data?.[0].reactions).toEqual([ + { content: 'heart', count: 2, viewerHasReacted: false }, + { content: 'eyes', count: 1, viewerHasReacted: false } + ]) + }) + + it('routes reaction mutations through the workspace runtime', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-pr-reaction', + ok: true, + result: true, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-id' + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos: [ + { + id: repoId, + path: repoPath, + name: 'repo', + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + } as unknown as Partial) + + await store.getState().setPRCommentReaction(repoPath, 12, 'PRRC_10', 'rocket', true, { + repoId, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.setPRCommentReaction', + params: { + repo: repoId, + reactionSubjectId: 'PRRC_10', + content: 'rocket', + reacted: true, + prRepo: { owner: 'Acme', repo: 'Widgets' } + }, + timeoutMs: 30_000 + }) + expect(mockApi.gh.setPRCommentReaction).not.toHaveBeenCalled() + }) + it('does not mutate the PR comments cache when GitHub omits the comment payload', async () => { mockApi.gh.addIssueComment.mockResolvedValueOnce({ ok: true }) const store = createTestStore() diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 599ef6cd960..21b66f2e9bd 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -17,6 +17,7 @@ import type { PRRefreshErrorType, PRRefreshOutcome, GitHubCommentResult, + GitHubReactionContent, IssueInfo, PRCheckDetail, PRCheckRunDetails, @@ -74,6 +75,7 @@ import { type TaskSourceContext } from '../../../../shared/task-source-context' import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' +import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -1890,6 +1892,14 @@ export type GitHubSlice = { line?: number } ) => Promise + setPRCommentReaction: ( + repoPath: string, + prNumber: number, + reactionSubjectId: string, + content: GitHubReactionContent, + reacted: boolean, + options?: RepoScopedFetchOptions & { prRepo?: GitHubOwnerRepo | null } + ) => Promise resolveReviewThread: ( repoPath: string, prNumber: number, @@ -3839,6 +3849,115 @@ export const createGitHubSlice: StateCreator = (s return { ok: true, comment } }, + setPRCommentReaction: async ( + repoPath, + prNumber, + reactionSubjectId, + content, + reacted, + options + ) => { + const repo = get().repos?.find((candidate) => + options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath + ) + const repoId = options?.repoId ?? repo?.id + const requestSettings = getGitHubRepoSourceSettings( + get().settings, + repo, + options?.sourceContext + ) + const cacheKey = sourceScopedRepoCacheKey( + repoPath, + repoId, + prCommentsCacheSuffix(prNumber, options?.prRepo), + requestSettings, + repo?.connectionId, + repo?.executionHostId, + options?.sourceContext, + repo !== undefined + ) + const previousComment = get().commentsCache[cacheKey]?.data?.find( + (comment) => comment.reactionSubjectId === reactionSubjectId + ) + const previousReaction = previousComment?.reactions?.find( + (reaction) => reaction.content === content + ) + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: setReactionOnSubject(entry.data, reactionSubjectId, content, reacted) + } + } + } + }) + + const requestContext = getGitHubWorkItemRequestContext( + get(), + requestSettings, + repoId ?? repoPath, + repoPath, + options?.sourceContext + ) + let ok = false + try { + ok = + requestContext.target.kind === 'environment' + ? await callRuntimeRpc( + { kind: 'environment', environmentId: requestContext.target.environmentId }, + 'github.setPRCommentReaction', + { + repo: requestContext.target.runtimeRepoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.setPRCommentReaction({ + repoPath, + repoId, + reactionSubjectId, + content, + reacted, + prRepo: options?.prRepo ?? null, + sourceContext: options?.sourceContext + }) + } catch (err) { + console.error('Failed to update PR comment reaction:', err) + } + if (!ok && previousComment) { + set((state) => { + const entry = state.commentsCache[cacheKey] + if (!entry?.data) { + return state + } + return { + commentsCache: { + ...state.commentsCache, + [cacheKey]: { + ...entry, + data: restoreReactionOnSubject( + entry.data, + reactionSubjectId, + content, + previousReaction + ) + } + } + } + }) + } + return ok + }, + resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => { const repo = get().repos?.find((candidate) => options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 92dd209d047..cc508040e49 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -3769,6 +3769,7 @@ describe('web GitHub preload API', () => { 'resolveProjectRef', 'resolveReviewThread', 'setPRAutoMerge', + 'setPRCommentReaction', 'setPRFileViewed', 'starOrca', 'updateIssue', @@ -3950,6 +3951,17 @@ describe('web GitHub preload API', () => { expectedMethod: 'github.prComments', expectedParams: withRepo({ repoPath, prNumber: 7, noCache: true }) }, + { + key: 'setPRCommentReaction', + args: { repoPath, reactionSubjectId: 'IC_1', content: 'heart', reacted: true }, + expectedMethod: 'github.setPRCommentReaction', + expectedParams: withRepo({ + repoPath, + reactionSubjectId: 'IC_1', + content: 'heart', + reacted: true + }) + }, { key: 'resolveReviewThread', args: { repoPath, threadId: 'thread-1', resolve: true }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6f049d237c8..038e05e1373 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -289,6 +289,7 @@ type WebGitHubRouteKey = | 'prCheckDetails' | 'rerunPRChecks' | 'prComments' + | 'setPRCommentReaction' | 'resolveReviewThread' | 'setPRFileViewed' | 'updatePRTitle' @@ -337,6 +338,7 @@ type WebGitHubRuntimeMethod = | 'github.prCheckDetails' | 'github.rerunPRChecks' | 'github.prComments' + | 'github.setPRCommentReaction' | 'github.resolveReviewThread' | 'github.setPRFileViewed' | 'github.updatePRTitle' @@ -438,6 +440,7 @@ export const GITHUB_WEB_RPC_METHODS = { prCheckDetails: 'github.prCheckDetails', rerunPRChecks: 'github.rerunPRChecks', prComments: 'github.prComments', + setPRCommentReaction: 'github.setPRCommentReaction', resolveReviewThread: 'github.resolveReviewThread', setPRFileViewed: 'github.setPRFileViewed', updatePRTitle: 'github.updatePRTitle', @@ -2385,6 +2388,11 @@ function createGitHubApi(): WebGitHubApi { route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), prComments: (args) => route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), resolveReviewThread: (args) => route>( GITHUB_WEB_RPC_METHODS.resolveReviewThread, diff --git a/src/shared/types.ts b/src/shared/types.ts index c6dfff01d3c..9eb3de46c3b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1485,6 +1485,7 @@ export type GitHubReactionContent = export type GitHubReaction = { content: GitHubReactionContent count: number + viewerHasReacted?: boolean } export type PRComment = { @@ -1495,6 +1496,8 @@ export type PRComment = { createdAt: string url: string reactions?: GitHubReaction[] + /** GraphQL node ID for GitHub comments that support reaction mutations. */ + reactionSubjectId?: string /** File path for inline review comments (absent for top-level conversation comments). */ path?: string /** GraphQL node ID of the review thread — present only for inline review comments. diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index c6784a2f282..b44aaa84c3c 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -15,6 +15,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [ body: 'Please update this handler before merge.', createdAt: '2026-05-14T10:00:00.000Z', url: 'https://github.com/acme/orca/pull/73#discussion_r101', + reactionSubjectId: 'PRRC_101', threadId: 'thread-open', path: 'src/handler.ts', isResolved: false @@ -25,7 +26,8 @@ export const FIXTURE_COMMENTS: PRComment[] = [ authorAvatarUrl: '', body: 'LGTM on the overall approach.', createdAt: '2026-05-14T11:00:00.000Z', - url: 'https://github.com/acme/orca/pull/73#issuecomment-102' + url: 'https://github.com/acme/orca/pull/73#issuecomment-102', + reactionSubjectId: 'IC_102' }, { id: 103, @@ -72,7 +74,8 @@ export async function seedPRCommentsSidebarFixture(page: Page): Promise [], fetchPRComments: async () => comments, + setPRCommentReaction: async () => true, fetchUpstreamStatus: async () => undefined, setUpstreamStatus: () => undefined })) diff --git a/tests/e2e/helpers/source-control-ai-generation.ts b/tests/e2e/helpers/source-control-ai-generation.ts index c408c6c4a6a..149f6a019d3 100644 --- a/tests/e2e/helpers/source-control-ai-generation.ts +++ b/tests/e2e/helpers/source-control-ai-generation.ts @@ -31,22 +31,30 @@ export async function openChecks(page: Page, worktreeId: string): Promise const state = window.__store?.getState() state?.setActiveWorktree(targetWorktreeId) state?.setRightSidebarOpen(true) - state?.setRightSidebarTab('checks') }, worktreeId) await expect .poll( () => page.evaluate((targetWorktreeId) => { const state = window.__store?.getState() - return ( - state?.activeWorktreeId === targetWorktreeId && - state.rightSidebarOpen && - state.rightSidebarTab === 'checks' - ) + return state?.activeWorktreeId === targetWorktreeId && state.rightSidebarOpen }, worktreeId), { timeout: 5_000 } ) .toBe(true) + const checksButton = page.getByRole('button', { name: 'Checks', exact: true }) + await expect + .poll( + async () => { + if ((await page.evaluate(() => window.__store?.getState().rightSidebarTab)) !== 'checks') { + await checksButton.click() + } + await page.waitForTimeout(250) + return page.evaluate(() => window.__store?.getState().rightSidebarTab) + }, + { timeout: 10_000 } + ) + .toBe('checks') } export async function seedCreatePrComposer(page: Page): Promise<{ diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index 9cccce81bfb..ee95b3f74cb 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -124,6 +124,39 @@ test.describe('PR comments sidebar cards view', () => { expect(positions[1]).toBeLessThan(positions[2]) }) + test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => { + const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) + await openChecks(orcaPage, worktreeId) + await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + + const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'LGTM on the overall approach.' + }) + const conversationReactionButton = conversationCard.getByRole('button', { + name: 'Add reaction' + }) + await conversationReactionButton.evaluate((element) => (element as HTMLElement).click()) + const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' }) + await expect(heartReactionButton).toBeVisible() + await heartReactionButton.evaluate((element) => (element as HTMLElement).click()) + await expect( + conversationCard.getByRole('button', { name: '1 heart reaction' }) + ).toHaveAttribute('aria-pressed', 'true') + await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0) + + const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'Please update this handler before merge.' + }) + const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await threadReactionButton.evaluate((element) => (element as HTMLElement).click()) + await orcaPage + .getByRole('button', { name: 'Add rocket reaction' }) + .evaluate((element) => (element as HTMLElement).click()) + await expect( + reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + ).toHaveAttribute('aria-pressed', 'true') + }) + test('queues an open thread for the agent from the visible row action and menu fallback', async ({ orcaPage }) => { From f2b2ece83184070f2d6017e9f4dea4c37ca0044f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:09 -0700 Subject: [PATCH 16/20] perf(renderer): reuse locale collators (#13444) --- .../locale-collator-sort-benchmark.mjs | 147 ++++++++++++++++++ src/renderer/src/components/TaskPage.tsx | 3 +- .../src/components/jira-issue-sorter.ts | 3 +- .../native-chat/native-chat-picker-items.ts | 5 +- .../src/lib/browser-palette-search.ts | 3 +- .../src/lib/locale-text-collators.test.ts | 77 +++++++++ src/renderer/src/lib/locale-text-collators.ts | 13 ++ .../src/lib/simulator-palette-search.ts | 3 +- .../src/lib/workspace-tab-palette-results.ts | 3 +- 9 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 config/scripts/locale-collator-sort-benchmark.mjs create mode 100644 src/renderer/src/lib/locale-text-collators.test.ts create mode 100644 src/renderer/src/lib/locale-text-collators.ts diff --git a/config/scripts/locale-collator-sort-benchmark.mjs b/config/scripts/locale-collator-sort-benchmark.mjs new file mode 100644 index 00000000000..8a68331bd44 --- /dev/null +++ b/config/scripts/locale-collator-sort-benchmark.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Benchmarks optioned localeCompare calls used by renderer list comparators. +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' +import { createJiti } from 'jiti' + +const ROUND_COUNT = 5 +const MIN_ROUND_MS = 120 +const jiti = createJiti(import.meta.url, { + alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) } +}) +const { compareBaseSensitivityLocaleText } = await jiti.import( + '../../src/renderer/src/lib/locale-text-collators.ts' +) +const { sortJiraIssues } = await jiti.import( + '../../src/renderer/src/components/jira-issue-sorter.ts' +) + +let randomState = 0x9e3779b9 +function random() { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return randomState / 0x100000000 +} + +function shuffle(values) { + for (let index = values.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)) + ;[values[index], values[swapIndex]] = [values[swapIndex], values[index]] + } + return values +} + +const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström'] + +function makeJiraIssues(count) { + return shuffle( + Array.from({ length: count }, (_, index) => ({ + key: `TASK-${(index * 37) % (count + 17)}`, + title: labels[index % labels.length], + updatedAt: '2026-01-01T00:00:00.000Z' + })) + ) +} + +function makeBaseSensitivityValues(count) { + return shuffle( + Array.from( + { length: count }, + (_, index) => + `${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}` + ) + ) +} + +function calibrate(run) { + for (let index = 0; index < 5; index += 1) { + run() + } + let iterations = 1 + while (true) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + if (performance.now() - startedAt >= MIN_ROUND_MS) { + break + } + iterations *= 2 + } + return iterations +} + +function measureRound(run, iterations) { + const startedAt = performance.now() + for (let index = 0; index < iterations; index += 1) { + run() + } + return (performance.now() - startedAt) / iterations +} + +function measurePair(before, after) { + const beforeIterations = calibrate(before) + const afterIterations = calibrate(after) + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < ROUND_COUNT; round += 1) { + if (round % 2 === 0) { + beforeSamples.push(measureRound(before, beforeIterations)) + afterSamples.push(measureRound(after, afterIterations)) + } else { + afterSamples.push(measureRound(after, afterIterations)) + beforeSamples.push(measureRound(before, beforeIterations)) + } + } + const middle = Math.floor(ROUND_COUNT / 2) + return { + beforeMs: beforeSamples.sort((a, b) => a - b)[middle], + afterMs: afterSamples.sort((a, b) => a - b)[middle] + } +} + +function assertSameOrder(before, after, label) { + const expected = before() + const actual = after() + if ( + expected.length !== actual.length || + expected.some((value, index) => value !== actual[index]) + ) { + throw new Error(`${label} sort order changed`) + } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.') +console.log( + `${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}` +) + +for (const count of [36, 50, 250]) { + const issues = makeJiraIssues(count) + const before = () => + [...issues] + .sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) + .map((issue) => issue.key) + const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key) + assertSameOrder(before, after, `numeric ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +for (const count of [10, 50, 250]) { + const values = makeBaseSensitivityValues(count) + const before = () => + [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + const after = () => [...values].sort(compareBaseSensitivityLocaleText) + assertSameOrder(before, after, `base ${count}`) + const { beforeMs, afterMs } = measurePair(before, after) + console.log( + `${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.' +) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 6b86b16a000..520f2debbdd 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -43,6 +43,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' import { getSettingsFocusedExecutionHostId, parseExecutionHostId, @@ -846,7 +847,7 @@ function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrde return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() } if (orderBy === 'identifier') { - return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + return compareNumericLocaleText(a.identifier, b.identifier) } const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts index a95680c1c34..875aaff0ef0 100644 --- a/src/renderer/src/components/jira-issue-sorter.ts +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -1,4 +1,5 @@ import type { JiraIssue, JiraPriority } from '../../../shared/types' +import { compareNumericLocaleText } from '@/lib/locale-text-collators' export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' @@ -57,7 +58,7 @@ export function sortJiraIssues( return [...issues].sort((a, b) => { let comparison = 0 if (orderBy === 'key') { - comparison = a.key.localeCompare(b.key, undefined, { numeric: true }) + comparison = compareNumericLocaleText(a.key, b.key) } else if (orderBy === 'title') { comparison = a.title.localeCompare(b.title) } else if (orderBy === 'status') { diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts index 2592ca0acab..6938c2078a4 100644 --- a/src/renderer/src/components/native-chat/native-chat-picker-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -4,6 +4,7 @@ import { isSafeDisplayCharacter, stripUnsafeDisplayCharacters } from '../../../../shared/skill-display-text' +import { compareBaseSensitivityLocaleText } from '@/lib/locale-text-collators' // Send classification lives in shared so mobile gates optimistic echoes with // the same rules; re-exported here to keep renderer import paths stable. @@ -191,7 +192,7 @@ function sanitizePickerText(value: string, maxLength: number): string { function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { return ( SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + compareBaseSensitivityLocaleText(a.name, b.name) || a.skillFilePath.localeCompare(b.skillFilePath) ) } @@ -202,7 +203,7 @@ function comparePickerSkills( ): number { return ( SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + compareBaseSensitivityLocaleText(a.name, b.name) ) } diff --git a/src/renderer/src/lib/browser-palette-search.ts b/src/renderer/src/lib/browser-palette-search.ts index 94233164037..7c76e9f9ead 100644 --- a/src/renderer/src/lib/browser-palette-search.ts +++ b/src/renderer/src/lib/browser-palette-search.ts @@ -1,6 +1,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../shared/constants' import type { BrowserPage, BrowserWorkspace, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -43,7 +44,7 @@ export function isBrowserPaletteQueryTooLarge( } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } export function isBlankBrowserUrl(url: string): boolean { diff --git a/src/renderer/src/lib/locale-text-collators.test.ts b/src/renderer/src/lib/locale-text-collators.test.ts new file mode 100644 index 00000000000..63a15ba5b63 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest' +import { compareBaseSensitivityLocaleText, compareNumericLocaleText } from './locale-text-collators' + +function expectComparatorParity( + values: readonly string[], + legacy: (a: string, b: string) => number, + current: (a: string, b: string) => number +): void { + for (const a of values) { + for (const b of values) { + expect(Math.sign(current(a, b))).toBe(Math.sign(legacy(a, b))) + } + } + expect([...values].sort(current)).toEqual([...values].sort(legacy)) +} + +describe('locale text collators', () => { + it('preserves base-sensitivity localeCompare ordering', () => { + const values = [ + 'zeta', + 'Zeta', + 'Álpha', + 'alpha', + 'BÉTA', + 'beta', + 'café', + 'Cafe', + 'a-b', + 'a b', + 'a_b', + 'Ångström', + 'alpha' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: 'base' }) + + expectComparatorParity(values, legacy, compareBaseSensitivityLocaleText) + }) + + it('preserves numeric localeCompare ordering and ties', () => { + const values = [ + 'TASK-10', + 'TASK-2', + 'TASK-02', + 'TASK-1', + 'TASK-20', + 'TASK-11', + 'TASK_2', + 'task-2', + 'TASK-2' + ] + const legacy = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true }) + + expectComparatorParity(values, legacy, compareNumericLocaleText) + }) + + it('constructs each collator only when its comparison mode is first used', async () => { + vi.resetModules() + const NativeCollator = Intl.Collator + const collatorSpy = vi + .spyOn(Intl, 'Collator') + .mockImplementation(function Collator(locales, options) { + return new NativeCollator(locales, options) + }) + try { + const comparison = await import('./locale-text-collators') + expect(collatorSpy).not.toHaveBeenCalled() + comparison.compareNumericLocaleText('TASK-2', 'TASK-10') + comparison.compareNumericLocaleText('TASK-3', 'TASK-11') + expect(collatorSpy).toHaveBeenCalledTimes(1) + comparison.compareBaseSensitivityLocaleText('café', 'Cafe') + comparison.compareBaseSensitivityLocaleText('Álpha', 'alpha') + expect(collatorSpy).toHaveBeenCalledTimes(2) + } finally { + collatorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/locale-text-collators.ts b/src/renderer/src/lib/locale-text-collators.ts new file mode 100644 index 00000000000..d8e7bccda12 --- /dev/null +++ b/src/renderer/src/lib/locale-text-collators.ts @@ -0,0 +1,13 @@ +let baseSensitivityCollator: Intl.Collator | undefined +let numericCollator: Intl.Collator | undefined + +export function compareBaseSensitivityLocaleText(a: string, b: string): number { + // Why: stay lazy like localeCompare while resolving ICU options only once. + baseSensitivityCollator ??= new Intl.Collator(undefined, { sensitivity: 'base' }) + return baseSensitivityCollator.compare(a, b) +} + +export function compareNumericLocaleText(a: string, b: string): number { + numericCollator ??= new Intl.Collator(undefined, { numeric: true }) + return numericCollator.compare(a, b) +} diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index d1c7b47779b..148d723f8f2 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import type { Tab, TabGroup, Worktree } from '../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' @@ -67,7 +68,7 @@ export type BuildSearchableSimulatorTabsOptions = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null { diff --git a/src/renderer/src/lib/workspace-tab-palette-results.ts b/src/renderer/src/lib/workspace-tab-palette-results.ts index 18570802abc..6a34cfbd95a 100644 --- a/src/renderer/src/lib/workspace-tab-palette-results.ts +++ b/src/renderer/src/lib/workspace-tab-palette-results.ts @@ -1,4 +1,5 @@ import { selectPaletteTypeAliasMatch } from './palette-type-alias-match' +import { compareBaseSensitivityLocaleText } from './locale-text-collators' import { resolveWorktreeDisplayName } from './worktree-default-display-name' import type { MatchRange } from './worktree-palette-search' import type { @@ -27,7 +28,7 @@ export type WorkspaceTabPaletteSearchResult = { } function compareText(a: string, b: string): number { - return a.localeCompare(b, undefined, { sensitivity: 'base' }) + return compareBaseSensitivityLocaleText(a, b) } function findRange(text: string, query: string): MatchRange | null { From 8859e73980d2cf81eaad465b258bbb13d54aab8e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:24:33 -0700 Subject: [PATCH 17/20] perf(main): retire stale worktree marker probes (#13437) --- ...worktree-base-pending-marker-benchmark.mjs | 117 ++++++++++++++++++ .../worktree-base-directory-poller.test.ts | 61 +++++++++ .../ipc/worktree-base-directory-poller.ts | 43 ++++--- 3 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 config/scripts/worktree-base-pending-marker-benchmark.mjs diff --git a/config/scripts/worktree-base-pending-marker-benchmark.mjs b/config/scripts/worktree-base-pending-marker-benchmark.mjs new file mode 100644 index 00000000000..9240acd22ad --- /dev/null +++ b/config/scripts/worktree-base-pending-marker-benchmark.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs +import fs from 'node:fs' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { syncBuiltinESMExports } from 'node:module' +import os from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { createJiti } from 'jiti' + +const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64') +const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900') +const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300') + +for (const [name, value] of [ + ['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT], + ['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS], + ['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} +if (STEADY_TICKS >= TOTAL_TICKS) { + throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks') +} + +const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-')) +const markerPaths = new Set() +for (let index = 0; index < CANDIDATE_COUNT; index += 1) { + const candidate = join(root, `ordinary-folder-${index}`) + await mkdir(candidate) + markerPaths.add(join(candidate, '.git')) +} + +const originalStat = fs.promises.stat +let visibleTick = 0 +const markerStatTicks = [] +fs.promises.stat = async (path, ...args) => { + if (markerPaths.has(String(path))) { + markerStatTicks.push(visibleTick) + } + return originalStat(path, ...args) +} +syncBuiltinESMExports() + +let poller +let timeout +try { + const jiti = createJiti(import.meta.url) + const { startWorktreeBaseDirectoryPoller } = await jiti.import( + '../../src/main/ipc/worktree-base-directory-poller.ts' + ) + const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false } + const target = { + key: `base:local:${root}`, + kind: 'base', + path: root, + repos: new Map([[repo.repoId, repo]]) + } + let finish + let fail + const completed = new Promise((resolve, reject) => { + finish = resolve + fail = reject + }) + timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000) + const startedAt = performance.now() + poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { + pollIntervalMs: 0, + visibility: { + isWindowVisible: () => { + visibleTick += 1 + if (visibleTick > TOTAL_TICKS) { + finish() + return false + } + return true + }, + onWindowBecameVisible: () => () => {} + } + } + ) + await completed + clearTimeout(timeout) + await poller.unsubscribe() + poller = undefined + + const steadyStartTick = TOTAL_TICKS - STEADY_TICKS + const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length + const totalMarkerStats = markerStatTicks.length + const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS + const elapsedMs = performance.now() - startedAt + + console.log('Worktree-base marker cooldown benchmark. Lower is better.') + console.log( + JSON.stringify({ + candidates: CANDIDATE_COUNT, + totalTicks: TOTAL_TICKS, + steadyTicks: STEADY_TICKS, + totalMarkerStats, + steadyMarkerStats, + statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)), + elapsedMs: Number(elapsedMs.toFixed(1)) + }) + ) +} finally { + clearTimeout(timeout) + await poller?.unsubscribe() + fs.promises.stat = originalStat + syncBuiltinESMExports() + await rm(root, { recursive: true, force: true }) +} diff --git a/src/main/ipc/worktree-base-directory-poller.test.ts b/src/main/ipc/worktree-base-directory-poller.test.ts index 09cda0ed480..e28823adbc8 100644 --- a/src/main/ipc/worktree-base-directory-poller.test.ts +++ b/src/main/ipc/worktree-base-directory-poller.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeFileSync } from 'node:fs' import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createWorktreePollerWindowVisibility, startWorktreeBaseDirectoryPoller, + WORKTREE_BASE_BACKSTOP_TICKS, type WorktreeBasePollEvent, type WorktreePollerWindowVisibility } from './worktree-base-directory-poller' @@ -204,6 +206,65 @@ describe('worktree base directory poller', () => { expect(fullScans.length).toBeGreaterThan(0) }) + it('retires stale marker probes while preserving backstop detection and path reuse', async () => { + const root = await makeRoot() + const ordinaryFolder = join(root, 'ordinary-folder') + const markerPath = join(ordinaryFolder, '.git') + await mkdir(ordinaryFolder) + + const received: WorktreeBasePollEvent[][] = [] + const target = makeTarget('base', root) + let fullScans = 0 + let pendingMarkerProbes = 0 + let writeMarkerOnNextProbe = false + const pendingMarkerMaxTicks = WORKTREE_BASE_BACKSTOP_TICKS * 2 + const markerCreationTick = pendingMarkerMaxTicks * 2 + WORKTREE_BASE_BACKSTOP_TICKS * 4 + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(events), + { + pollIntervalMs: 0, + pendingMarkerMaxTicks, + onFullScan: () => { + fullScans += 1 + if (fullScans * WORKTREE_BASE_BACKSTOP_TICKS === markerCreationTick) { + writeFileSync(markerPath, 'gitdir: elsewhere') + } + }, + onPendingMarkerProbe: (path) => { + pendingMarkerProbes += 1 + if (writeMarkerOnNextProbe && path === markerPath) { + writeMarkerOnNextProbe = false + writeFileSync(markerPath, 'gitdir: elsewhere') + } + } + } + ) + cleanups.push(() => poller.unsubscribe()) + + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'create' && event.path === markerPath) + ) + + await rm(ordinaryFolder, { recursive: true }) + await waitForEvents(received, (flat) => + flat.some((event) => event.type === 'delete' && event.path === ordinaryFolder) + ) + writeMarkerOnNextProbe = true + await mkdir(ordinaryFolder) + const events = await waitForEvents( + received, + (flat) => + flat.filter((event) => event.type === 'create' && event.path === markerPath).length === 2 + ) + + expect(pendingMarkerProbes).toBeLessThanOrEqual(pendingMarkerMaxTicks) + expect( + events.filter((event) => event.type === 'create' && event.path === markerPath) + ).toHaveLength(2) + }) + it('parks base scans while hidden and losslessly detects changes on resume', async () => { const root = await makeRoot() const visibility = createVisibilityHarness() diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 2f65e0fc282..b5076c2ed7f 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -63,6 +63,10 @@ export type WorktreeBasePollerOptions = { onWatchError?: (error: Error) => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void + /** Test hook: called before a pending `.git` marker stat. */ + onPendingMarkerProbe?: (path: string) => void + /** Test hook: overrides the fast-probe window. */ + pendingMarkerMaxTicks?: number } // Why: these targets used to be recursive FSEvents subscriptions spanning the @@ -193,7 +197,7 @@ async function startBasePoller( onEvents: (events: WorktreeBasePollEvent[]) => void, pollIntervalMs: number, visibility: WorktreePollerWindowVisibility, - onFullScan?: () => void + options: WorktreeBasePollerOptions ): Promise { let disposed = false let ticking = false @@ -202,16 +206,17 @@ async function startBasePoller( let gateSignatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) let timer: ReturnType | null = null let parkedWhileHidden = false - // dir → tick when first seen without a `.git` marker - const pendingMarkers = new Map() + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() for (const [dir, marker] of snapshot.markers) { if (!marker) { - pendingMarkers.set(dir, 0) + markerProbeStartedAt.set(dir, 0) } } const fullScan = async (): Promise => { - onFullScan?.() + options.onFullScan?.() const next = await snapshotBase(target.path, getRepos()) const nextSignatures = await Promise.all(next.gateDirs.map(dirSignature)) if (disposed) { @@ -220,14 +225,14 @@ async function startBasePoller( const events = diffBase(snapshot, next) for (const [dir, marker] of next.markers) { if (marker) { - pendingMarkers.delete(dir) - } else if (!pendingMarkers.has(dir)) { - pendingMarkers.set(dir, tickCount) + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) } } - for (const [dir, firstSeenTick] of pendingMarkers) { - if (!next.markers.has(dir) || tickCount - firstSeenTick > PENDING_MARKER_MAX_TICKS) { - pendingMarkers.delete(dir) + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) } } snapshot = next @@ -239,9 +244,17 @@ async function startBasePoller( const checkPendingMarkers = async (): Promise => { const events: WorktreeBasePollEvent[] = [] - for (const dir of pendingMarkers.keys()) { + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) if (await hasGitMarker(dir)) { - pendingMarkers.delete(dir) + markerProbeStartedAt.delete(dir) snapshot.markers.set(dir, true) events.push({ type: 'create', path: join(dir, '.git') }) } @@ -267,7 +280,7 @@ async function startBasePoller( await fullScan() return } - if (pendingMarkers.size > 0) { + if (markerProbeStartedAt.size > 0) { await checkPendingMarkers() } } @@ -356,5 +369,5 @@ export async function startWorktreeBaseDirectoryPoller( options.onWatchError ) } - return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options.onFullScan) + return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) } From 7dce0442abdadbb611db8e487eeb71b8af9e2e7d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:52:11 -0700 Subject: [PATCH 18/20] Add Caffeinate controls to the status bar (#13480) * feat: add caffeinate status controls * refactor: compact caffeinate status * fix: harden caffeinate readiness --- src/main/agent-awake-service.test.ts | 36 ++++ src/main/agent-awake-service.ts | 60 +++++- src/main/index.ts | 8 +- src/main/ipc/settings.test.ts | 15 +- src/main/ipc/settings.ts | 41 +++- src/preload/api-types.ts | 5 + src/preload/index.ts | 11 ++ .../components/feature-wall/KeepAwakeCard.tsx | 12 +- .../components/settings/AgentAwakeSetting.tsx | 57 +++--- .../components/settings/AgentsPane.test.tsx | 48 ++--- .../src/components/settings/AgentsPane.tsx | 5 +- .../components/settings/agent-awake-copy.ts | 14 +- .../src/components/settings/agents-search.ts | 13 +- .../status-bar/CaffeinateStatusSegment.tsx | 177 ++++++++++++++++++ .../src/components/status-bar/StatusBar.tsx | 3 + .../hooks/useSettingsNavigationMetadata.ts | 5 +- src/renderer/src/i18n/locales/en.json | 22 ++- src/renderer/src/web/web-preload-api.ts | 35 ++++ src/shared/computer-awake-mode.test.ts | 33 ++++ src/shared/computer-awake-mode.ts | 36 ++++ src/shared/types.ts | 3 + tests/e2e/settings-agent-awake.spec.ts | 34 ++-- tests/e2e/status-bar-caffeinate.spec.ts | 70 +++++++ 23 files changed, 647 insertions(+), 96 deletions(-) create mode 100644 src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx create mode 100644 src/shared/computer-awake-mode.test.ts create mode 100644 src/shared/computer-awake-mode.ts create mode 100644 tests/e2e/status-bar-caffeinate.spec.ts diff --git a/src/main/agent-awake-service.test.ts b/src/main/agent-awake-service.test.ts index 12ae01f3fc7..6a85e45c41a 100644 --- a/src/main/agent-awake-service.test.ts +++ b/src/main/agent-awake-service.test.ts @@ -122,6 +122,42 @@ describe('AgentAwakeService', () => { expect(linuxAssertion.start).toHaveBeenCalledTimes(1) }) + it('stays awake in On mode without a working agent', () => { + const blocker = createBlocker() + const service = createService(() => 1_000, blocker) + + service.setMode('on') + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + expect(service.getStatus()).toEqual({ mode: 'on', active: true }) + }) + + it('publishes Auto activity changes to status subscribers', () => { + const service = createService(() => 1_000) + const listener = vi.fn() + service.subscribe(listener) + + service.setMode('auto') + service.setStatuses([workingStatus()]) + service.setStatuses([workingStatus(), workingStatus()]) + service.setStatuses([workingStatus()]) + service.setStatuses([]) + + expect(listener).toHaveBeenNthCalledWith(1, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenNthCalledWith(2, { + mode: 'auto', + active: true + }) + expect(listener).toHaveBeenNthCalledWith(3, { + mode: 'auto', + active: false + }) + expect(listener).toHaveBeenCalledTimes(3) + }) + it('starts and stops from settings flips around an already-running status', () => { const blocker = createBlocker() const macosAssertion = createMacosAssertion() diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index a0cffcf27c4..adba93e7ea1 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -1,5 +1,10 @@ import { powerMonitor, powerSaveBlocker } from 'electron' import type { AgentStatusState } from '../shared/agent-status-types' +import { + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../shared/computer-awake-mode' import { LinuxLidSleepAssertion } from './linux-lid-sleep-assertion' import { MacosSystemSleepAssertion } from './macos-system-sleep-assertion' @@ -40,10 +45,12 @@ type AgentAwakeServiceOptions = { } export class AgentAwakeService { - private enabled = false + private mode: ComputerAwakeMode = 'off' private statuses: AgentAwakeStatus[] = [] private blockerId: number | null = null private staleTimer: ReturnType | null = null + private readonly statusListeners = new Set<(status: ComputerAwakeStatus) => void>() + private lastPublishedStatus: ComputerAwakeStatus | null = null private readonly blocker: PowerSaveBlocker private readonly linuxAssertion: PlatformAwakeAssertion private readonly logger: Logger @@ -82,10 +89,15 @@ export class AgentAwakeService { } setEnabled(enabled: boolean): void { - if (this.enabled === enabled) { + this.setMode(enabled ? 'auto' : 'off') + } + + setMode(mode: ComputerAwakeMode): void { + const normalized = normalizeComputerAwakeMode(mode) + if (this.mode === normalized) { return } - this.enabled = enabled + this.mode = normalized this.refresh('settings-change') } @@ -94,6 +106,19 @@ export class AgentAwakeService { this.refresh('status-change') } + getStatus(): ComputerAwakeStatus { + const workingAgentCount = this.getEligibleRunningStatusCount() + return { + mode: this.mode, + active: this.mode === 'on' || (this.mode === 'auto' && workingAgentCount > 0) + } + } + + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { + this.statusListeners.add(listener) + return () => this.statusListeners.delete(listener) + } + dispose(): void { this.clearStaleTimer() this.unsubscribeResume?.() @@ -105,7 +130,7 @@ export class AgentAwakeService { private refresh(reason: string): void { this.scheduleStaleTimer() const runningStatusCount = this.getEligibleRunningStatusCount() - const shouldBlock = this.enabled && runningStatusCount > 0 + const shouldBlock = this.mode === 'on' || (this.mode === 'auto' && runningStatusCount > 0) if (shouldBlock) { this.startBlocker(reason, runningStatusCount) this.startMacosAssertion(reason) @@ -115,6 +140,21 @@ export class AgentAwakeService { this.stopMacosAssertion(reason) this.stopLinuxAssertion(reason) } + this.publishStatus(shouldBlock) + } + + private publishStatus(active: boolean): void { + const status = { mode: this.mode, active } + if ( + this.lastPublishedStatus?.mode === status.mode && + this.lastPublishedStatus.active === status.active + ) { + return + } + this.lastPublishedStatus = status + for (const listener of this.statusListeners) { + listener(status) + } } private getEligibleRunningStatusCount(): number { @@ -182,7 +222,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, error: err }) @@ -195,7 +235,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -207,7 +247,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to start Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -219,7 +259,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop macOS system sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -231,7 +271,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop Linux lid sleep assertion', { reason, - enabled: this.enabled, + mode: this.mode, error: err }) } @@ -247,7 +287,7 @@ export class AgentAwakeService { } catch (err) { this.logger.warn('[agent-awake] failed to stop blocker', { reason, - enabled: this.enabled, + mode: this.mode, runningStatusCount, blockerId: id, error: err diff --git a/src/main/index.ts b/src/main/index.ts index ddf79fd9455..0a4be2562c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -267,6 +267,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' @@ -2216,7 +2217,12 @@ void app.whenReady().then(async () => { }) unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() - agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) + agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 88199db7e41..af7117b086f 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -344,7 +344,7 @@ describe('registerSettingsHandlers', () => { }) it('updates the agent awake service when the keep-awake setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: true }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -356,11 +356,18 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { keepComputerAwakeWhileAgentsRun: true }) - expect(agentAwakeService.setEnabled).toHaveBeenCalledWith(true) + expect(store.updateSettings).toHaveBeenCalledWith( + { + computerAwakeMode: 'auto', + keepComputerAwakeWhileAgentsRun: true + }, + expect.any(Object) + ) + expect(agentAwakeService.setMode).toHaveBeenCalledWith('auto') }) it('does not notify the agent awake service for unrelated setting changes', () => { - const agentAwakeService = { setEnabled: vi.fn() } + const agentAwakeService = { setMode: vi.fn() } store.getSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) store.updateSettings.mockReturnValue({ keepComputerAwakeWhileAgentsRun: false }) registerSettingsHandlers(store as never, agentAwakeService as never) @@ -372,7 +379,7 @@ describe('registerSettingsHandlers', () => { handler(settingsInvokeEvent, { defaultTuiAgent: 'codex' }) - expect(agentAwakeService.setEnabled).not.toHaveBeenCalled() + expect(agentAwakeService.setMode).not.toHaveBeenCalled() }) it('prepares local worktree roots when workspace directory changes', async () => { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index df3817c949c..98e918300ab 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -29,6 +29,10 @@ import { normalizeMobilePairingCustomAddress, normalizeMobilePairingCustomAddresses } from '../../shared/mobile-pairing-custom-address' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../shared/computer-awake-mode' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -65,6 +69,18 @@ export function registerSettingsHandlers( store: Store, agentAwakeService?: AgentAwakeService ): void { + ipcMain.handle( + 'agentAwake:getStatus', + () => agentAwakeService?.getStatus() ?? { mode: 'off', active: false } + ) + agentAwakeService?.subscribe?.((status) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('agentAwake:changed', status) + } + } + }) + store.onSettingsChanged((updates, _settings, originWebContentsId) => { for (const window of BrowserWindow.getAllWindows()) { const isOrigin = @@ -109,6 +125,22 @@ export function registerSettingsHandlers( // Why: Floating Workspace grants are trusted only when written by the // main-process directory picker, never by renderer-provided settings IPC. delete sanitizedArgs.floatingTerminalTrustedCwds + if ('computerAwakeMode' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedArgs.computerAwakeMode, + sanitizedArgs.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { + Object.assign( + sanitizedArgs, + computerAwakeSettingsForMode(sanitizedArgs.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off') + ) + } if (typeof args.floatingTerminalCwd === 'string') { sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting( store, @@ -161,8 +193,13 @@ export function registerSettingsHandlers( notifyListeners: true, originWebContentsId: event.sender.id }) - if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { - agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) + if ( + 'computerAwakeMode' in sanitizedArgs || + 'keepComputerAwakeWhileAgentsRun' in sanitizedArgs + ) { + agentAwakeService?.setMode( + normalizeComputerAwakeMode(result.computerAwakeMode, result.keepComputerAwakeWhileAgentsRun) + ) } const hookSettingChanged = ('agentStatusHooksEnabled' in sanitizedArgs && diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 87c8fce5901..1cae8cd2359 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { HostedReviewProvider } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { BrowserFindSource } from '../shared/browser-find-source' import type { DashboardRevealAgentArgs, @@ -2425,6 +2426,10 @@ export type PreloadApi = { /** Subscribe to out-of-band settings updates (e.g. View > Appearance toggles) to stay in sync with main. */ onChanged: (callback: (updates: Partial) => void) => () => void } + agentAwake: { + getStatus: () => Promise + onChanged: (callback: (status: ComputerAwakeStatus) => void) => () => void + } localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cf88323b23..179ce5ecc2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,6 +4,7 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' +import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' import type { DashboardRevealAgentArgs, DashboardSleepWorkspaceArgs, @@ -2047,6 +2048,16 @@ const api = { } }, + agentAwake: { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } + } satisfies PreloadApi['agentAwake'], + localhostWorktreeLabels: { register: (args: LocalhostWorktreeLabelRoute): Promise => ipcRenderer.invoke('localhostWorktreeLabels:register', args) diff --git a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx index 2a7b57adc67..40d25ebf8bb 100644 --- a/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx +++ b/src/renderer/src/components/feature-wall/KeepAwakeCard.tsx @@ -3,13 +3,21 @@ import { cn } from '@/lib/utils' import { getAgentAwakeDescription, getAgentAwakeTitle } from '../settings/agent-awake-copy' import type { GlobalSettings } from '../../../../shared/types' import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' export function KeepAwakeCard(props: { settings: GlobalSettings updateSettings: (updates: Partial) => void }): JSX.Element { const { settings, updateSettings } = props - const enabled = settings.keepComputerAwakeWhileAgentsRun + const enabled = + normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) !== 'off' const title = getAgentAwakeTitle() return (
@@ -29,7 +37,7 @@ export function KeepAwakeCard(props: { role="switch" aria-label={title} aria-checked={enabled} - onClick={() => updateSettings({ keepComputerAwakeWhileAgentsRun: !enabled })} + onClick={() => updateSettings(computerAwakeSettingsForMode(enabled ? 'off' : 'auto'))} className={cn( 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors', enabled ? 'bg-foreground' : 'bg-muted-foreground/30' diff --git a/src/renderer/src/components/settings/AgentAwakeSetting.tsx b/src/renderer/src/components/settings/AgentAwakeSetting.tsx index 28cd7cc0114..7df934be0c7 100644 --- a/src/renderer/src/components/settings/AgentAwakeSetting.tsx +++ b/src/renderer/src/components/settings/AgentAwakeSetting.tsx @@ -6,6 +6,13 @@ import { getAgentAwakeTitle } from './agent-awake-copy' import { SearchableSetting } from './SearchableSetting' +import { SettingsSegmentedControl } from './SettingsFormControls' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { translate } from '@/i18n/i18n' type AgentAwakeSettingProps = { settings: GlobalSettings @@ -18,6 +25,13 @@ export function AgentAwakeSetting({ }: AgentAwakeSettingProps): React.JSX.Element { const title = getAgentAwakeTitle() const description = getAgentAwakeDescription() + const mode = normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ) + const setMode = (nextMode: ComputerAwakeMode): void => { + updateSettings(computerAwakeSettingsForMode(nextMode)) + } return (
@@ -31,29 +45,26 @@ export function AgentAwakeSetting({

{description}

- {/* Why: this button is read directly from the React element tree by tests - that walk props (without rendering), so the role/aria attributes - must remain on a literal + diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 4a8428ca892..af821f94920 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -117,19 +117,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSwitch(node: unknown, ariaLabel: string): ReactElementLike { - let found: ReactElementLike | null = null - visit(node, (entry) => { - if (entry.props.role === 'switch' && entry.props['aria-label'] === ariaLabel) { - found = entry - } - }) - if (!found) { - throw new Error('switch not found') - } - return found -} - function findSwitchRow(node: unknown, ariaLabel: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -249,17 +236,30 @@ describe('AgentsPane', () => { expect(agentRuntimeSettingMock.lastRefresh).not.toBe(detectedAgentsMock.refresh) }) - it('renders the keep-awake toggle from settings', () => { + it('renders the keep-awake modes from settings', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).not.toContain('Agent location') expect(markup).not.toContain('Agent runtime') expect(markup).not.toContain('aria-label="Agent runtime"') - expect(markup).toContain('Keep computer awake while agents are working') + expect(markup).toContain('Keep computer awake') expect(markup).toContain( - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) - expect(markup).toContain('aria-checked="false"') + expect(markup).toContain('role="radiogroup"') + expect(markup).toContain('>Auto<') + }) + + it('hides desktop-only awake modes in paired web clients', () => { + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + try { + expect(renderPane(getDefaultSettings('/tmp'))).not.toContain('Keep computer awake') + expect( + matchesSettingsSearch('awake', getAgentsPaneSearchEntries({ includeAgentAwake: false })) + ).toBe(false) + } finally { + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + } }) it('renders the agent runtime control on Windows-class hosts', () => { @@ -316,11 +316,11 @@ describe('AgentsPane', () => { it('describes Windows lid behavior according to the device', () => { expect(getAgentAwakeDescription('Windows')).toBe( - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) }) - it('toggles the keep-awake setting with the next value', () => { + it('updates the keep-awake mode with its legacy fallback', () => { const updateSettings = vi.fn() const element = AgentAwakeSetting({ settings: { @@ -331,14 +331,14 @@ describe('AgentsPane', () => { }) const keepAwakeTitle = getAgentAwakeTitle() - const keepAwakeSwitch = findSwitch(element, keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-label']).toBe(keepAwakeTitle) - expect(keepAwakeSwitch.props['aria-checked']).toBe(false) + const keepAwakeControl = findSegmentedControl(element, keepAwakeTitle) + expect(keepAwakeControl.props.value).toBe('off') - const onClick = keepAwakeSwitch.props.onClick as () => void - onClick() + const onChange = keepAwakeControl.props.onChange as (mode: 'auto') => void + onChange('auto') expect(updateSettings).toHaveBeenCalledWith({ + computerAwakeMode: 'auto', keepComputerAwakeWhileAgentsRun: true }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 21f50bede40..70ba5f64c3f 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -56,6 +56,7 @@ import { getSettingOwnershipSummary } from './setting-ownership' import { translate } from '@/i18n/i18n' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' export { getAgentsPaneSearchEntries } from './agents-search' @@ -867,7 +868,9 @@ export function AgentsPane({ - + {!isPairedWebClientWindow() ? ( + + ) : null} diff --git a/src/renderer/src/components/settings/agent-awake-copy.ts b/src/renderer/src/components/settings/agent-awake-copy.ts index 10d629b58d1..1076312ed18 100644 --- a/src/renderer/src/components/settings/agent-awake-copy.ts +++ b/src/renderer/src/components/settings/agent-awake-copy.ts @@ -1,12 +1,14 @@ import { translate } from '@/i18n/i18n' import { searchKeywords } from './settings-search-keywords' -const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.e5995ce268' -const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = 'auto.components.settings.agent-awake-copy.95d3031db2' -const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = 'auto.components.settings.agent-awake-copy.a42f6fbdd8' +const AGENT_AWAKE_TITLE_KEY = 'auto.components.settings.agent-awake-copy.modeTitle' +const AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionWindows' +const AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY = + 'auto.components.settings.agent-awake-copy.modeDescriptionDefault' export function getAgentAwakeTitle(): string { - return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake while agents are working') + return translate(AGENT_AWAKE_TITLE_KEY, 'Keep computer awake') } export function getAgentAwakeDescription( @@ -15,13 +17,13 @@ export function getAgentAwakeDescription( if (userAgent.includes('Windows')) { return translate( AGENT_AWAKE_DESCRIPTION_WINDOWS_KEY, - "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings." + "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings." ) } return translate( AGENT_AWAKE_DESCRIPTION_DEFAULT_KEY, - 'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' + 'Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.' ) } diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 5e378d732d1..ed353d5acbf 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -60,9 +60,11 @@ function expandAgentSearchText(value: string): string[] { } type AgentsPaneSearchOptions = { + includeAgentAwake?: boolean includeAgentRuntime?: boolean } +const AGENT_AWAKE_SEARCH_ENTRY_ID = 'agent-awake' const AGENT_RUNTIME_SEARCH_ENTRY_ID = 'agent-runtime' const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ @@ -113,6 +115,7 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ }, { title: getAgentAwakeTitle(), + id: AGENT_AWAKE_SEARCH_ENTRY_ID, description: getAgentAwakeDescription(), keywords: getAgentAwakeSearchKeywords() }, @@ -141,11 +144,13 @@ const getAllAgentsPaneSearchEntries = createLocalizedCatalog(() => [ ]) export function getAgentsPaneSearchEntries({ + includeAgentAwake = true, includeAgentRuntime = true }: AgentsPaneSearchOptions = {}) { const entries = getAllAgentsPaneSearchEntries() - if (includeAgentRuntime) { - return entries - } - return entries.filter((entry) => !('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID) + return entries.filter( + (entry) => + (!('id' in entry) || entry.id !== AGENT_RUNTIME_SEARCH_ENTRY_ID || includeAgentRuntime) && + (!('id' in entry) || entry.id !== AGENT_AWAKE_SEARCH_ENTRY_ID || includeAgentAwake) + ) } diff --git a/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx new file mode 100644 index 00000000000..b991e63a096 --- /dev/null +++ b/src/renderer/src/components/status-bar/CaffeinateStatusSegment.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react' +import { Coffee } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' +import { translate } from '@/i18n/i18n' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode, + type ComputerAwakeMode, + type ComputerAwakeStatus +} from '../../../../shared/computer-awake-mode' +import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' + +const INACTIVE_STATUS: ComputerAwakeStatus = { + mode: 'off', + active: false +} + +function modeLabel(mode: ComputerAwakeMode): string { + if (mode === 'on') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.on', 'On') + } + if (mode === 'auto') { + return translate('auto.components.status.bar.CaffeinateStatusSegment.auto', 'Auto') + } + return translate('auto.components.status.bar.CaffeinateStatusSegment.off', 'Off') +} + +function activityLabel(active: boolean): string { + return active + ? translate('auto.components.status.bar.CaffeinateStatusSegment.active', 'Active') + : translate('auto.components.status.bar.CaffeinateStatusSegment.inactive', 'Inactive') +} + +export function CaffeinateStatusSegment({ + iconOnly +}: { + iconOnly: boolean +}): React.JSX.Element | null { + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) + const configuredMode = normalizeComputerAwakeMode( + settings?.computerAwakeMode, + settings?.keepComputerAwakeWhileAgentsRun + ) + const [serviceStatus, setServiceStatus] = useState(INACTIVE_STATUS) + + useEffect(() => { + let mounted = true + const unsubscribe = window.api.agentAwake.onChanged((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + void window.api.agentAwake + .getStatus() + .then((status) => { + if (mounted) { + setServiceStatus(status) + } + }) + .catch(() => {}) + return () => { + mounted = false + unsubscribe() + } + }, []) + + if (isPairedWebClientWindow()) { + return null + } + + const mode = serviceStatus.mode === configuredMode ? serviceStatus.mode : configuredMode + const active = + serviceStatus.mode === configuredMode ? serviceStatus.active : configuredMode === 'on' + const statusText = `${modeLabel(mode)} · ${activityLabel(active)}` + const ariaLabel = translate( + 'auto.components.status.bar.CaffeinateStatusSegment.ariaLabel', + 'Caffeinate, {{status}}', + { status: statusText } + ) + + const setMode = (nextMode: string): void => { + void updateSettings(computerAwakeSettingsForMode(normalizeComputerAwakeMode(nextMode))) + } + + return ( + + + + + + + + + {ariaLabel} + + + + + + {translate('auto.components.status.bar.CaffeinateStatusSegment.title', 'Caffeinate')} + + {statusText} + + + + + + {modeLabel('on')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.onDescription', + 'Keep this computer awake continuously' + )} + + + + + + {modeLabel('auto')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.autoDescription', + 'Stay awake while an agent is working' + )} + + + + + + {modeLabel('off')} + + {translate( + 'auto.components.status.bar.CaffeinateStatusSegment.offDescription', + 'Allow normal system sleep behavior' + )} + + + + + + + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 1b3075e305b..1c4af8fdff8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -71,6 +71,7 @@ import { } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' +import { CaffeinateStatusSegment } from './CaffeinateStatusSegment' import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment' import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' @@ -84,6 +85,7 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu' import { summarizeCodexRestartStatus } from './codex-restart-status-summary' +import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { getWindowsTerminalCapabilityOwnerKey, useWindowsTerminalCapabilities @@ -2349,6 +2351,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
+ {!isPairedWebClientWindow() ? : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 17f535457ee..364d525298b 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -165,7 +165,10 @@ export function buildSettingsNavigationMetadata({ 'Manage AI agents, set a default, and customize commands.' ), icon: Bot, - searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }), + searchEntries: getAgentsPaneSearchEntries({ + includeAgentAwake: !isWebClient, + includeAgentRuntime: isLocalWindowsHost + }), group: 'capabilities' }, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index baadf663b56..1aaead6e8f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3635,6 +3635,18 @@ } } } + }, + "CaffeinateStatusSegment": { + "on": "On", + "auto": "Auto", + "off": "Off", + "active": "Active", + "inactive": "Inactive", + "ariaLabel": "Caffeinate, {{status}}", + "title": "Caffeinate", + "onDescription": "Keep this computer awake continuously", + "autoDescription": "Stay awake while an agent is working", + "offDescription": "Allow normal system sleep behavior" } } }, @@ -9455,7 +9467,10 @@ "agent-awake-copy": { "e5995ce268": "Keep computer awake while agents are working", "95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.", - "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." + "a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.", + "modeTitle": "Keep computer awake", + "modeDescriptionWindows": "Choose On, Auto while agents are working, or Off. Lid-close behavior follows this device's power settings.", + "modeDescriptionDefault": "Choose On, Auto while agents are working, or Off. Orca also asks this device to stay awake when the lid is closed, subject to its power policy." }, "agent-status-hooks-copy": { "7707c15abb": "Agent status hooks", @@ -10514,6 +10529,11 @@ "openAutomationsDescription": "Create schedules and inspect recent runs.", "title": "Automations", "description": "Schedule agent work and choose whether Automations appears in the sidebar." + }, + "AgentAwakeSetting": { + "on": "On", + "auto": "Auto", + "off": "Off" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 038e05e1373..77f9a40dfdb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -92,6 +92,10 @@ import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-t import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../shared/computer-awake-mode' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' @@ -680,6 +684,24 @@ function createWebPreloadApi(): Partial { set: async (updates) => { const sanitizedUpdates = { ...updates } delete sanitizedUpdates.activeRuntimeEnvironmentId + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true } @@ -704,6 +726,19 @@ function createWebPreloadApi(): Partial { listFonts: () => Promise.resolve([]), onChanged: () => noopUnsubscribe } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + }, keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), crashReports: { diff --git a/src/shared/computer-awake-mode.test.ts b/src/shared/computer-awake-mode.test.ts new file mode 100644 index 00000000000..5a0920f4c0c --- /dev/null +++ b/src/shared/computer-awake-mode.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from './computer-awake-mode' + +describe('computer awake mode', () => { + it('maps the legacy enabled setting to Auto', () => { + expect(normalizeComputerAwakeMode(undefined, true)).toBe('auto') + expect(normalizeComputerAwakeMode(undefined, false)).toBe('off') + }) + + it('preserves explicit modes when the legacy projection agrees or is absent', () => { + expect(normalizeComputerAwakeMode('on')).toBe('on') + expect(normalizeComputerAwakeMode('on', true)).toBe('on') + expect(normalizeComputerAwakeMode('off', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', true)).toBe('auto') + }) + + it('honors legacy changes made by a rollback build', () => { + expect(normalizeComputerAwakeMode('on', false)).toBe('off') + expect(normalizeComputerAwakeMode('auto', false)).toBe('off') + expect(normalizeComputerAwakeMode('off', true)).toBe('auto') + }) + + it('writes a safe legacy approximation', () => { + expect(computerAwakeSettingsForMode('on')).toEqual({ + computerAwakeMode: 'on', + keepComputerAwakeWhileAgentsRun: true + }) + expect(computerAwakeSettingsForMode('off')).toEqual({ + computerAwakeMode: 'off', + keepComputerAwakeWhileAgentsRun: false + }) + }) +}) diff --git a/src/shared/computer-awake-mode.ts b/src/shared/computer-awake-mode.ts new file mode 100644 index 00000000000..0c598fd25d5 --- /dev/null +++ b/src/shared/computer-awake-mode.ts @@ -0,0 +1,36 @@ +export const COMPUTER_AWAKE_MODES = ['on', 'off', 'auto'] as const + +export type ComputerAwakeMode = (typeof COMPUTER_AWAKE_MODES)[number] + +export type ComputerAwakeStatus = { + mode: ComputerAwakeMode + active: boolean +} + +export function normalizeComputerAwakeMode( + mode: unknown, + legacyAutoEnabled?: boolean +): ComputerAwakeMode { + const explicitMode = COMPUTER_AWAKE_MODES.includes(mode as ComputerAwakeMode) + ? (mode as ComputerAwakeMode) + : null + if (!explicitMode) { + return legacyAutoEnabled === true ? 'auto' : 'off' + } + if (typeof legacyAutoEnabled === 'boolean' && legacyAutoEnabled !== (explicitMode !== 'off')) { + // Older builds can only change the legacy boolean, so disagreement means it was written later. + return legacyAutoEnabled ? 'auto' : 'off' + } + return explicitMode +} + +export function computerAwakeSettingsForMode(mode: ComputerAwakeMode): { + computerAwakeMode: ComputerAwakeMode + keepComputerAwakeWhileAgentsRun: boolean +} { + return { + computerAwakeMode: mode, + // Older Orca versions approximate On with their supported Auto behavior. + keepComputerAwakeWhileAgentsRun: mode !== 'off' + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 9eb3de46c3b..284782625e9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,7 @@ import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt import type { TaskSourceContext } from './task-source-context' import type { SetupRunnerShell } from './setup-runner-command' import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { ComputerAwakeMode } from './computer-awake-mode' // Re-exported for backward compat with renderer call sites that import // `WorkspaceCreateTelemetrySource` from '../../../shared/types'. @@ -3050,6 +3051,8 @@ export type GlobalSettings = { confirmClosePinnedTab: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean + /** Optional for mixed-version compatibility; the legacy boolean maps true to Auto. */ + computerAwakeMode?: ComputerAwakeMode /** macOS Option key: compose layout chars (@ German, € French) vs act as Meta/Esc for readline. * 'auto' (default) = layout-aware via navigator.keyboard.getLayoutMap() (US → Meta, else compose); * 'false' = compose; 'true' = Meta on both Option keys; 'left'/'right' = only that key is Meta. diff --git a/tests/e2e/settings-agent-awake.spec.ts b/tests/e2e/settings-agent-awake.spec.ts index e35aed89c39..0432970b39c 100644 --- a/tests/e2e/settings-agent-awake.spec.ts +++ b/tests/e2e/settings-agent-awake.spec.ts @@ -139,37 +139,37 @@ test.describe('Agent awake setting', () => { await waitForSessionReady(orcaPage) }) - test('can be toggled from Agents settings and persists through IPC', async ({ orcaPage }) => { + test('can be changed from Agents settings and persists through IPC', async ({ orcaPage }) => { await openSettings(orcaPage) await dismissTransientAnnouncement(orcaPage) await orcaPage.getByPlaceholder('Search settings').fill('awake') - await expect( - orcaPage.getByText('Keep computer awake while agents are working').first() - ).toBeVisible() + await expect(orcaPage.getByText('Keep computer awake').first()).toBeVisible() - const keepAwakeSwitch = orcaPage.getByRole('switch', { - name: 'Keep computer awake while agents are working' + const keepAwakeModes = orcaPage.getByRole('radiogroup', { + name: 'Keep computer awake' }) + const offMode = keepAwakeModes.getByRole('radio', { name: 'Off' }) + const autoMode = keepAwakeModes.getByRole('radio', { name: 'Auto' }) - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'true') + await expect(offMode).toHaveAttribute('aria-checked', 'true') + await autoMode.click() + await expect(autoMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after enabling' + message: 'keep-awake mode did not persist after selecting Auto' }) - .toBe(true) + .toBe('auto') - await keepAwakeSwitch.click() - await expect(keepAwakeSwitch).toHaveAttribute('aria-checked', 'false') + await offMode.click() + await expect(offMode).toHaveAttribute('aria-checked', 'true') await expect - .poll(async () => (await getSettings(orcaPage)).keepComputerAwakeWhileAgentsRun, { + .poll(async () => (await getSettings(orcaPage)).computerAwakeMode, { timeout: 5_000, - message: 'keep-awake setting did not persist after disabling' + message: 'keep-awake mode did not persist after selecting Off' }) - .toBe(false) + .toBe('off') }) test('keeps the OS awake only while a hook-reported agent is working', async ({ diff --git a/tests/e2e/status-bar-caffeinate.spec.ts b/tests/e2e/status-bar-caffeinate.spec.ts new file mode 100644 index 00000000000..2e28770213d --- /dev/null +++ b/tests/e2e/status-bar-caffeinate.spec.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { readHookEndpoint } from './helpers/agent-hook-endpoint' + +async function postCodexHookEvent( + electronApp: ElectronApplication, + paneKey: string, + eventName: 'UserPromptSubmit' | 'Stop' +): Promise { + const endpoint = await readHookEndpoint(electronApp) + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + paneKey, + tabId: 'e2e-caffeinate-tab', + worktreeId: 'e2e-caffeinate-worktree', + env: endpoint.env, + version: endpoint.version, + payload: { hook_event_name: eventName, prompt: 'e2e caffeinate prompt' } + }) + }) + expect(response.status).toBe(204) +} + +test('shows Caffeinate mode and Auto activity in the status bar', async ({ + electronApp, + orcaPage +}) => { + await waitForSessionReady(orcaPage) + + const offStatus = orcaPage.getByRole('button', { name: 'Caffeinate, Off · Inactive' }) + await expect(offStatus).toBeVisible() + await expect(offStatus).toHaveText('Off') + await offStatus.click() + await expect(orcaPage.getByRole('menuitemradio', { name: /^On/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Auto/ })).toBeVisible() + await expect(orcaPage.getByRole('menuitemradio', { name: /^Off/ })).toBeVisible() + const menuProofPath = process.env.ORCA_CAFFEINATE_MENU_PROOF_PATH + if (menuProofPath) { + await orcaPage.screenshot({ path: menuProofPath }) + } + await orcaPage.getByRole('menuitemradio', { name: /^Auto/ }).click() + + const autoInactiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Inactive' + }) + await expect(autoInactiveStatus).toBeVisible() + + const paneKey = `e2e-caffeinate-tab:${randomUUID()}` + await postCodexHookEvent(electronApp, paneKey, 'UserPromptSubmit') + const autoActiveStatus = orcaPage.getByRole('button', { + name: 'Caffeinate, Auto · Active' + }) + await expect(autoActiveStatus).toBeVisible() + await expect(autoActiveStatus).toHaveText('Auto') + + const proofPath = process.env.ORCA_CAFFEINATE_PROOF_PATH + if (proofPath) { + await orcaPage.screenshot({ path: proofPath }) + } + + await postCodexHookEvent(electronApp, paneKey, 'Stop') + await expect(autoInactiveStatus).toBeVisible() +}) From d6362deb044d887db47779ae181ad93042354465 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:55:32 -0700 Subject: [PATCH 19/20] feat(agent-hooks): support Prime Agent status in WSL (#13430) --- .../wsl-hook-relay-manager.test.ts | 7 + .../agent-hooks/wsl-hook-relay-manager.ts | 12 +- src/main/daemon/shell-ready.test.ts | 5 + src/main/daemon/shell-ready.ts | 4 + src/main/ipc/pty.test.ts | 36 ++- src/main/ipc/pty.ts | 30 ++- .../pi/agent-status-extension-source.test.ts | 33 +++ src/main/pi/agent-status-extension-source.ts | 84 +----- src/main/pi/agent-status-wsl-curl-source.ts | 81 ++++++ .../pi/titlebar-extension-service.test.ts | 19 ++ src/main/pi/titlebar-extension-service.ts | 22 +- .../providers/local-pty-shell-ready.test.ts | 5 + src/main/providers/local-pty-shell-ready.ts | 4 + src/main/providers/windows-shell-args.test.ts | 7 +- .../pty/prime-agent-shell-wrapper.test.ts | 244 ++++++++++++++++++ src/main/pty/prime-agent-shell-wrapper.ts | 74 ++++++ src/main/pty/wsl-orca-env.test.ts | 5 + src/main/pty/wsl-orca-env.ts | 1 + 18 files changed, 575 insertions(+), 98 deletions(-) create mode 100644 src/main/pi/agent-status-wsl-curl-source.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.test.ts create mode 100644 src/main/pty/prime-agent-shell-wrapper.ts diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4d2ae9b7bb5..cb538595c19 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -233,6 +233,13 @@ describe('WslHookRelayManager', () => { return { manager: new WslHookRelayManager(deps), deps } } + it('exposes the stable guest endpoint instance key with a port fallback', () => { + expect(createManager({}).manager.getInstanceKey()).toBe('testinstance') + expect(createManager({ instanceKey: () => 'unsafe/key' }).manager.getInstanceKey()).toBe( + 'port43117' + ) + }) + it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu') diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..828e2fa888a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -109,6 +109,15 @@ export class WslHookRelayManager { return this.stateFor(distro)?.opencodeOverlayDir ?? null } + getInstanceKey(): string | null { + const stableKey = sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) + if (stableKey) { + return stableKey + } + const port = Number(this.deps.hookCoordsEnv().ORCA_AGENT_HOOK_PORT ?? '') + return Number.isInteger(port) && port > 0 ? `port${port}` : null + } + /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the * manager reusable, so re-enabling hooks can start relays again without an app restart. */ disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { @@ -172,8 +181,7 @@ export class WslHookRelayManager { } // Why: restart-stable instance identity keeps the guest endpoint file at // ONE path across restarts so daemon-surviving agents re-coordinate. - const instanceKey = - sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}` + const instanceKey = this.getInstanceKey() ?? `port${port}` if (existing) { this.recovery.clearTimers(existing) } diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 42e6ccc15f5..9610f2600d1 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -604,6 +604,8 @@ describePosix('daemon shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -625,6 +627,9 @@ describePosix('daemon shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 38a26be8b75..27bcf6f48cb 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -11,6 +11,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { getFishShellReadyInitCommand, getZshEnvTemplate, @@ -122,6 +123,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -243,6 +245,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi __orca_osc133_precmd() { @@ -306,6 +309,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 010f35f436e..d3ec26f91a6 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -56,6 +56,7 @@ const { clearAgentHookPaneStateMock, registerPaneKeyAliasMock, piBuildPtyEnvMock, + piBuildStatusOnlyPtyEnvMock, piClearPtyMock, isPwshAvailableMock, wslUncDirectoryExistsAsyncMock, @@ -94,6 +95,7 @@ const { clearAgentHookPaneStateMock: vi.fn(), registerPaneKeyAliasMock: vi.fn(), piBuildPtyEnvMock: vi.fn(), + piBuildStatusOnlyPtyEnvMock: vi.fn(), piClearPtyMock: vi.fn(), trackMock: vi.fn(), classifyErrorMock: vi.fn(), @@ -178,6 +180,7 @@ vi.mock('../agent-hooks/server', () => ({ vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: piBuildPtyEnvMock, + buildStatusOnlyPtyEnv: piBuildStatusOnlyPtyEnvMock, clearPty: piClearPtyMock } })) @@ -341,6 +344,7 @@ describe('registerPtyHandlers', () => { const savedOrcaOmpStatusExtension = process.env.ORCA_OMP_STATUS_EXTENSION const savedPrimeAgentDir = process.env.PRIME_AGENT_CODING_AGENT_DIR const savedOrcaPrimeAgentSourceDir = process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + const savedOrcaPrimeAgentStatusExtension = process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL @@ -368,6 +372,7 @@ describe('registerPtyHandlers', () => { delete process.env.ORCA_OMP_STATUS_EXTENSION delete process.env.PRIME_AGENT_CODING_AGENT_DIR delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION handlers.clear() handleMock.mockReset() onMock.mockReset() @@ -390,6 +395,7 @@ describe('registerPtyHandlers', () => { clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() piBuildPtyEnvMock.mockReset() + piBuildStatusOnlyPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() wslUncDirectoryExistsAsyncMock.mockReset() @@ -491,6 +497,10 @@ describe('registerPtyHandlers', () => { } } ) + piBuildStatusOnlyPtyEnvMock.mockReturnValue({ + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + }) isPwshAvailableMock.mockReturnValue(false) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -596,6 +606,11 @@ describe('registerPtyHandlers', () => { } else { delete process.env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (savedOrcaPrimeAgentStatusExtension !== undefined) { + process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION = savedOrcaPrimeAgentStatusExtension + } else { + delete process.env.ORCA_PRIME_AGENT_STATUS_EXTENSION + } if (savedOrcaClaudeAgentStatusSettings === undefined) { delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS } else { @@ -4206,7 +4221,7 @@ describe('registerPtyHandlers', () => { }) }) - it('does not materialize Windows-host Prime extensions for a WSL launch', async () => { + it('bridges a host-managed Prime status extension into WSL without redirecting Prime home', async () => { await withWin32Platform(async () => { const env = await daemonSpawnAndGetEnv( { PRIME_AGENT_CODING_AGENT_DIR: 'C:\\Users\\test\\.prime\\agent' }, @@ -4217,11 +4232,30 @@ describe('registerPtyHandlers', () => { ) expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') expect(env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toBe( + 'C:\\Users\\test\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts' + ) + expect(env.ORCA_WSL_HOOK_INSTANCE).toBe('port5678') expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBe('C:\\Users\\test\\.prime\\agent') }) }) + it('prepares the Prime bridge for a typed launch in a bare WSL shell', async () => { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe' + }) + + expect(piBuildStatusOnlyPtyEnvMock).toHaveBeenCalledWith('prime-agent') + expect(env.ORCA_PRIME_AGENT_STATUS_EXTENSION).toContain( + 'prime-agent-managed-status-extension' + ) + expect(env.PRIME_AGENT_CODING_AGENT_DIR).toBeUndefined() + }) + }) + it('points OPENCODE_CONFIG_DIR at the guest overlay when the WSL relay reports it', async () => { const guestDir = '/home/jin/.orca-relay/opencode-overlays/abc' const spy = vi.spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir').mockReturnValue(guestDir) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3ff4e36fe1d..1ca019f6f0f 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1513,6 +1513,7 @@ function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKin } if (kind === 'prime-agent') { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1544,6 +1545,11 @@ function exposePiManagedExtensionEnv( } else { delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR } + if (managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION) { + baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION = managedEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } return } delete baseEnv.ORCA_PI_CODING_AGENT_DIR @@ -1686,6 +1692,8 @@ export function buildPtyHostEnv( }) const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + const shouldPreparePrimeStatus = + piAgentKind === 'prime-agent' || (opts.isWsl && !hasLaunchCommand) // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') const preexistingOmpAgentDir = @@ -1744,6 +1752,12 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null + const hookInstance = wslHookRelayManager.getInstanceKey() + if (hookInstance) { + baseEnv.ORCA_WSL_HOOK_INSTANCE = hookInstance + } else { + delete baseEnv.ORCA_WSL_HOOK_INSTANCE + } wslHookRelayManager.ensureForDistro(distro) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { @@ -1790,13 +1804,14 @@ export function buildPtyHostEnv( exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) } - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) + if (shouldPreparePrimeStatus) { + // Why: the Windows host cannot safely resolve or write Prime's guest + // config root; WSL loads the host-managed status file explicitly. + const primeEnv = opts.isWsl + ? piTitlebarExtensionService.buildStatusOnlyPtyEnv('prime-agent') + : piTitlebarExtensionService.buildPtyEnv(id, preexistingPrimeAgentDir, 'prime-agent', { + materializeDefaultHome: explicitPiAgentKind === 'prime-agent' + }) Object.assign(baseEnv, primeEnv) exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) } @@ -1814,6 +1829,7 @@ export function buildPtyHostEnv( }) delete baseEnv.ORCA_OMP_STATUS_EXTENSION delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION } // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index ee051e938d1..b735cd60198 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -30,6 +30,7 @@ type Harness = { fsMock: { existsSync: ReturnType readFileSync: ReturnType + statSync: ReturnType } handlers: Record processEnv: Record @@ -62,6 +63,7 @@ function createHarness(args: { argv?: string[] existsSync?: (path: string) => boolean readFileSync?: (path: string, encoding: string) => string + statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } fetchImpl?: (...params: Parameters) => Promise }): Harness { const fetchMock = vi.fn( @@ -86,6 +88,12 @@ function createHarness(args: { const fsMock = { existsSync: vi.fn(args.existsSync ?? (() => false)), + statSync: vi.fn( + args.statSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ), readFileSync: vi.fn( args.readFileSync ?? ((path: string) => { @@ -562,6 +570,31 @@ describe('getPiAgentStatusExtensionSource', () => { ) }) + it('uses current Windows coordinates when a same-token guest endpoint is stale', async () => { + const endpointPath = '/home/u/.orca-wsl/agent-hooks/instance-test/endpoint.env' + const harness = createHarness({ + kind: 'prime-agent', + env: { WSL_DISTRO_NAME: 'Ubuntu', ORCA_AGENT_HOOK_ENDPOINT: endpointPath }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + statSync: () => ({ mtimeMs: 1, size: 80, ino: 1 }), + readFileSync: (path) => { + if (path === endpointPath) { + return 'ORCA_AGENT_HOOK_PORT=9999\nORCA_AGENT_HOOK_TOKEN=token-1\n' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('stale guest relay') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:9999/hook/prime-agent') + await vi.waitFor(() => expect(harness.spawnMock).toHaveBeenCalledTimes(1)) + expect(harness.spawnMock.mock.calls[0]?.[1]).toContain('http://127.0.0.1:4321/hook/prime-agent') + }) + it('probes WSL evidence and the curl path once per process', async () => { const harness = createHarness({ kind: 'omp', diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index ff2240531ce..6adfdf23fbc 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -13,6 +13,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' +import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -228,92 +229,13 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', ' if (!isWslRuntime()) return', - ' postViaWindowsCurl(url, coords, body)', + ' postViaWindowsCurl(body, ompRuntime)', ' } finally {', ' if (timeout) clearTimeout(timeout)', ' }', '}', '', - '// Why: WSL-ness and curl.exe presence cannot change within a process', - '// lifetime; re-probing /proc and /mnt/c on every failed event would add', - '// filesystem work to the per-event path.', - 'let cachedIsWslRuntime: boolean | null = null', - 'let cachedWindowsCurlPath: string | null | undefined', - '', - 'function isWslRuntime(): boolean {', - ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', - ' cachedIsWslRuntime = detectWslRuntime()', - ' return cachedIsWslRuntime', - '}', - '', - 'function detectWslRuntime(): boolean {', - ' if (process.env.WSL_DISTRO_NAME) return true', - ' try {', - " const fs = require('fs')", - " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", - ' try {', - " const contents = String(fs.readFileSync(path, 'utf8'))", - ' if (/microsoft|wsl/i.test(contents)) return true', - ' } catch {', - ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', - ' }', - ' }', - ' } catch {', - ' return false', - ' }', - ' return false', - '}', - '', - 'function resolveWindowsCurlPath(): string | null {', - ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', - ' try {', - " const fs = require('fs')", - " const curlPath = '/mnt/c/Windows/System32/curl.exe'", - ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', - ' } catch {', - ' cachedWindowsCurlPath = null', - ' }', - ' return cachedWindowsCurlPath', - '}', - '', - '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', - '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', - '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', - '// the pi event loop (and the TUI) on every hook event.', - 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', - ' const curlPath = resolveWindowsCurlPath()', - ' if (!curlPath) return', - ' try {', - " const { spawn } = require('child_process')", - ' const child = spawn(', - ' curlPath,', - ' [', - " '-sS',", - ' // Why: the spawn is detached from the event loop, so these bounds', - ' // size a background process, not TUI latency. WSL->Win32 interop', - ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', - ' // to a healthy listener); size for delivery, not snappiness.', - " '--connect-timeout', '3',", - " '--max-time', '10',", - " '--noproxy', '127.0.0.1',", - " '-o', 'NUL',", - " '-X', 'POST',", - " '-H', 'Content-Type: application/json',", - " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", - " '--data-binary', '@-',", - ' url', - ' ],', - " { stdio: ['pipe', 'ignore', 'ignore'] }", - ' )', - " child.on('error', () => {})", - " child.stdin.on('error', () => {})", - ' child.stdin.end(body)', - ' } catch {', - ' // Why: the bridge is best-effort; a failed spawn must not surface', - ' // inside the pi TUI.', - ' }', - '}', - '', + ...getPiAgentStatusWslCurlSourceLines(), ...getPiAgentStatusHandlerSourceLines(kind) ].join('\n') } diff --git a/src/main/pi/agent-status-wsl-curl-source.ts b/src/main/pi/agent-status-wsl-curl-source.ts new file mode 100644 index 00000000000..558e25a49db --- /dev/null +++ b/src/main/pi/agent-status-wsl-curl-source.ts @@ -0,0 +1,81 @@ +export function getPiAgentStatusWslCurlSourceLines(): string[] { + return [ + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.', + 'function postViaWindowsCurl(body: string, ompRuntime: boolean): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' const windowsPort = process.env.ORCA_AGENT_HOOK_PORT', + ' const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN', + ' if (!curlPath || !windowsPort || !windowsToken) return', + ' // Why: a stale guest endpoint must fall back to current host coordinates.', + ' const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: detached delivery may take seconds under loaded WSL interop.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,", + " '--data-binary', '@-',", + ' windowsUrl', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: status delivery must not surface inside the agent TUI.', + ' }', + '}', + '' + ] +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index e197a4a0c3a..485ea71cafa 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -94,6 +94,10 @@ describe('PiTitlebarExtensionService', () => { rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true }) rmSync(join(userDataDir, 'omp-managed-status-extension'), { recursive: true, force: true }) + rmSync(join(userDataDir, 'prime-agent-managed-status-extension'), { + recursive: true, + force: true + }) }) function expectPiHomeIntact(): void { @@ -181,6 +185,21 @@ describe('PiTitlebarExtensionService', () => { expectPiHomeIntact() }) + it('writes a Prime status-only extension outside the guest-owned config dir', () => { + const svc = new PiTitlebarExtensionService() + const env = svc.buildStatusOnlyPtyEnv('prime-agent') + const statusPath = join( + userDataDir, + 'prime-agent-managed-status-extension', + 'orca-agent-status.ts' + ) + + expect(env).toEqual({ ORCA_PRIME_AGENT_STATUS_EXTENSION: statusPath }) + expect(readFileSync(statusPath, 'utf8')).toContain('/hook/prime-agent') + expect(readFileSync(statusPath, 'utf8')).not.toContain("return '/hook/omp'") + expectPiHomeIntact() + }) + it('uses the same source dir for multiple PTYs with the same Pi dir', () => { const svc = new PiTitlebarExtensionService() const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 5e728fa0274..59c0f44797f 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -27,7 +27,10 @@ export const isSafeDescendCandidate = sharedIsSafeDescendCandidate const PI_AGENT_SUBDIR = 'agent' const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension' -const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' +const MANAGED_STATUS_EXTENSION_DIR: Record<'omp' | 'prime-agent', string> = { + omp: 'omp-managed-status-extension', + 'prime-agent': 'prime-agent-managed-status-extension' +} type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed' @@ -121,8 +124,11 @@ export class PiTitlebarExtensionService { } } - private writeOmpFallbackStatusExtension(source: string): string | undefined { - const fallbackDir = join(app.getPath('userData'), OMP_MANAGED_STATUS_EXTENSION_DIR) + private writeFallbackStatusExtension( + source: string, + kind: keyof typeof MANAGED_STATUS_EXTENSION_DIR + ): string | undefined { + const fallbackDir = join(app.getPath('userData'), MANAGED_STATUS_EXTENSION_DIR[kind]) try { mkdirSync(fallbackDir, { recursive: true }) } catch { @@ -165,7 +171,7 @@ export class PiTitlebarExtensionService { statusResult === 'written' ? statusExtensionPath : kind === 'omp' - ? this.writeOmpFallbackStatusExtension(statusSource) + ? this.writeFallbackStatusExtension(statusSource, 'omp') : undefined } } @@ -195,7 +201,7 @@ export class PiTitlebarExtensionService { if (!existsSync(sourceAgentDir) && !materializeDefaultHome) { if (kind === 'omp') { const statusSource = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) - const statusExtensionPath = this.writeOmpFallbackStatusExtension(statusSource) + const statusExtensionPath = this.writeFallbackStatusExtension(statusSource, 'omp') return statusExtensionPath ? { ORCA_OMP_STATUS_EXTENSION: statusExtensionPath } : {} } return {} @@ -220,6 +226,12 @@ export class PiTitlebarExtensionService { return env } + buildStatusOnlyPtyEnv(kind: 'prime-agent'): Record { + const source = withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind)) + const statusExtensionPath = this.writeFallbackStatusExtension(source, kind) + return statusExtensionPath ? { ORCA_PRIME_AGENT_STATUS_EXTENSION: statusExtensionPath } : {} + } + clearPty(ptyId: string): void { // Why: PTY teardown doesn't know which kind was launched (the daemon // exit path discards the launch command). Sweep both old PTY-scoped diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 1cb10ad7f14..d9034a5cc24 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -526,6 +526,8 @@ describePosix('local PTY shell-ready launch config', () => { '[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"' const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0' const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"' + const primeWrapperLine = + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' expect(zshrc).toContain(restoreLine) expect(zlogin).toContain(restoreLine) expect(bashRc).toContain(restoreLine) @@ -547,6 +549,9 @@ describePosix('local PTY shell-ready launch config', () => { expect(zshrc).toContain(ompWrapperLine) expect(zlogin).toContain(ompWrapperLine) expect(bashRc).toContain(ompWrapperLine) + expect(zshrc).toContain(primeWrapperLine) + expect(zlogin).toContain(primeWrapperLine) + expect(bashRc).toContain(primeWrapperLine) }) // Why: issue #2422 — without OSC 133 C/D markers, bash sessions kept the worktree spinner "working" ~30min after the agent exited. diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 18052dfa670..63885e23579 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -15,6 +15,7 @@ import { isPowerShellExecutableName } from '../powershell-osc133-bootstrap' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { getPosixPrimeAgentShellWrapper } from '../pty/prime-agent-shell-wrapper' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { getFishShellReadyInitCommand, @@ -129,6 +130,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" # Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent @@ -245,6 +247,7 @@ if [[ ! -o login ]]; then [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} + ${getPosixPrimeAgentShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" fi @@ -305,6 +308,7 @@ __orca_restore_agent_teams_path [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} +${getPosixPrimeAgentShellWrapper()} [[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}" ${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)} ${getZshFinalZdotdirRestoreBlock()} diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 39cb81ee9b5..53a83e6638d 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -280,13 +280,16 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) - // Why: the point of materializing wrappers for WSL is that a typed `omp` - // picks up Orca's status extension; pin that shim end to end. + // Why: typed agents need the host-managed status extension in WSL. const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') for (const wrapperFile of [bashRcfile, zshLogin]) { expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + expect(wrapperFile).toContain( + 'command prime-agent --extension "${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@"' + ) + expect(wrapperFile).toContain('prime-agent() { __orca_prime_agent "$@"; }') } }) diff --git a/src/main/pty/prime-agent-shell-wrapper.test.ts b/src/main/pty/prime-agent-shell-wrapper.test.ts new file mode 100644 index 00000000000..1ad1fe774cb --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.test.ts @@ -0,0 +1,244 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getPosixPrimeAgentShellWrapper } from './prime-agent-shell-wrapper' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 +const itWithBash = hasBash ? it : it.skip +const tempDirs: string[] = [] + +function makeFixture(): { + root: string + extensionPath: string + capturePath: string + endpointPath: string + primeHome: string + rcfile: string +} { + const root = mkdtempSync(join(tmpdir(), 'orca-prime-wrapper-')) + tempDirs.push(root) + const binDir = join(root, 'bin') + const primeHome = join(root, 'guest-prime-home') + const extensionPath = join(root, 'orca-agent-status.ts') + const capturePath = join(root, 'capture') + const endpointPath = join( + root, + '.orca-wsl', + 'agent-hooks', + 'instance-testinstance', + 'endpoint.env' + ) + const rcfile = join(root, 'rcfile') + mkdirSync(binDir) + mkdirSync(primeHome) + mkdirSync(dirname(endpointPath), { recursive: true }) + writeFileSync(extensionPath, 'export default {}') + writeFileSync(endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=current-token\n') + writeFileSync(rcfile, getPosixPrimeAgentShellWrapper()) + writeFileSync( + join(binDir, 'prime-agent'), + `#!/bin/sh +{ + printf 'HOME=%s\n' "$PRIME_AGENT_CODING_AGENT_DIR" + printf 'ENDPOINT=%s\n' "$ORCA_AGENT_HOOK_ENDPOINT" + i=0 + for arg in "$@"; do + i=$((i + 1)) + printf 'ARG%s=%s\n' "$i" "$arg" + done +} > "$ORCA_CAPTURE_FILE" +if [ "\${1:-}" = "config" ]; then + printf 'preserved\n' > "$PRIME_AGENT_CODING_AGENT_DIR/config.yml" +fi +`, + { mode: 0o755 } + ) + chmodSync(join(binDir, 'prime-agent'), 0o755) + return { root, extensionPath, capturePath, endpointPath, primeHome, rcfile } +} + +function runPrime( + fixture: ReturnType, + command: string, + extensionPath = fixture.extensionPath +): string { + const result = spawnSync('bash', ['--noprofile', '--rcfile', fixture.rcfile, '-ic', command], { + cwd: fixture.root, + env: { + ...process.env, + HOME: fixture.root, + PATH: `${join(fixture.root, 'bin')}:${process.env.PATH ?? ''}`, + PRIME_AGENT_CODING_AGENT_DIR: fixture.primeHome, + ORCA_PRIME_AGENT_STATUS_EXTENSION: extensionPath, + ORCA_WSL_HOOK_INSTANCE: 'testinstance', + ORCA_AGENT_HOOK_ENDPOINT: '/mnt/c/current/endpoint.cmd', + ORCA_AGENT_HOOK_PORT: '4567', + ORCA_AGENT_HOOK_TOKEN: 'current-token', + ORCA_CAPTURE_FILE: fixture.capturePath + }, + encoding: 'utf8' + }) + expect(result.status, result.stderr).toBe(0) + return readFileSync(fixture.capturePath, 'utf8') +} + +describePosix('Prime Agent shell wrapper', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + itWithBash('injects the managed extension into an interactive launch', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain(`ARG2=${fixture.extensionPath}`) + expect(capture).toContain('ARG3=ask') + }) + + itWithBash('preserves config subcommands and the guest Prime home', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent config') + + expect(capture).toContain(`HOME=${fixture.primeHome}`) + expect(capture).toContain('ARG1=config') + expect(capture).not.toContain('ARG1=--extension') + expect(readFileSync(join(fixture.primeHome, 'config.yml'), 'utf8')).toBe('preserved\n') + }) + + itWithBash.each([ + 'agents', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' + ])('preserves the %s subcommand argv', (subcommand) => { + const fixture = makeFixture() + const capture = runPrime(fixture, `prime-agent ${subcommand}`) + + expect(capture).toContain(`ARG1=${subcommand}`) + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash.each(['stop', 'rename'])( + 'preserves --daemon-socket before the %s management command', + (subcommand) => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + `prime-agent --daemon-socket /tmp/prime.sock ${subcommand} agent-123` + ) + + expect(capture).toContain('ARG1=--daemon-socket') + expect(capture).toContain('ARG2=/tmp/prime.sock') + expect(capture).toContain(`ARG3=${subcommand}`) + expect(capture).not.toContain(fixture.extensionPath) + } + ) + + itWithBash('injects the extension after the attach agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach agent-123 --follow') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain(`ARG4=${fixture.extensionPath}`) + expect(capture).toContain('ARG5=--follow') + }) + + itWithBash('preserves attach help without inventing an agent operand', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent attach --help') + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=--help') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not override an explicit user extension', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent --extension /tmp/user-extension.ts ask') + + expect(capture).toContain('ARG1=--extension') + expect(capture).toContain('ARG2=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('does not add a second extension to attach', () => { + const fixture = makeFixture() + const capture = runPrime( + fixture, + 'prime-agent attach agent-123 --extension /tmp/user-extension.ts' + ) + + expect(capture).toContain('ARG1=attach') + expect(capture).toContain('ARG2=agent-123') + expect(capture).toContain('ARG3=--extension') + expect(capture).toContain('ARG4=/tmp/user-extension.ts') + expect(capture).not.toContain(fixture.extensionPath) + }) + + itWithBash('degrades to the original argv when the managed extension is unavailable', () => { + const fixture = makeFixture() + const capture = runPrime(fixture, 'prime-agent ask', join(fixture.root, 'missing.ts')) + + expect(capture).toContain('ARG1=ask') + expect(capture).not.toContain('ARG1=--extension') + }) + + itWithBash('keeps the stable guest endpoint path while its relay refreshes stale data', () => { + const fixture = makeFixture() + writeFileSync(fixture.endpointPath, 'ORCA_AGENT_HOOK_PORT=4567\nORCA_AGENT_HOOK_TOKEN=stale\n') + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('uses the guest endpoint path when the relay binds a fallback port', () => { + const fixture = makeFixture() + writeFileSync( + fixture.endpointPath, + 'ORCA_AGENT_HOOK_PORT=9876\nORCA_AGENT_HOOK_TOKEN=current-token\n' + ) + + const capture = runPrime(fixture, 'prime-agent ask') + + expect(capture).toContain(`ENDPOINT=${fixture.endpointPath}`) + expect(capture).toContain('ARG1=--extension') + }) + + itWithBash('does not define wrapper helpers when the bridge env is absent', () => { + const fixture = makeFixture() + const result = spawnSync( + 'bash', + ['--noprofile', '--rcfile', fixture.rcfile, '-ic', 'type -t __orca_prime_agent'], + { + cwd: fixture.root, + env: { ...process.env, ORCA_PRIME_AGENT_STATUS_EXTENSION: '' }, + encoding: 'utf8' + } + ) + + expect(result.status).not.toBe(0) + expect(result.stdout.trim()).toBe('') + }) +}) diff --git a/src/main/pty/prime-agent-shell-wrapper.ts b/src/main/pty/prime-agent-shell-wrapper.ts new file mode 100644 index 00000000000..97e723c26f1 --- /dev/null +++ b/src/main/pty/prime-agent-shell-wrapper.ts @@ -0,0 +1,74 @@ +import { wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' + +const PRIME_AGENT_SUBCOMMANDS = [ + 'agents', + 'config', + 'doctor', + 'help', + 'list', + 'model', + 'package', + 'rename', + 'schedule', + 'send', + 'session', + 'shutdown', + 'status', + 'stop', + 'update' +] as const + +export function getPosixPrimeAgentShellWrapper(): string { + const subcommands = PRIME_AGENT_SUBCOMMANDS.join('|') + const guestEndpointPath = wslHookRelayEndpointFilePath('${HOME%/}', '${ORCA_WSL_HOOK_INSTANCE}') + return `# Why: WSL cannot install into Prime's guest-owned config root from the +# Windows host, so pass Orca's status extension only to interactive launches. +if [[ -n "\${ORCA_PRIME_AGENT_STATUS_EXTENSION:-}" ]]; then + __orca_prime_agent_should_skip_extension() { + if [[ "\${1:-}" == "--daemon-socket" && ( "\${3:-}" == "stop" || "\${3:-}" == "rename" ) ]]; then + return 0 + fi + if [[ "\${1:-}" == --daemon-socket=* && ( "\${2:-}" == "stop" || "\${2:-}" == "rename" ) ]]; then + return 0 + fi + case "\${1:-}" in + help|--help|-h|--version|-v|--extension|--extension=*) return 0 ;; + ${subcommands}) return 0 ;; + esac + return 1 + } + __orca_prime_agent_has_explicit_extension() { + local __orca_arg + for __orca_arg in "$@"; do + case "$__orca_arg" in + --extension|--extension=*) return 0 ;; + esac + done + return 1 + } + __orca_prime_agent() { + if [[ "\${1:-}" == "attach" && ( -z "\${2:-}" || "\${2:-}" == -* ) ]]; then + command prime-agent "$@" + return + fi + if ! __orca_prime_agent_should_skip_extension "$@" && ! __orca_prime_agent_has_explicit_extension "$@" && [[ -f "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" ]]; then + local __orca_guest_endpoint="${guestEndpointPath}" + if [[ -n "\${HOME:-}" && -n "\${ORCA_WSL_HOOK_INSTANCE:-}" ]]; then + local ORCA_AGENT_HOOK_ENDPOINT="$__orca_guest_endpoint" + export ORCA_AGENT_HOOK_ENDPOINT + fi + if [[ "\${1:-}" == "attach" && -n "\${2:-}" && "\${2:-}" != -* ]]; then + local __orca_attach_agent="$2" + shift 2 + command prime-agent attach "$__orca_attach_agent" --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + else + command prime-agent --extension "\${ORCA_PRIME_AGENT_STATUS_EXTENSION}" "$@" + fi + else + command prime-agent "$@" + fi + } + prime-agent() { __orca_prime_agent "$@"; } +fi +` +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 15c6b43cf44..c27655ba7e4 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -48,6 +48,8 @@ describe('addOrcaWslInteropEnv', () => { ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_CLI_COMMAND: 'orca-ide', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', + ORCA_PRIME_AGENT_STATUS_EXTENSION: + 'C:\\Users\\jin\\AppData\\Roaming\\Orca\\prime-agent-managed-status-extension\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', @@ -56,6 +58,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' @@ -67,6 +70,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') + expect(env.WSLENV).toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u') @@ -75,6 +79,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 40f3b957249..5e90eaa6b4a 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -96,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_WSL_HOOK_INSTANCE/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', 'ORCA_OMP_STATUS_EXTENSION/p', + 'ORCA_PRIME_AGENT_STATUS_EXTENSION/p', ...worktreeSetupWslenvEntries(env) ] applyWslenvPassthrough(env, passthroughEntries) From 016df33f00ea3588c1b945fcb2ec985ba8d8ac79 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:14:17 -0700 Subject: [PATCH 20/20] fix(github): complete PR reactions for CodeRabbit reviews (#13456) * feat(github): add PR comment reaction controls * feat(github): add full PR comment reaction picker * fix(github): cover all reactable PR comment paths * fix(github): reconcile comment reactions with main * fix(github): preserve focus on failed reaction removal --- src/main/github/client.test.ts | 54 +++++++++++ src/main/github/client.ts | 38 +++++++- .../components/github/CommentReactions.tsx | 62 +++++++++--- .../components/right-sidebar/ChecksPanel.tsx | 11 ++- .../right-sidebar/checks-panel-content.tsx | 8 +- .../helpers/pr-comments-sidebar-fixture.ts | 3 +- tests/e2e/pr-comments-sidebar-cards.spec.ts | 95 ++++++++++++++++--- 7 files changed, 237 insertions(+), 34 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index eef3aace0e7..a33b9b38d20 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -4044,6 +4044,60 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql') }) + it('maps review summary reaction subjects from GraphQL', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [] }, + comments: { nodes: [] }, + reviews: { + nodes: [ + { + id: 'PRR_44', + databaseId: 44, + author: { + __typename: 'Bot', + login: 'coderabbitai', + avatarUrl: 'https://avatar' + }, + body: 'Automated review summary', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/7#pullrequestreview-44', + reactionGroups: [ + { + content: 'ROCKET', + viewerHasReacted: true, + reactors: { totalCount: 2 } + } + ] + } + ] + } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) + + await expect(getPRComments('/repo-root', 7)).resolves.toEqual([ + expect.objectContaining({ + id: 44, + reactionSubjectId: 'PRR_44', + isBot: true, + reactions: [{ content: 'rocket', count: 2, viewerHasReacted: true }] + }) + ]) + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([expect.stringContaining('reviews(first: 100)')]) + ) + }) + it('uses explicit PR repo for comments when a fork PR is discovered', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 46ea1180e24..7518c9d3581 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -4157,6 +4157,23 @@ query($owner: String!, $repo: String!, $pr: Int!) { } } } + reviews(first: 100) { + nodes { + id + databaseId + author { __typename login avatarUrl(size: 48) } + body + createdAt + url + reactionGroups { + content + viewerHasReacted + reactors { + totalCount + } + } + } + } } } }` @@ -4284,6 +4301,7 @@ export async function getPRComments( url: string reactionGroups?: GitHubGraphQLReactionGroup[] | null } + let graphQLReviewSummaries: PRComment[] | undefined const reviewComments: PRComment[] = [] if (threadsResult.status === 'fulfilled' && threadsResult.value) { const threadsData = JSON.parse(threadsResult.value.stdout) as { @@ -4292,6 +4310,7 @@ export async function getPRComments( pullRequest: { reviewThreads: { nodes: GQLThread[] } comments?: { nodes: GQLIssueComment[] } + reviews?: { nodes: GQLIssueComment[] } } } } @@ -4313,6 +4332,21 @@ export async function getPRComments( if (graphQLIssueComments.length > 0) { issueComments = graphQLIssueComments } + graphQLReviewSummaries = (pullRequest.reviews?.nodes ?? []) + .filter((review) => review.body?.trim()) + .map( + (review): PRComment => ({ + id: review.databaseId, + author: review.author?.login ?? 'ghost', + authorAvatarUrl: review.author?.avatarUrl ?? '', + body: review.body, + createdAt: review.createdAt, + url: review.url, + isBot: review.author?.__typename === 'Bot', + reactionSubjectId: review.id, + reactions: mapGraphQLReactionGroups(review.reactionGroups) + }) + ) const threads = pullRequest.reviewThreads.nodes for (const thread of threads) { @@ -4353,7 +4387,9 @@ export async function getPRComments( html_url: string } let reviewSummaries: PRComment[] = [] - if (reviewsResult.status === 'fulfilled') { + if (graphQLReviewSummaries) { + reviewSummaries = graphQLReviewSummaries + } else if (reviewsResult.status === 'fulfilled') { reviewSummaries = (JSON.parse(reviewsResult.value.stdout) as RESTReview[]) .filter((r) => r.body?.trim()) .map( diff --git a/src/renderer/src/components/github/CommentReactions.tsx b/src/renderer/src/components/github/CommentReactions.tsx index bbdca0fbe3d..21aa4ac2316 100644 --- a/src/renderer/src/components/github/CommentReactions.tsx +++ b/src/renderer/src/components/github/CommentReactions.tsx @@ -37,9 +37,15 @@ export function CommentReactions({ }: { reactions?: GitHubReaction[] className?: string - onReactionChange?: (content: GitHubReactionContent, reacted: boolean) => Promise | void + onReactionChange?: ( + content: GitHubReactionContent, + reacted: boolean + ) => Promise | boolean }): React.JSX.Element | null { const visibleReactions = (reactions ?? []).filter((reaction) => reaction.count > 0) + const addReactionButtonRef = React.useRef(null) + const pickerGroupRef = React.useRef(null) + const mutationPendingRef = React.useRef(false) const [open, setOpen] = React.useState(false) const [pendingContent, setPendingContent] = React.useState(null) if (visibleReactions.length === 0 && !onReactionChange) { @@ -48,16 +54,25 @@ export function CommentReactions({ const changeReaction = async ( content: GitHubReactionContent, - reacted: boolean + reacted: boolean, + closePicker: boolean, + focusTriggerAfterChange = false ): Promise => { - if (!onReactionChange || pendingContent) { + if (!onReactionChange || mutationPendingRef.current) { return } + mutationPendingRef.current = true setPendingContent(content) - setOpen(false) + if (focusTriggerAfterChange) { + addReactionButtonRef.current?.focus() + } try { - await onReactionChange(content, reacted) + const changed = await onReactionChange(content, reacted) + if (changed && closePicker) { + setOpen(false) + } } finally { + mutationPendingRef.current = false setPendingContent(null) } } @@ -76,7 +91,7 @@ export function CommentReactions({ type="button" variant="outline" size="xs" - disabled={pendingContent !== null} + aria-disabled={pendingContent !== null} className={cn( 'h-6 gap-1 rounded-full px-2 text-[12px] font-normal', reaction.viewerHasReacted && 'border-ring bg-accent text-accent-foreground' @@ -91,7 +106,14 @@ export function CommentReactions({ value2: reaction.count === 1 ? '' : 's' } )} - onClick={() => void changeReaction(reaction.content, !reaction.viewerHasReacted)} + onClick={() => + void changeReaction( + reaction.content, + !reaction.viewerHasReacted, + false, + Boolean(reaction.viewerHasReacted && reaction.count === 1) + ) + } > {reaction.count} @@ -121,10 +143,11 @@ export function CommentReactions({ diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 805a125d565..9fc66a78819 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -2945,10 +2945,14 @@ export default function ChecksPanel(): React.JSX.Element { ) const handleSetReaction = useCallback( - async (comment: PRComment, content: GitHubReactionContent, reacted: boolean): Promise => { + async ( + comment: PRComment, + content: GitHubReactionContent, + reacted: boolean + ): Promise => { const reactionSubjectId = comment.reactionSubjectId if (!repo || !prNumber || !pr?.prRepo || !reactionSubjectId) { - return + return false } const requestKey = checksPanelAsyncResultKey( prCacheKey, @@ -2968,7 +2972,7 @@ export default function ChecksPanel(): React.JSX.Element { { repoId: repo.id, prRepo: pr.prRepo } ) if (!isCurrentAsyncResult(requestKey) || ok) { - return + return ok } setComments((current) => restoreReactionOnSubject(current, reactionSubjectId, content, previousReaction) @@ -2979,6 +2983,7 @@ export default function ChecksPanel(): React.JSX.Element { 'Failed to update reaction.' ) ) + return false }, [branch, isCurrentAsyncResult, pr, prCacheKey, prNumber, repo, setPRCommentReaction] ) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index dda1c3ee4e1..2b2b695b189 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -1756,7 +1756,7 @@ function CommentRow({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { const automated = isBotPRComment(comment, botAuthorOverrides) @@ -2070,7 +2070,7 @@ function PRCommentGroupView({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise onQueueForAgent?: () => void }): React.JSX.Element { // Reply targets a specific comment id so any comment in a thread — root or @@ -2219,7 +2219,7 @@ function ResolvedCommentGroupsSection({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise }): React.JSX.Element | null { if (groups.length === 0) { return null @@ -2344,7 +2344,7 @@ export function PRCommentsList({ comment: PRComment, content: GitHubReactionContent, reacted: boolean - ) => Promise + ) => Promise }): React.JSX.Element { const presentation = React.useMemo(() => getPRCommentPresentationClasses(), []) const [commentFilter, setCommentFilter] = useState('all') diff --git a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts index b44aaa84c3c..3fc047ec1bf 100644 --- a/tests/e2e/helpers/pr-comments-sidebar-fixture.ts +++ b/tests/e2e/helpers/pr-comments-sidebar-fixture.ts @@ -10,7 +10,7 @@ export type PRCommentsSidebarSeed = { export const FIXTURE_COMMENTS: PRComment[] = [ { id: 101, - author: 'alice', + author: 'coderabbitai', authorAvatarUrl: '', body: 'Please update this handler before merge.', createdAt: '2026-05-14T10:00:00.000Z', @@ -18,6 +18,7 @@ export const FIXTURE_COMMENTS: PRComment[] = [ reactionSubjectId: 'PRRC_101', threadId: 'thread-open', path: 'src/handler.ts', + isBot: true, isResolved: false }, { diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index ee95b3f74cb..d0cfa4498c2 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -57,7 +57,7 @@ test.describe('PR comments sidebar cards view', () => { await expect(orcaPage.getByText('Needs review · 1')).toBeVisible() await expect(orcaPage.getByText('Please update this handler before merge.')).toBeVisible() - await expect(orcaPage.getByText('alice')).toBeVisible() + await expect(orcaPage.getByText('coderabbitai')).toBeVisible() await expect(orcaPage.getByText('LGTM on the overall approach.')).toBeVisible() const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ @@ -75,7 +75,7 @@ test.describe('PR comments sidebar cards view', () => { 'Please update this handler before merge.', 'LGTM on the overall approach.' ) - await expectOpenTextNotShiftedLeft(openThreadCard, conversationCard, 'alice', 'bob') + await expectOpenTextNotShiftedLeft(openThreadCard, conversationCard, 'coderabbitai', 'bob') const resolvedTrigger = orcaPage.getByRole('button', { name: 'Resolved · 1' }) await expect(resolvedTrigger).toBeVisible() @@ -124,37 +124,106 @@ test.describe('PR comments sidebar cards view', () => { expect(positions[1]).toBeLessThan(positions[2]) }) - test('adds reactions to conversation and review-thread comments', async ({ orcaPage }) => { + test('adds reactions to conversation and review-thread comments', async ({ + orcaPage + }, testInfo) => { const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) await openChecks(orcaPage, worktreeId) await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ + hasText: 'Please update this handler before merge.' + }) + const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-before.png') }) + await threadReactionButton.click() + await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeFocused() + await orcaPage.waitForTimeout(300) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-picker.png') }) + await orcaPage.getByRole('button', { name: 'Add rocket reaction' }).click() + await expect(orcaPage.getByRole('group', { name: 'Add reaction' })).toBeHidden() + const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') + await selectedRocket.focus() + await orcaPage.waitForTimeout(300) + await orcaPage.screenshot({ path: testInfo.outputPath('reaction-after.png') }) + await selectedRocket.press('Enter') + await expect(selectedRocket).toHaveCount(0) + await expect(threadReactionButton).toBeFocused() + const conversationCard = orcaPage.getByTestId('pr-comment-group').filter({ hasText: 'LGTM on the overall approach.' }) const conversationReactionButton = conversationCard.getByRole('button', { name: 'Add reaction' }) - await conversationReactionButton.evaluate((element) => (element as HTMLElement).click()) - const heartReactionButton = orcaPage.getByRole('button', { name: 'Add heart reaction' }) + await conversationReactionButton.click() + const conversationPicker = orcaPage.getByRole('group', { name: 'Add reaction' }).last() + const heartReactionButton = conversationPicker.getByRole('button', { + name: 'Add heart reaction' + }) await expect(heartReactionButton).toBeVisible() await heartReactionButton.evaluate((element) => (element as HTMLElement).click()) await expect( conversationCard.getByRole('button', { name: '1 heart reaction' }) ).toHaveAttribute('aria-pressed', 'true') await expect(orcaPage.getByRole('button', { name: 'Add rocket reaction' })).toHaveCount(0) + }) + + test('keeps reaction focus while a remote mutation fails', async ({ orcaPage }) => { + const { worktreeId } = await seedPRCommentsSidebarFixture(orcaPage) + await openChecks(orcaPage, worktreeId) + await expect(orcaPage.getByText('Needs review · 1')).toBeVisible({ timeout: 10_000 }) + await orcaPage.evaluate(() => { + window.__store?.setState({ + setPRCommentReaction: async () => { + await new Promise((resolve) => window.setTimeout(resolve, 300)) + return false + } + }) + }) const reviewThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ hasText: 'Please update this handler before merge.' }) - const threadReactionButton = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) - await threadReactionButton.evaluate((element) => (element as HTMLElement).click()) - await orcaPage - .getByRole('button', { name: 'Add rocket reaction' }) - .evaluate((element) => (element as HTMLElement).click()) - await expect( - reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) - ).toHaveAttribute('aria-pressed', 'true') + const addReaction = reviewThreadCard.getByRole('button', { name: 'Add reaction' }) + await addReaction.focus() + await addReaction.press('Enter') + const picker = orcaPage.getByRole('group', { name: 'Add reaction' }) + await expect(picker).toBeFocused() + const rocket = picker.getByRole('button', { name: /rocket reaction/ }) + await rocket.focus() + await rocket.press('Enter') + await expect(rocket).toBeFocused() + await expect(rocket).toHaveAttribute('aria-disabled', 'true') + await rocket.press('Enter') + await expect(picker).toBeVisible() + await expect(rocket).toHaveAttribute('aria-disabled', 'false') + await expect(rocket).toBeFocused() + await expect(rocket).toHaveAccessibleName('Add rocket reaction') + + await orcaPage.evaluate(() => { + window.__store?.setState({ setPRCommentReaction: async () => true }) + }) + await rocket.press('Enter') + const selectedRocket = reviewThreadCard.getByRole('button', { name: '1 rocket reaction' }) + await expect(selectedRocket).toHaveAttribute('aria-pressed', 'true') + await orcaPage.evaluate(() => { + window.__store?.setState({ + setPRCommentReaction: async () => { + await new Promise((resolve) => window.setTimeout(resolve, 300)) + return false + } + }) + }) + await selectedRocket.focus() + await selectedRocket.press('Enter') + await expect(addReaction).toBeFocused() + await expect(addReaction).toHaveAttribute('aria-disabled', 'true') + await expect(selectedRocket).toHaveCount(0) + await expect(selectedRocket).toHaveCount(1) + await expect(addReaction).toHaveAttribute('aria-disabled', 'false') + await expect(addReaction).toBeFocused() }) test('queues an open thread for the agent from the visible row action and menu fallback', async ({