diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 00000000000..8014145939b --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,98 @@ +name: Release Policy + +on: + release: + types: + - published + - edited + +permissions: + contents: write + +concurrency: + group: release-policy + cancel-in-progress: false + +jobs: + enforce: + if: github.repository == 'stablyai/orca' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Enforce release policy + uses: actions/github-script@v8 + with: + script: | + const release = context.payload.release; + const tag = release.tag_name; + const author = release.author?.login; + const number = "(?:0|[1-9][0-9]*)"; + const version = `${number}\\.${number}\\.${number}`; + const stableTag = new RegExp(`^v${version}$`); + const prereleaseTag = new RegExp( + `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, + ); + const expectedPrerelease = prereleaseTag.test(tag); + const allowed = author === "github-actions[bot]" && + (stableTag.test(tag) || expectedPrerelease); + const { owner, repo } = context.repo; + + async function restoreLatestStable() { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const stable = releases + .filter((candidate) => + !candidate.draft && + !candidate.prerelease && + candidate.author?.login === "github-actions[bot]" && + stableTag.test(candidate.tag_name), + ) + .sort((left, right) => { + const a = left.tag_name.slice(1).split(".").map(Number); + const b = right.tag_name.slice(1).split(".").map(Number); + return a.reduce((result, part, index) => result || part - b[index], 0); + }) + .at(-1); + if (stable) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: stable.id, + make_latest: "true", + }); + } + } + + if (allowed) { + if (release.prerelease !== expectedPrerelease) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + prerelease: expectedPrerelease, + make_latest: expectedPrerelease ? "false" : "legacy", + }); + } + await restoreLatestStable(); + return; + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: true, + prerelease: true, + make_latest: "false", + }); + await restoreLatestStable(); + await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); + try { + await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status !== 404) throw error; + } + core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); diff --git a/README.md b/README.md index 670b54e2835..b679cb95c3d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b37d3374bf5..d3c9f3b0864 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -7,7 +7,7 @@ "count": 1 }, { - "filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", "kind": "jsx-attribute:label", "text": "sidebar", "dynamic": false, diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3275e33987a..8710b4e82e3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -235,7 +235,6 @@ inline src/renderer/src/components/settings/Settings.tsx inline src/renderer/src/components/settings/SettingsFormControls.tsx inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -inline src/renderer/src/components/sidebar/WorktreeCard.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts inline src/renderer/src/components/sidebar/WorktreeList.tsx @@ -270,7 +269,6 @@ inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts inline src/renderer/src/hooks/useIpcEvents.test.ts inline src/renderer/src/hooks/useIpcEvents.ts -inline src/renderer/src/hooks/useIssueMetadata.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/active-agent-note-send.test.ts inline src/renderer/src/lib/file-type-icons.ts @@ -342,7 +340,6 @@ mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx mobile-config app/pair-scan.tsx -mobile-config app/terminal-settings.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts mobile-config scripts/repro-terminal-colors.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 16e479b2666..f5e5a39caeb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1969,7 +1969,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; a terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", "oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -1989,11 +1989,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2711,11 +2718,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2727,6 +2736,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -7867,16 +7883,24 @@ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" ], "testFiles": [ "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..bd0c3c15aee 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1058,36 +1058,6 @@ "auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": { "ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "auto.components.feature.wall.FeatureTourPreview.04d54d50ec": { - "ko": "orca · zsh" - }, - "auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": { - "ko": "생각 중..." - }, - "auto.components.feature.wall.FeatureTourPreview.3822d8d14b": { - "ko": "fix/worktree-picker-truncates" - }, - "auto.components.feature.wall.FeatureTourPreview.47f16ecf34": { - "ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다." - }, - "auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": { - "ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, - "auto.components.feature.wall.FeatureTourPreview.6218a9014d": { - "ko": "pnpm playwright test" - }, - "auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": { - "ko": "dashboard.spec.ts" - }, - "auto.components.feature.wall.FeatureTourPreview.70aa182266": { - "ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다." - }, - "auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": { - "ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요." - }, - "auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": { - "ko": "작업 중" - }, "auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": { "ko": "마일스톤" }, @@ -1502,12 +1472,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, @@ -1520,9 +1484,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1499,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, @@ -1556,18 +1511,6 @@ "auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": { "ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요." }, - "auto.components.onboarding.RepoStep.466108ab89": { - "ko": "선택한 호스트에 존재하는 경로를 입력하세요." - }, - "auto.components.onboarding.RepoStep.6558d50c69": { - "ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요." - }, - "auto.components.onboarding.RepoStep.c33b190ca3": { - "ko": "호스트 경로만" - }, - "auto.components.onboarding.RepoStep.cf23006ba7": { - "ko": "선택한 호스트" - }, "auto.components.onboarding.ThemeStep.7ee9234e54": { "ko": "Ghostty 설정을 찾았습니다." }, @@ -4976,9 +4919,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..ba23a49cdd0 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 14295cb475a..747780e416b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", + "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 735e1b2a0af..d23bee128c9 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ test: { environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. - execArgv: ['--no-experimental-webstorage'], + // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. + execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..c21496b40bc 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7414e4cea7a..a3074230367 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 17m + + downloads: 18m @@ -15,7 +15,7 @@ downloads downloads - 17m - 17m + 18m + 18m diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 51dd82305f1..877a6d7e123 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -93,12 +93,6 @@ "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["app/terminal-settings.tsx"], - "rules": { - "max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 677ded5aa78..553490b4748 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -115,13 +115,15 @@ import { import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' import { - type GitHubProjectSortDirection, - type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, type ProjectGroup -} from '../../../src/tasks/mobile-github-project-group-sort' +} from '../../../../src/shared/github-project-group-sort' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../../src/shared/github-project-types' import { CROSS_REPO_DISPLAY_LIMIT, isGitHubWorkItemsSshRemoteRequiredError, diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e55819aa22a..860f906e77b 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { View, Text, Pressable, Switch } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { @@ -9,7 +9,7 @@ import Animated, { } from 'react-native-reanimated' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' @@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' +import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' import { loadTerminalAutocompleteEnabled, loadTerminalTextScale, @@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg, - paddingTop: 0 - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - inputGroupGap: { - marginTop: spacing.xl - }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - } -}) diff --git a/mobile/src/components/MobilePrBasePicker.tsx b/mobile/src/components/MobilePrBasePicker.tsx deleted file mode 100644 index be9f92d65fc..00000000000 --- a/mobile/src/components/MobilePrBasePicker.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { Check, ChevronDown } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { RpcClient } from '../transport/rpc-client' -import { searchBaseRefs } from '../source-control/mobile-base-ref-search' - -type Props = { - client: RpcClient | null - worktreeId: string - value: string - onChange: (ref: string) => void - editable?: boolean -} - -// Base-branch field for the create-PR composer: a free-text input that also searches -// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's -// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still -// be entered. -export function MobilePrBasePicker({ - client, - worktreeId, - value, - onChange, - editable = true -}: Props) { - const [results, setResults] = useState([]) - const [focused, setFocused] = useState(false) - const timer = useRef | null>(null) - // Guards: drop results after unmount, and ignore an earlier search whose response - // arrives after a later one (out-of-order network) so stale matches can't clobber. - const mounted = useRef(true) - const seq = useRef(0) - - useEffect(() => { - return () => { - mounted.current = false - if (timer.current) { - clearTimeout(timer.current) - } - } - }, []) - - const queryRefs = useCallback( - (query: string) => { - if (timer.current) { - clearTimeout(timer.current) - } - if (!client || query.trim().length === 0) { - // Advance the generation so an earlier in-flight search can't land and - // repopulate results after the input was cleared. - seq.current += 1 - setResults([]) - return - } - timer.current = setTimeout(() => { - const requestSeq = ++seq.current - void searchBaseRefs(client, worktreeId, query.trim()) - .then((refs) => { - if (!mounted.current || requestSeq !== seq.current) { - return - } - setResults(refs.filter((r) => r !== query).slice(0, 6)) - }) - // Why: a rejected ref search must not escape as an unhandled rejection; - // drop to an empty result set (free text stays valid to submit). - .catch(() => { - if (mounted.current && requestSeq === seq.current) { - setResults([]) - } - }) - }, 200) - }, - [client, worktreeId] - ) - - return ( - - - { - onChange(text) - queryRefs(text) - }} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - placeholder="main" - placeholderTextColor={colors.textMuted} - autoCapitalize="none" - autoCorrect={false} - editable={editable} - /> - - - {focused && results.length > 0 ? ( - - {results.map((ref) => ( - [styles.resultRow, pressed && styles.resultRowPressed]} - onPress={() => { - onChange(ref) - setResults([]) - }} - > - - {ref} - - {ref === value ? ( - - ) : null} - - ))} - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - inputShell: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs - }, - inputShellDisabled: { - opacity: 0.6 - }, - input: { - flex: 1, - minWidth: 0, - padding: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - }, - results: { - marginTop: spacing.xs, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - resultRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - paddingHorizontal: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - resultRowPressed: { backgroundColor: colors.bgRaised }, - resultText: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - } -}) diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx index 2dfd6bf26e4..cbeab92c208 100644 --- a/mobile/src/components/MobileSearchField.tsx +++ b/mobile/src/components/MobileSearchField.tsx @@ -149,8 +149,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - // Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious - // control, matching TextInputModal / MobilePrBasePicker input shells. + // Keep search visually distinct from surrounding panels. backgroundColor: colors.bgRaised, borderWidth: 1, borderColor: colors.borderSubtle, diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx deleted file mode 100644 index d832d38c88d..00000000000 --- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useCallback, useState } from 'react' -import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native' -import { - ArrowRight, - GitMerge, - GitPullRequestArrow, - Sparkles, - TriangleAlert, - X -} from 'lucide-react-native' -import { colors } from '../../theme/mobile-theme' -import type { RpcClient } from '../../transport/rpc-client' -import type { RpcSuccess } from '../../transport/types' -import { triggerError, triggerSuccess } from '../../platform/haptics' -import { - createMobilePr, - getMobilePrCreateSuccessWarning, - shouldPushBeforeMobilePrCreate, - type MobilePrPrefill -} from '../../source-control/mobile-pr-create' -import { hostedReviewCopy } from '../../source-control/hosted-review-copy' -import { - getPrComposeDisabledReason, - isBaseHeadDistinct -} from '../../source-control/pr-compose-validation' -import { MobilePrBasePicker } from '../MobilePrBasePicker' -import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles' - -export type PrComposePrefill = MobilePrPrefill - -type Props = { - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onCancel: () => void - onCreated: (url: string, warning?: string) => void -} - -// A plain View keeps the compose form usable inside the PR sidebar ScrollView. -export function MobilePrComposeForm({ - client, - worktreeId, - prefill, - head, - onCancel, - onCreated -}: Props) { - const copy = hostedReviewCopy(prefill.provider) - const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow - const [title, setTitle] = useState(prefill.title) - const [body, setBody] = useState(prefill.body) - const [base, setBase] = useState(prefill.base) - const [draft, setDraft] = useState(false) - const [generating, setGenerating] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill) - - const generate = useCallback(async () => { - if (!client || generating) { - return - } - setGenerating(true) - setError(null) - try { - const response = await client.sendRequest('git.generatePullRequestFields', { - worktree: `id:${worktreeId}`, - base, - title, - body, - draft - }) - if (!response.ok) { - setError(response.error?.message || 'Failed to generate PR fields') - return - } - const result = (response as RpcSuccess).result as { - success?: boolean - fields?: { base: string; title: string; body: string; draft: boolean } - error?: string - } - if (result.success && result.fields) { - setBase(result.fields.base || base) - setTitle(result.fields.title || title) - setBody(result.fields.body || body) - setDraft(result.fields.draft) - } else if (result.error) { - setError(result.error) - } - } catch (err) { - triggerError() - setError(err instanceof Error ? err.message : 'Failed to generate PR fields') - } finally { - setGenerating(false) - } - }, [base, body, client, draft, generating, title, worktreeId]) - - const headRef = head ?? '' - const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef) - const submitDisabledReason = getPrComposeDisabledReason({ - title, - base, - head: headRef, - generating, - reviewLabel: copy.reviewLabel - }) - const canSubmit = submitDisabledReason === null - const fieldsLocked = submitting || generating - - const submit = useCallback(async () => { - if (!client || submitting || !canSubmit) { - return - } - setSubmitting(true) - setError(null) - try { - const outcome = await createMobilePr(client, worktreeId, { - provider: prefill.provider, - base, - // Send the same head the submit guard validated against, so the PR opens - // from the validated branch instead of a host-inferred one. - ...(head ? { head } : {}), - title, - body, - draft, - pushBeforeCreate - }) - if (outcome.ok) { - triggerSuccess() - const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider) - if (warning) { - setError(warning) - } - onCreated(outcome.url, warning) - } else { - triggerError() - setError(outcome.error) - } - } finally { - setSubmitting(false) - } - }, [ - base, - body, - canSubmit, - client, - copy.titleLabel, - draft, - head, - onCreated, - prefill.provider, - pushBeforeCreate, - submitting, - title, - worktreeId - ]) - - return ( - - - - - New {copy.reviewLabel} - - - [styles.genButton, pressed && styles.genButtonPressed]} - disabled={generating || submitting} - onPress={() => void generate()} - accessibilityRole="button" - accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`} - > - {generating ? ( - - ) : ( - - )} - {generating ? 'Generating…' : 'Generate'} - - - - - - - - {head ? ( - - - {head} - - - - {base || 'base'} - - - ) : null} - - - - - - - {generating ? ( - - - Generating title and description… - - ) : null} - - - Base - - - - - - - Create as draft - - - {error || submitDisabledReason ? ( - - - {error ?? submitDisabledReason} - - ) : null} - [ - styles.submit, - (submitting || !canSubmit) && styles.submitDisabled, - pressed && styles.submitPressed - ]} - disabled={submitting || !canSubmit} - onPress={() => void submit()} - accessibilityRole="button" - > - {submitting ? ( - - ) : ( - - )} - - {pushBeforeCreate - ? draft - ? `Push & create draft ${copy.shortLabel}` - : `Push & create ${copy.shortLabel}` - : draft - ? `Create draft ${copy.shortLabel}` - : `Create ${copy.shortLabel}`} - - - - ) -} diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index ce1ef33cb96..23d768b7814 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac import { ConfirmModal } from '../ConfirmModal' import { CommentMarkdown } from './CommentMarkdown' import { PRCommentComposer } from './PRCommentComposer' -import { formatPrCommentRelativeTime } from './pr-comment-time' +import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index bb473307619..8fb5de31037 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown' import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard' import { PRCommentComposer } from './PRCommentComposer' import { - PR_COMMENT_AUDIENCE_FILTERS, filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, type PRCommentAudienceFilter -} from './pr-comment-audience' +} from '../../../../src/shared/pr-comment-audience' +import { + PR_COMMENT_AUDIENCE_FILTERS, + getPRCommentAudienceEmptyLabel +} from './pr-comment-audience-labels' import { getPRCommentGroupCount, getPRCommentGroupId, @@ -25,7 +27,7 @@ import { groupPRComments, isResolvedPRCommentGroup, type PRCommentGroup -} from './pr-comment-groups' +} from '../../../../src/shared/pr-comment-groups' import { prCommentsStyles as styles } from './pr-comments-styles' import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles' diff --git a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts deleted file mode 100644 index 587082d972f..00000000000 --- a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { StyleSheet } from 'react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' - -export const mobilePrComposeFormStyles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - headingRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - marginBottom: spacing.xs - }, - headingTitle: { - flex: 1, - minWidth: 0, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - heading: { - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '700' - }, - headingActions: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - genButton: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - genButtonPressed: { - opacity: 0.7 - }, - genButtonText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - iconButton: { - minWidth: 32, - minHeight: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - branchFlow: { - minHeight: 28, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - branchToken: { - maxWidth: 116, - color: colors.textPrimary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - branchTokenError: { - color: colors.statusRed - }, - fieldStack: { - gap: spacing.sm - }, - titleInput: { - minHeight: 40, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - bodyInput: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - // Why: a moderate fixed height avoids over-expanding inside the sidebar scroll. - minHeight: 120, - textAlignVertical: 'top' - }, - baseRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - baseLabel: { - color: colors.textSecondary, - fontSize: typography.metaSize, - width: 36 - }, - baseControl: { - flex: 1, - minWidth: 0 - }, - draftRow: { - minHeight: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingHorizontal: spacing.sm - }, - draftText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - notice: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.xs - }, - noticeText: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18 - }, - errorText: { - color: colors.statusRed - }, - submit: { - marginTop: spacing.xs, - minHeight: 44, - borderRadius: radii.button, - backgroundColor: colors.textPrimary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs - }, - submitDisabled: { - opacity: 0.45 - }, - submitPressed: { - opacity: 0.8 - }, - submitText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '700' - } -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..43f0d54289f --- /dev/null +++ b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts @@ -0,0 +1,18 @@ +import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience' + +export const PR_COMMENT_AUDIENCE_FILTERS = [ + { value: 'all', label: 'All' }, + { value: 'human', label: 'Humans' }, + { value: 'bot', label: 'Bots' } +] satisfies { value: PRCommentAudienceFilter; label: string }[] + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return 'No bot comments.' + case 'human': + return 'No human comments.' + case 'all': + return 'No comments yet.' + } +} diff --git a/mobile/src/components/pr-sidebar/pr-comment-groups.ts b/mobile/src/components/pr-sidebar/pr-comment-groups.ts deleted file mode 100644 index 78bff853478..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../../src/shared/types' - -// Thread grouping for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants. -// Inline review comments sharing a threadId collapse into a root + replies; all -// other comments are standalone. Upstream order is preserved. -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts deleted file mode 100644 index e01fb4166dc..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - isBotPRComment -} from './pr-comment-audience' -import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import { formatPrCommentRelativeTime } from './pr-comment-time' - -function comment(overrides: Partial & { id: number }): PRComment { - return { - author: 'octocat', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - ...overrides - } -} - -describe('pr comment audience', () => { - it('classifies app bots, [bot] suffix, and known automation logins', () => { - expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false) - expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true) - expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true) - expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true) - }) - - it('counts and filters by audience', () => { - const comments = [ - comment({ id: 1, author: 'alice' }), - comment({ id: 2, author: 'dependabot[bot]' }), - comment({ id: 3, author: 'bob' }) - ] - expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2]) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3]) - expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3) - }) - - it('classifies desktop-marked bot authors via overrides (desktop parity)', () => { - const overrides = createBotAuthorOverrideSet([' GretelFlux ']) - const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })] - expect(isBotPRComment(comments[1])).toBe(false) - expect(isBotPRComment(comments[1], overrides)).toBe(true) - expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2]) - }) -}) - -describe('pr comment groups', () => { - it('threads comments sharing a threadId as root + replies, preserving order', () => { - const comments = [ - comment({ id: 1, author: 'a' }), - comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }), - comment({ id: 3, author: 'c', threadId: 't1' }) - ] - const groups = groupPRComments(comments) - expect(groups).toHaveLength(2) - expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) - expect(groups[1].kind).toBe('thread') - if (groups[1].kind === 'thread') { - expect(groups[1].root.id).toBe(2) - expect(groups[1].replies.map((r) => r.id)).toEqual([3]) - } - expect(isResolvedPRCommentGroup(groups[1])).toBe(true) - }) -}) - -describe('pr comment relative time', () => { - const now = Date.parse('2026-06-16T12:00:00Z') - it('formats buckets and rejects bad input', () => { - expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now') - expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago') - expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago') - expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago') - expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('') - }) -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-time.ts b/mobile/src/components/pr-sidebar/pr-comment-time.ts deleted file mode 100644 index 47d78ea8a2f..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-time.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out), -// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure + -// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts). -export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { - const ts = Date.parse(iso) - if (Number.isNaN(ts)) { - return '' - } - const delta = nowMs - ts - if (delta < 60_000) { - return 'just now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h ago` - } - const days = Math.floor(hours / 24) - if (days < 30) { - return `${days}d ago` - } - const months = Math.floor(days / 30) - if (months < 12) { - return `${months}mo ago` - } - return `${Math.floor(months / 12)}y ago` -} diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index eaf50ba08ab..1d1b0288467 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => { }) it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => { + const onChangeText = vi.fn() await act(async () => { renderer = create( createElement(MobileNativeChatComposer, { value: '/c', - onChangeText: vi.fn(), + onChangeText, onSend: vi.fn().mockResolvedValue(true), agent: 'claude' }) @@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => { (node) => node.type === 'Pressable' && !node.props.accessibilityLabel )[0] as { props: { onPress: () => void } } await act(async () => firstSuggestion.props.onPress()) + expect(onChangeText).toHaveBeenCalledWith('/clear ') // `/clear ` is 7 chars — the caret jumps just past the inserted command + space. expect(input().props.selection).toEqual({ start: 7, end: 7 }) // The next native selection event releases control so manual placement still works. diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..cc70e2e3b0f 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,65 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) + + it('retires a parked launch draft only with the final typed Enter', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + await vi.runAllTimersAsync() + await result + + const params = vi.mocked(client.sendRequest).mock.calls.map((call) => call[1]) as Array<{ + text: string + resolvedLaunchDraft?: { text: string; createdAt: number } + }> + expect(params.slice(0, -1).every((entry) => entry.resolvedLaunchDraft === undefined)).toBe(true) + expect(params.at(-1)).toMatchObject({ + text: '\r', + resolvedLaunchDraft: { text: 'seed', createdAt: 7 } + }) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..28f91759168 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,35 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + resolvedLaunchDraft?: { text: string; createdAt: number } + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + let writeIndex = 0 + return typeAgentTuiCommand({ + command: args.command, + write: (key) => { + const isSubmit = writeIndex === args.command.length + 1 + writeIndex += 1 + return sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(isSubmit && args.resolvedLaunchDraft + ? { resolvedLaunchDraft: args.resolvedLaunchDraft } + : {}), + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + } + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..dd67cf46da8 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -215,24 +219,46 @@ describe('useMobileNativeChatMessageSend', () => { expect(onCommandSend).toHaveBeenCalledWith('/clear') }) - it('never creates an optimistic echo for an unknown slash token', async () => { - // `/model` is not in Claude's autocomplete catalog, but the session-option - // recorder still recognizes it without claiming a generic command ran. - mount(() => null) - await act(async () => { - await api!.send('/model sonnet') - }) - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') - }) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s composer slash sends pasted', + async (agent) => { + // `/model` is not in Claude's autocomplete catalog, but the session-option + // recorder still recognizes it without claiming a generic command ran. + mount(() => null, agent) + await act(async () => { + await api!.send('/model sonnet') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('/model sonnet') + expect(sendWithOutcome).toHaveBeenCalledOnce() + expect(typeCommandWithOutcome).not.toHaveBeenCalled() + } + ) - it('classifies per agent: /model is a catalog command for Codex', async () => { + it('types Codex composer slash sends instead of pasting them', async () => { mount(() => null, 'codex') await act(async () => { await api!.send('/model') }) expect(acceptSend).not.toHaveBeenCalled() expect(onCommandSend).toHaveBeenCalledWith('/model') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('keeps Codex skill sends pasted', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.send('$ref-oss') + }) + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).toHaveBeenCalledWith('$ref-oss') + expect(sendWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ text: '$ref-oss', terminal: 'term' }) + ) + expect(typeCommandWithOutcome).not.toHaveBeenCalled() }) it('holds only chat sends for transcript confirmation on a lost ack', async () => { @@ -248,16 +274,44 @@ describe('useMobileNativeChatMessageSend', () => { expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1) }) - it('dispatchCommand surfaces the outcome without echo or composer sync', async () => { - mount(() => ({ text: DRAFT, createdAt: 1 })) + it.each(['claude', 'openclaude'] as const)( + 'keeps %s session-option commands pasted', + async (agent) => { + mount(() => ({ text: DRAFT, createdAt: 1 }), agent) + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model sonnet', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(acceptSend).not.toHaveBeenCalled() + expect(onCommandSend).not.toHaveBeenCalled() + expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + } + ) + + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') let outcome: string | undefined await act(async () => { - outcome = await api!.dispatchCommand('/model sonnet') + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) }) expect(outcome).toBe('accepted') - expect(acceptSend).not.toHaveBeenCalled() - expect(onCommandSend).not.toHaveBeenCalled() - expect(sentArgs().resolvedLaunchDraft).toBeUndefined() + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + + it('types Codex session-option commands without caller delivery metadata', async () => { + mount(() => null, 'codex') + await act(async () => { + await api!.dispatchCommand('/model') + }) + + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() }) it('binds classification to the agent that started the send', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 338c800cecd..17f34b86d27 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,11 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' +import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +35,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -150,40 +156,42 @@ export function useMobileNativeChatMessageSend(args: { return 'rejected' } } - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text, - // Why: pre-clear only when nothing was deliberately pasted first. The heal - // above fires only for terminals a mobile image paste marked, so a desktop - // launch-draft prefill parked on the input line would otherwise glue onto - // this message. An image send already led its own paste with Ctrl+U, and a - // second one here would wipe the image it just pasted (desktop's image path - // likewise clears once, before the paste, and never again). - // - // Also skipped once the dedicated clear above ran: the line is already - // empty, and a Ctrl+U written immediately before body text in the SAME - // write reaches the agent as a literal control character rather than a - // keypress (observed live as a stray \x15 heading the received message). - clearInputFirst: !images?.length && !seededLaunchDraft, - ...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number' - ? { - resolvedLaunchDraft: { - text: seededLaunchDraft.text, - createdAt: seededLaunchDraft.createdAt - } - } - : {}), - deadline, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) + const classification = classifyMobileNativeChatSend(agent, text) + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + const resolvedLaunchDraft = + syncComposer && typeof seededLaunchDraft?.createdAt === 'number' + ? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt } + : undefined + const outcome = + agent === 'codex' && + classification !== 'chat' && + isSlashCommandDraft(text) && + !images?.length + ? await typeMobileNativeChatCommandWithOutcome({ + client, + terminal: handle, + command: text, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + : await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text, + // Why: an image send already cleared before its paste; a second + // clear here would wipe the image before submission. + clearInputFirst: !images?.length && !seededLaunchDraft, + ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), + deadline, + ...(mobileClient ? { mobileClient } : {}) + }) // Why (desktop parity): a slash/skill send dispatches into the agent's own // TUI, not the conversation — the transcript never echoes it as a user // turn, so an optimistic bubble would never reconcile and the // unconfirmed hold could never observe a landing. - const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { if (classification === 'chat') { // Why: an ack-lost send usually WAS delivered (issue seen on cellular @@ -269,12 +277,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + _options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (agentRef.current === 'codex') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +319,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 4ccd3f40843..1e3ef2cafd5 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -98,7 +99,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -300,7 +304,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/mobile/src/source-control/mobile-base-ref-search.test.ts b/mobile/src/source-control/mobile-base-ref-search.test.ts deleted file mode 100644 index b59b4fe8da2..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapBaseRefResults } from './mobile-base-ref-search' - -describe('mapBaseRefResults', () => { - it('extracts a clean string list from a well-formed payload', () => { - expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev']) - }) - - it('returns [] for malformed payloads and drops bad entries', () => { - expect(mapBaseRefResults(null)).toEqual([]) - expect(mapBaseRefResults({})).toEqual([]) - expect(mapBaseRefResults({ refs: 'x' })).toEqual([]) - expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two']) - }) -}) diff --git a/mobile/src/source-control/mobile-base-ref-search.ts b/mobile/src/source-control/mobile-base-ref-search.ts deleted file mode 100644 index 84a962a41e5..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create' - -export function mapBaseRefResults(raw: unknown): string[] { - if (raw === null || typeof raw !== 'object') { - return [] - } - const refs = (raw as { refs?: unknown }).refs - if (!Array.isArray(refs)) { - return [] - } - return refs.filter((r): r is string => typeof r === 'string' && r.length > 0) -} - -export async function searchBaseRefs( - client: Pick, - worktreeId: string, - query: string, - limit = 20 -): Promise { - const response = await client.sendRequest('repo.searchRefs', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId), - query, - limit - }) - if (!response.ok) { - return [] - } - return mapBaseRefResults((response as RpcSuccess).result) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts deleted file mode 100644 index eb0927bcff6..00000000000 --- a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { MobileGitUpstreamStatus } from './mobile-git-status' - -export type MobileSourceControlPrimaryActionKind = - | 'commit' - | 'stage' - | 'push' - | 'pull' - | 'sync' - | 'publish' - -export type MobileSourceControlRemoteOpKind = - | 'push' - | 'force_push' - | 'pull' - | 'sync' - | 'fetch' - | 'fast_forward' - | 'publish' - | 'rebase' - -export type MobileSourceControlPrimaryActionTitleIntent = - | 'commit_in_progress' - | 'force_push_in_progress' - | 'action_in_progress' - | 'remote_operation_in_progress' - | 'remote_operation_blocks_commit' - | 'resolve_conflicts_before_commit' - | 'commit_staged_changes' - | 'enter_commit_message' - | 'stage_all_changes' - | 'stage_file_to_commit' - | 'checkout_branch_before_publish' - | 'publish_branch' - | 'force_push_with_lease' - | 'sync_counts' - | 'pull_count' - | 'push_count' - | 'nothing_to_commit_up_to_date' - -export type MobileSourceControlPrimaryActionDecision = { - kind: MobileSourceControlPrimaryActionKind - disabled: boolean - labelIntent: MobileSourceControlPrimaryActionKind | 'force_push' - titleIntent: MobileSourceControlPrimaryActionTitleIntent - count?: number - ahead?: number - behind?: number - upstreamName?: string - requiresForceWithLease?: boolean -} - -export type MobileSourceControlPrimaryActionDecisionInputs = { - stagedCount: number - hasUnstagedChanges: boolean - hasStageableChanges: boolean - hasPartiallyStagedChanges: boolean - hasMessage: boolean - hasUnresolvedConflicts: boolean - isCommitting: boolean - isRemoteOperationActive: boolean - upstreamStatus: MobileGitUpstreamStatus | undefined - inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null - branchCommitsAhead?: number - hasCurrentBranch?: boolean -} - -// Why: Metro cannot load runtime modules from the desktop/root `src/shared` -// tree. Keep this mobile mirror narrow and parity-tested against the shared -// commit-area decision core so the semantic ladder cannot drift silently. -export function resolveMobileSourceControlCommitAreaPrimaryActionDecision( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { - stagedCount, - hasUnstagedChanges, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - isCommitting, - isRemoteOperationActive, - upstreamStatus, - branchCommitsAhead, - hasCurrentBranch = true - } = inputs - - if (isCommitting) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_in_progress', - disabled: true - } - } - - if (isRemoteOperationActive) { - return resolveMobilePrimaryActionDuringRemoteOp(inputs) - } - - if (hasUnresolvedConflicts) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'resolve_conflicts_before_commit', - disabled: true - } - } - - const hasStaged = stagedCount > 0 - - if (hasStaged && hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_staged_changes', - disabled: false - } - } - - if (hasStaged && !hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'enter_commit_message', - disabled: true - } - } - - if (!hasStaged && hasStageableChanges) { - return { - kind: 'stage', - labelIntent: 'stage', - titleIntent: 'stage_all_changes', - disabled: false - } - } - - if (!upstreamStatus) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'stage_file_to_commit', - disabled: true - } - } - - if (!upstreamStatus.hasUpstream) { - if (!hasCurrentBranch) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'checkout_branch_before_publish', - disabled: true - } - } - return { - kind: 'publish', - labelIntent: 'publish', - titleIntent: 'publish_branch', - disabled: false - } - } - - if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { - if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_with_lease', - disabled: false, - count: branchCommitsAhead, - upstreamName: upstreamStatus.upstreamName, - requiresForceWithLease: true - } - } - return { - kind: 'sync', - labelIntent: 'sync', - titleIntent: 'sync_counts', - disabled: false, - ahead: upstreamStatus.ahead, - behind: upstreamStatus.behind - } - } - - if (upstreamStatus.behind > 0) { - return { - kind: 'pull', - labelIntent: 'pull', - titleIntent: 'pull_count', - disabled: false, - count: upstreamStatus.behind - } - } - - if (upstreamStatus.ahead > 0) { - return { - kind: 'push', - labelIntent: 'push', - titleIntent: 'push_count', - disabled: false, - count: upstreamStatus.ahead - } - } - - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', - disabled: true - } -} - -function resolveMobilePrimaryActionDuringRemoteOp( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs - const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ - ...inputs, - isRemoteOperationActive: false - }) - const inFlightIsPrimaryKind = - inFlightRemoteOpKind === 'push' || - inFlightRemoteOpKind === 'pull' || - inFlightRemoteOpKind === 'sync' || - inFlightRemoteOpKind === 'publish' - - if (inFlightRemoteOpKind === 'force_push') { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_in_progress', - disabled: true, - requiresForceWithLease: true - } - } - - if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { - return { - kind: inFlightRemoteOpKind, - labelIntent: inFlightRemoteOpKind, - titleIntent: 'action_in_progress', - disabled: true - } - } - - const titleIntent = hasUnresolvedConflicts - ? 'resolve_conflicts_before_commit' - : candidate.kind === 'commit' - ? 'remote_operation_blocks_commit' - : 'remote_operation_in_progress' - - return { - ...candidate, - titleIntent, - disabled: true - } -} - -function shouldForcePushWithLeaseForMobileUpstream( - status: MobileGitUpstreamStatus | undefined -): boolean { - return ( - status?.hasUpstream === true && - status.ahead > 0 && - status.behind > 0 && - status.behindCommitsArePatchEquivalent === true - ) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts index 0b127d9bde3..8314ecb2d80 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.test.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision' -import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecisionInputs -} from './mobile-source-control-primary-action-decision' import { buildMobileSourceControlPrimaryAction, type MobileSourceControlPrimaryActionArgs, @@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => { expect(action.disabled).toBe(false) }) }) - -function decisionInputs( - overrides: Partial = {} -): MobileSourceControlPrimaryActionDecisionInputs { - return { - stagedCount: 0, - hasUnstagedChanges: false, - hasStageableChanges: false, - hasPartiallyStagedChanges: false, - hasMessage: false, - hasUnresolvedConflicts: false, - isCommitting: false, - isRemoteOperationActive: false, - upstreamStatus: undefined, - ...overrides - } -} - -describe('mobile source-control primary action decision parity', () => { - it.each([ - { - name: 'dirty tree stages first', - input: decisionInputs({ - hasUnstagedChanges: true, - hasStageableChanges: true, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } - }) - }, - { - name: 'staged message commits', - input: decisionInputs({ stagedCount: 1, hasMessage: true }) - }, - { - name: 'staged without message blocks commit', - input: decisionInputs({ stagedCount: 1, hasMessage: false }) - }, - { - name: 'unresolved conflicts block commit', - input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) - }, - { - name: 'unpublished branch publishes', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: true - }) - }, - { - name: 'detached head blocks publish', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: false - }) - }, - { - name: 'tracked ahead pushes', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) - }, - { - name: 'tracked behind pulls', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) - }, - { - name: 'tracked diverged syncs', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) - }, - { - name: 'patch-equivalent diverged force-pushes with lease', - input: decisionInputs({ - branchCommitsAhead: 4, - upstreamStatus: { - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 10, - behind: 2, - behindCommitsArePatchEquivalent: true - } - }) - }, - { - name: 'in-flight pull mirrors pull', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'pull', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight force push mirrors force push', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'force_push', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight push blocks committable candidate', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push', - stagedCount: 1, - hasMessage: true - }) - } - ])('matches the shared commit-area decision for $name', ({ input }) => { - expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual( - resolveSourceControlCommitAreaPrimaryActionDecision(input) - ) - }) -}) diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts index f62a7770d16..2a993967b52 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.ts @@ -1,14 +1,17 @@ import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecision, - type MobileSourceControlPrimaryActionKind, - type MobileSourceControlRemoteOpKind -} from './mobile-source-control-primary-action-decision' + resolveSourceControlCommitAreaPrimaryActionDecision, + type SourceControlCommitAreaPrimaryActionDecision, + type SourceControlRemoteOpKind +} from '../../../src/shared/source-control-primary-action-decision' import type { MobileGitBranchCompareResult } from './mobile-branch-compare' import type { MobileGitStatusResult } from './mobile-git-status' type GitStep = { method: string; params?: Record } +type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind'] +type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision +type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind + export type MobileSourceControlPrimaryAction = { kind: MobileSourceControlPrimaryActionKind label: string @@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = { export function buildMobileSourceControlPrimaryAction( args: MobileSourceControlPrimaryActionArgs ): MobileSourceControlPrimaryAction { - const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + const decision = resolveSourceControlCommitAreaPrimaryActionDecision({ stagedCount: args.stagedCount, hasUnstagedChanges: args.unstagedCount > 0, hasStageableChanges: args.stageablePaths.length > 0, @@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe return 'Stage at least one file to commit.' case 'checkout_branch_before_publish': return 'Check out a branch before publishing commits.' + case 'checking_review_status': + return 'Checking review status.' + case 'review_already_merged': + return 'Nothing to commit. The review is already merged.' case 'publish_branch': return 'Publish this branch to origin.' + case 'push_linked_review': + return 'Push updates to the linked review branch.' + case 'linked_review_target_unavailable': + return 'The linked review branch is unavailable.' case 'force_push_with_lease': return 'Force push with lease to update the remote branch.' case 'sync_counts': diff --git a/mobile/src/source-control/pr-compose-validation.test.ts b/mobile/src/source-control/pr-compose-validation.test.ts deleted file mode 100644 index d0f76f55e4b..00000000000 --- a/mobile/src/source-control/pr-compose-validation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canSubmitPrCompose, - getPrComposeDisabledReason, - isBaseHeadDistinct -} from './pr-compose-validation' - -describe('isBaseHeadDistinct', () => { - it('is true when base and head differ', () => { - expect(isBaseHeadDistinct('main', 'feature')).toBe(true) - }) - - it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => { - expect(isBaseHeadDistinct('main', 'main')).toBe(false) - expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false) - expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false) - expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false) - }) - - it('is false for an empty base', () => { - expect(isBaseHeadDistinct('', 'feature')).toBe(false) - }) -}) - -describe('canSubmitPrCompose', () => { - it('requires a non-empty title and a distinct base', () => { - expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true) - expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false) - expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false) - }) -}) - -describe('getPrComposeDisabledReason', () => { - it('returns null when the form can submit', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBeNull() - }) - - it('names the active blocker', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: true, - reviewLabel: 'pull request' - }) - ).toBe('Wait for generation to finish.') - expect( - getPrComposeDisabledReason({ - title: '', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'merge request' - }) - ).toBe('Enter a merge request title.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: '', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Choose a base branch.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'main', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Base branch must differ from the head branch.') - }) -}) diff --git a/mobile/src/source-control/pr-compose-validation.ts b/mobile/src/source-control/pr-compose-validation.ts deleted file mode 100644 index c04f5c3f5b2..00000000000 --- a/mobile/src/source-control/pr-compose-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not -// referenced across the package boundary — Metro's resolver is rooted at mobile/ and -// can't bundle a runtime import from repo-root/src, unlike erased `import type`s). -// Keep in sync with the shared versions so mobile and desktop compare refs identically. -function normalizeHeadRef(ref: string): string { - return ref - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^refs\/remotes\/[^/]+\//, '') -} - -function normalizeBaseRef(ref: string): string { - return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '') -} - -// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog: -// the base ref must be non-empty and must differ from the head branch after ref -// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive. -export function isBaseHeadDistinct(base: string, head: string): boolean { - const b = normalizeBaseRef(base).toLowerCase() - const h = normalizeHeadRef(head).toLowerCase() - return b.length > 0 && b !== h -} - -export function canSubmitPrCompose(title: string, base: string, head: string): boolean { - return title.trim().length > 0 && isBaseHeadDistinct(base, head) -} - -export function getPrComposeDisabledReason({ - title, - base, - head, - generating, - reviewLabel -}: { - title: string - base: string - head: string - generating: boolean - reviewLabel: string -}): string | null { - if (generating) { - return 'Wait for generation to finish.' - } - if (title.trim().length === 0) { - return `Enter a ${reviewLabel} title.` - } - if (base.trim().length === 0) { - return 'Choose a base branch.' - } - if (!isBaseHeadDistinct(base, head)) { - return 'Base branch must differ from the head branch.' - } - return null -} diff --git a/mobile/src/tasks/mobile-github-project-group-sort.ts b/mobile/src/tasks/mobile-github-project-group-sort.ts deleted file mode 100644 index 7b86d3c38c0..00000000000 --- a/mobile/src/tasks/mobile-github-project-group-sort.ts +++ /dev/null @@ -1,261 +0,0 @@ -export type GitHubProjectSortDirection = 'ASC' | 'DESC' - -export type GitHubProjectFieldValue = - | { kind: 'text'; text: string } - | { kind: 'number'; number: number } - | { kind: 'date'; date: string } - | { kind: 'single-select'; optionId: string; name: string } - | { kind: 'iteration'; iterationId: string; title: string } - | { kind: 'labels'; labels: Array<{ name: string }> } - | { kind: 'users'; users: Array<{ login: string }> } - -export type GitHubProjectField = { - id: string - name: string - kind: string - options?: Array<{ id: string }> - iterations?: Array<{ - id: string - title?: string - startDate: string - duration: number - completed: boolean - }> -} - -export type GitHubProjectRow = { - position?: number | null - fieldValuesByFieldId: Record -} - -export type GitHubProjectSort = { - direction: GitHubProjectSortDirection - field: GitHubProjectField -} - -export type GitHubProjectTable = { - selectedView: { - groupByFields: GitHubProjectField[] - sortByFields: GitHubProjectSort[] - } - rows: GitHubProjectRow[] -} - -export type ProjectGroup = { - key: string - label: string - iteration: { - startDate: string - duration: number - completed: boolean - } | null - rows: GitHubProjectRow[] -} - -const EMPTY_GROUP_KEY = '__empty__' -const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER - -function labelForEmpty(field: GitHubProjectField): string { - return `No ${field.name}` -} - -function deriveStringValue(value: GitHubProjectFieldValue): string { - switch (value.kind) { - case 'text': - return value.text - case 'number': - return String(value.number) - case 'date': - return value.date - case 'single-select': - return value.name - case 'iteration': - return value.title - case 'labels': - return value.labels.map((label) => label.name).join(', ') - case 'users': - return value.users.map((user) => user.login).join(', ') - } -} - -function getFieldValueForGrouping( - row: GitHubProjectRow, - field: GitHubProjectField -): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { - const value = row.fieldValuesByFieldId[field.id] - if (!value) { - return { - key: EMPTY_GROUP_KEY, - label: labelForEmpty(field), - orderHint: UNKNOWN_INDEX_SENTINEL, - iteration: null - } - } - if (field.kind === 'iteration' && value.kind === 'iteration') { - const iterations = field.iterations ?? [] - const idx = iterations.findIndex((it) => it.id === value.iterationId) - const meta = iterations.find((it) => it.id === value.iterationId) - return { - key: value.iterationId, - label: value.title || meta?.title || 'Iteration', - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: meta - ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } - : null - } - } - if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) - return { - key: value.optionId, - label: value.name, - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: null - } - } - const label = deriveStringValue(value) - return { key: `raw:${label}`, label, orderHint: 0, iteration: null } -} - -export function groupRows( - table: GitHubProjectTable, - rowsInOrder: GitHubProjectRow[] -): ProjectGroup[] { - const groupField = table.selectedView.groupByFields[0] - if (!groupField) { - return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] - } - const buckets = new Map< - string, - { - label: string - orderHint: number - iteration: ProjectGroup['iteration'] - rows: GitHubProjectRow[] - } - >() - for (const row of rowsInOrder) { - const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) - let bucket = buckets.get(key) - if (!bucket) { - bucket = { label, orderHint, iteration, rows: [] } - buckets.set(key, bucket) - } - bucket.rows.push(row) - } - const entries = Array.from(buckets.entries()) - entries.sort((a, b) => { - if (a[0] === EMPTY_GROUP_KEY) { - return 1 - } - if (b[0] === EMPTY_GROUP_KEY) { - return -1 - } - if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { - return a[1].orderHint - b[1].orderHint - } - return a[1].label.localeCompare(b[1].label) - }) - return entries.map(([key, value]) => ({ - key, - label: value.label, - iteration: value.iteration, - rows: value.rows - })) -} - -function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { - const field = sort.field - const aValue = a.fieldValuesByFieldId[field.id] - const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 - } - - let cmp = 0 - if ( - field.kind === 'single-select' && - aValue.kind === 'single-select' && - bValue.kind === 'single-select' - ) { - const options = field.options ?? [] - const aIdx = options.findIndex((option) => option.id === aValue.optionId) - const bIdx = options.findIndex((option) => option.id === bValue.optionId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if ( - field.kind === 'iteration' && - aValue.kind === 'iteration' && - bValue.kind === 'iteration' - ) { - const iterations = field.iterations ?? [] - const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) - const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if (aValue.kind === 'number' && bValue.kind === 'number') { - cmp = aValue.number - bValue.number - } else if (aValue.kind === 'date' && bValue.kind === 'date') { - cmp = aValue.date.localeCompare(bValue.date) - } else if (aValue.kind === 'text' && bValue.kind === 'text') { - cmp = aValue.text.localeCompare(bValue.text) - } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } - } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } - } else { - return 0 - } - return sort.direction === 'DESC' ? -cmp : cmp -} - -export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] { - const sorts = table.selectedView.sortByFields - const out = [...rows] - out.sort((a, b) => { - for (const sort of sorts) { - const cmp = compareSort(a, b, sort) - if (cmp !== 0) { - return cmp - } - } - return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) - }) - return out -} - -export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { - const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() - if (Number.isNaN(start)) { - return false - } - const end = start + iteration.duration * 86_400_000 - const now = Date.now() - return now >= start && now < end -} diff --git a/mobile/src/terminal/terminal-settings-screen-styles.ts b/mobile/src/terminal/terminal-settings-screen-styles.ts new file mode 100644 index 00000000000..a3ff5c8dd6d --- /dev/null +++ b/mobile/src/terminal/terminal-settings-screen-styles.ts @@ -0,0 +1,91 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const terminalSettingsScreenStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg, + paddingTop: 0 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + inputGroupGap: { + marginTop: spacing.xl + }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/transport/pairing-connection-attempt.test.ts b/mobile/src/transport/pairing-connection-attempt.test.ts deleted file mode 100644 index b05867eb7c0..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { startPairingConnectionAttempt } from './pairing-connection-attempt' - -describe('pairing connection attempt cleanup', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('closes the temporary client when the overall pairing timeout fires', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - - expect(attempt.timedOut).toBe(false) - vi.advanceTimersByTime(24_999) - expect(closeClient).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - expect(attempt.timedOut).toBe(true) - expect(closeClient).toHaveBeenCalledTimes(1) - - attempt.dispose() - expect(closeClient).toHaveBeenCalledTimes(1) - }) - - it('clears the timeout and closes the temporary client when disposed early', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - attempt.dispose() - - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(25_000) - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/transport/pairing-connection-attempt.ts b/mobile/src/transport/pairing-connection-attempt.ts deleted file mode 100644 index 0a2d564949a..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type PairingConnectionAttempt = { - readonly timedOut: boolean - dispose: () => void -} - -export function startPairingConnectionAttempt({ - timeoutMs, - closeClient -}: { - timeoutMs: number - closeClient: () => void -}): PairingConnectionAttempt { - let disposed = false - let clientClosed = false - let timedOut = false - let timer: ReturnType | null = null - - function closeClientOnce() { - if (clientClosed) { - return - } - clientClosed = true - closeClient() - } - - function dispose() { - if (disposed) { - return - } - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - closeClientOnce() - } - - timer = setTimeout(() => { - timer = null - timedOut = true - dispose() - }, timeoutMs) - - return { - get timedOut() { - return timedOut - }, - dispose - } -} diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index f87fd8a06dd..60004f94df0 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ removeClaude: vi.fn(), removeCodex: vi.fn(), statusClaude: vi.fn(), - statusCodex: vi.fn() + statusCodex: vi.fn(), + refreshClaude: vi.fn(), + refreshCodex: vi.fn() })) vi.mock('./local-agent-cli-presence', () => ({ @@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({ MANAGED_AGENT_HOOK_STATUS_READERS: [ ['claude', mocks.statusClaude], ['codex', mocks.statusCodex] + ], + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [ + ['claude', mocks.refreshClaude], + ['codex', mocks.refreshCodex] ] })) @@ -51,6 +57,8 @@ describe('managed agent hook controls', () => { mocks.installCodex.mockReturnValue(status('codex', 'installed')) mocks.removeClaude.mockReturnValue(status('claude', 'not_installed')) mocks.removeCodex.mockReturnValue(status('codex', 'not_installed')) + mocks.refreshClaude.mockResolvedValue(undefined) + mocks.refreshCodex.mockResolvedValue(undefined) }) it('installs only agents with positively detected CLIs', async () => { @@ -73,6 +81,78 @@ describe('managed agent hook controls', () => { ]) }) + it('refreshes existing scripts for agents whose CLI is no longer detected', async () => { + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'found' } + }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + // Why (#11549 aftermath): the skipped agent's user-wide config still invokes the + // script, so a stale (leaking) script must not be frozen by the presence gate. + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + expect(mocks.installClaude).not.toHaveBeenCalled() + }) + + it('refreshes existing scripts even when CLI detection rejects', async () => { + mocks.detect.mockRejectedValue(new Error('detection unavailable')) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + + it('awaits each script refresh before probing for CLIs', async () => { + let releaseRefresh: (() => void) | undefined + mocks.refreshClaude.mockImplementation( + () => + new Promise((resolve) => { + releaseRefresh = resolve + }) + ) + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'missing' } + }) + + const install = installManagedAgentHooks({ agentCmdOverrides: {} }) + await Promise.resolve() + + expect(mocks.detect).not.toHaveBeenCalled() + releaseRefresh?.() + await install + expect(mocks.detect).toHaveBeenCalledTimes(1) + }) + + it('keeps installing when a script refresh throws', async () => { + mocks.refreshClaude.mockRejectedValue(new Error('disk full')) + mocks.detect.mockResolvedValue({ + claude: { state: 'found' }, + codex: { state: 'found' } + }) + + const results = await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.installClaude).toHaveBeenCalledTimes(1) + expect(mocks.installCodex).toHaveBeenCalledTimes(1) + expect(results).toEqual([ + expect.objectContaining({ agent: 'claude', state: 'installed' }), + expect.objectContaining({ agent: 'codex', state: 'installed' }) + ]) + }) + + it('only refreshes scripts for the selected agents', async () => { + mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] }) + + expect(mocks.refreshClaude).not.toHaveBeenCalled() + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + it('fails closed when CLI detection rejects', async () => { mocks.detect.mockRejectedValue(new Error('detection unavailable')) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 0e767ee72b0..0de10c6417c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_INSTALLERS, MANAGED_AGENT_HOOK_REMOVERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, MANAGED_AGENT_HOOK_STATUS_READERS, type ManagedAgentHookInstaller } from './managed-agent-hook-registry' @@ -87,10 +88,29 @@ function runInstaller( } } +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script, but the presence gate below then skips install() forever, freezing the +// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them +// current before any gating; creating new ones remains install()'s presence-gated job. +async function refreshExistingManagedScripts(options: InstallOptions): Promise { + const allowed = options.agents ? new Set(options.agents) : null + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + if (allowed !== null && !allowed.has(agent)) { + continue + } + try { + await refresh() + } catch (error) { + console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error) + } + } +} + export async function installManagedAgentHooks( settings: ManagedHookSettings = null, options: InstallOptions = {} ): Promise { + await refreshExistingManagedScripts(options) const installers = selectedInstallers(options) const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents)) const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent)) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 1c17da381ce..4772a763b6c 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['kimi', () => kimiHookService.install()] ] +// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a +// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write +// provider-native plugin code into their own config dirs with their own install lifecycles, +// not shared launchers, so they are deliberately absent. Enforced by the coverage test in +// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding +// a refresher here fails that test. +export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [ + ['claude', () => claudeHookService.refreshManagedScripts()], + ['openclaude', () => openClaudeHookService.refreshManagedScripts()], + ['codex', () => codexHookService.refreshManagedScripts()], + ['gemini', () => geminiHookService.refreshManagedScripts()], + ['antigravity', () => antigravityHookService.refreshManagedScripts()], + ['cursor', () => cursorHookService.refreshManagedScripts()], + ['droid', () => droidHookService.refreshManagedScripts()], + ['command-code', () => commandCodeHookService.refreshManagedScripts()], + ['grok', () => grokHookService.refreshManagedScripts()], + ['copilot', () => copilotHookService.refreshManagedScripts()], + ['devin', () => devinHookService.refreshManagedScripts()], + ['kimi', () => kimiHookService.refreshManagedScripts()] +] + export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['claude', () => claudeHookService.remove()], ['openclaude', () => openClaudeHookService.remove()], diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts new file mode 100644 index 00000000000..e80011d803f --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -0,0 +1,101 @@ +import type * as NodeFsModule from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import { tmpdir } from 'node:os' +import type * as NodeOsModule from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + home: '', + syncCalls: [] as { name: string; target: string }[], + blockAsyncReads: false +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const wrapped: Record = { ...actual } + for (const [name, value] of Object.entries(actual)) { + if (!name.endsWith('Sync') || typeof value !== 'function') { + continue + } + const original = value as ((...args: unknown[]) => unknown) & Record + const recorder = (...args: unknown[]): unknown => { + state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' }) + return original(...args) + } + Object.assign(recorder, original) + wrapped[name] = recorder + } + return { ...wrapped, default: wrapped } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: actual, + readFile: (...args: Parameters) => + state.blockAsyncReads && String(args[0]).startsWith(state.home) + ? new Promise(() => {}) + : actual.readFile(...args) + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: actual, homedir: () => state.home } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } })) + +import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry' + +function syncCallsUnderHome(): string[] { + return state.syncCalls + .filter((call) => call.target.startsWith(state.home)) + .map((call) => `${call.name}(${call.target})`) +} + +describe('managed hook script refresh stays off the main thread', () => { + beforeEach(async () => { + state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-')) + state.syncCalls = [] + state.blockAsyncReads = false + }) + + afterEach(async () => { + state.blockAsyncReads = false + await rm(state.home, { recursive: true, force: true }) + }) + + it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => { + const hooksDir = join(state.home, '.orca', 'agent-hooks') + const claudeScript = join( + hooksDir, + process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' + ) + await mkdir(hooksDir, { recursive: true }) + await writeFile(claudeScript, 'stale', 'utf-8') + state.syncCalls = [] + + for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await refresh() + } + + expect(syncCallsUnderHome()).toEqual([]) + }) + + it('keeps the event loop responsive while a HOME read is stalled', async () => { + state.blockAsyncReads = true + const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]() + let settled = false + void refresh.then(() => { + settled = true + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(settled).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts new file mode 100644 index 00000000000..799f8acf58c --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -0,0 +1,206 @@ +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script while the presence gate skips install() forever. These tests pin the +// repair — existing scripts come current, missing ones are never created. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +let isolatedUserDataDir = '' +let previousUserDataPath: string | undefined + +beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-')) + process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(isolatedUserDataDir, { recursive: true, force: true }) +}) + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh' +import { + MANAGED_AGENT_HOOK_INSTALLERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS +} from './managed-agent-hook-registry' +import { ClaudeHookService } from '../claude/hook-service' + +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +const STALE_WINDOWS_HOOK = [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin', + ':orca_agent_hook_drain_stdin', + '"%SystemRoot%\\System32\\more.com" >nul 2>nul', + 'exit /b 0', + '' +].join('\r\n') + +describe('refreshManagedScriptIfPresent', () => { + it('rewrites an existing script and refuses to create a missing one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-')) + try { + const present = join(dir, 'present.cmd') + writeFileSync(present, 'stale') + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(readFileSync(present, 'utf8')).toBe('fresh') + + if (process.platform !== 'win32') { + chmodSync(present, 0o600) + } + const fixedTime = new Date(1_000) + utimesSync(present, fixedTime, fixedTime) + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(statSync(present).mtimeMs).toBe(fixedTime.getTime()) + if (process.platform !== 'win32') { + expect(statSync(present).mode & 0o777).toBe(0o755) + } + + const missing = join(dir, 'missing.cmd') + expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false) + expect(existsSync(missing)).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('managed hook script refresh', () => { + it('brings a stale leaking script current without touching agent config', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-')) + homedirMock.mockReturnValue(home) + try { + // Why: the bug population has a script (from a past install) but no reachable + // CLI — and possibly no config dir Orca may create. Seed only the script. + const hooksDir = join(home, '.orca', 'agent-hooks') + mkdirSync(hooksDir, { recursive: true }) + writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK) + + await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts()) + + const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') + expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0') + expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto') + // Why: refresh must not resurrect config for a CLI the user may have removed. + expect(existsSync(join(home, '.claude'))).toBe(false) + // Why: the statusline script was never installed here, so it must not appear. + expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) + + it('covers every shared launcher script with a refresher', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-')) + homedirMock.mockReturnValue(home) + const previousGrokHome = process.env.GROK_HOME + const previousKimiHome = process.env.KIMI_CODE_HOME + delete process.env.GROK_HOME + delete process.env.KIMI_CODE_HOME + try { + await withPlatform('win32', () => { + for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + install() + } + }) + const hooksDir = join(home, '.orca', 'agent-hooks') + const files = readdirSync(hooksDir) + expect(files.length).toBeGreaterThan(0) + const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent) + // Why: an installer that writes a shared launcher but skips the refresher list would + // recreate the frozen-stale-script class this suite exists to prevent. + for (const file of files) { + expect( + refresherAgents.some((agent) => file.startsWith(`${agent}-`)), + `${file} is written to ~/.orca/agent-hooks but no refresher owns it` + ).toBe(true) + } + // Why: the reverse direction — a refresher naming an agent that writes nothing is a + // stale registry entry, likely a renamed script file. + for (const agent of refresherAgents) { + expect( + files.some((file) => file.startsWith(`${agent}-`)), + `refresher for ${agent} matches no installed script` + ).toBe(true) + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + if (previousKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = previousKimiHome + } + rmSync(home, { recursive: true, force: true }) + } + }) + + it('creates nothing anywhere when no managed scripts exist', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-')) + homedirMock.mockReturnValue(home) + try { + await withPlatform('win32', async () => { + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined() + } + }) + // Why: a refresh pass on a machine with no prior installs must be a strict no-op. + expect(readdirSync(home)).toEqual([]) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts new file mode 100644 index 00000000000..5842a9dfbfe --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' + +type ExistingScript = { exists: false } | { exists: true; content: string | null } + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingScript(scriptPath: string): Promise { + try { + return { exists: true, content: await readFile(scriptPath, 'utf-8') } + } catch (error) { + if (isMissingPathError(error)) { + return { exists: false } + } + try { + await stat(scriptPath) + return { exists: true, content: null } + } catch (statError) { + if (isMissingPathError(statError)) { + return { exists: false } + } + throw error + } + } +} + +async function scriptStillExists(scriptPath: string): Promise { + try { + await stat(scriptPath) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise { + try { + await writeFile(scriptPath, content, 'utf-8') + } catch (error) { + if (isPermissionError(error) && process.platform === 'win32') { + try { + await grantDirAclAsync(dirname(scriptPath)) + await writeFile(scriptPath, content, 'utf-8') + return + } catch { + // Re-throw the original permission error. + } + } + throw error + } +} + +// Why: refresh must not block Electron's main thread or create state for an absent CLI. +export async function refreshManagedScriptIfPresent( + scriptPath: string, + content: string +): Promise { + const existing = await readExistingScript(scriptPath) + if (!existing.exists) { + return false + } + if (existing.content === content) { + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755) + } + return true + } + + const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`) + try { + await writeScriptWithAclRetry(tmpPath, content) + if (process.platform !== 'win32') { + await chmod(tmpPath, 0o755) + } + if (!(await scriptStillExists(scriptPath))) { + return false + } + await rename(tmpPath, scriptPath) + return true + } finally { + await rm(tmpPath, { force: true }).catch(() => undefined) + } +} diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e83160a3549..535eb3a3682 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -25,6 +25,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void { } export class AntigravityHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + if (process.platform === 'win32') { + for (const event of ANTIGRAVITY_EVENTS) { + await refreshManagedScriptIfPresent( + getWindowsWrapperScriptPath(event), + getWindowsWrapperScript(event.eventName) + ) + } + } + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 4d89b3a06fa..6c04fd52ae5 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,22 +1,10 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function splitBrowserTextInsertionChunks( text: string, maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES @@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } @@ -68,8 +43,7 @@ export async function insertTextThroughCdp( let chunk = chunks.next() while (!chunk.done) { await sender('Input.insertText', { text: chunk.value }) - // Why: browser automation text can be paste-sized; yielding keeps the main - // process responsive between bounded CDP payloads. + // Keep paste-sized insertion from monopolizing the main process. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { await yieldToEventLoop() diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 751ee0c0dae..3609399ac33 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,20 +1,12 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' import type * as Scanner from './scanner' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -23,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', async (importOriginal) => ({ ...(await importOriginal()), scanClaudeUsageFiles: vi.fn() @@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({ import { ClaudeUsageStore, initClaudeUsagePath } from './store' import { scanClaudeUsageFiles } from './scanner' -function createStoreWithState(state: Partial): ClaudeUsageStore { - const store = new ClaudeUsageStore({ +function createBackingStore(): ConstructorParameters[0] { + return { getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + } +} + +function createStoreWithState(state: Partial): ClaudeUsageStore { + const store = new ClaudeUsageStore(createBackingStore()) ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, @@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-')) getPathMock.mockReturnValue(tempUserData) initClaudeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanClaudeUsageFiles).mockReset() vi.mocked(scanClaudeUsageFiles).mockResolvedValue({ processedFiles: [], @@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) + it('defaults a null legacy opt-in while invalidating the cache', () => { + writeFileSync( + join(tempUserData, 'orca-claude-usage.json'), + JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) + ) + + const store = new ClaudeUsageStore(createBackingStore()) + + expect(store.getScanState().enabled).toBe(false) + }) + it('reports no data for Orca scope when only non-Orca usage exists', async () => { const store = createStoreWithState({ sessions: [ @@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => { expect(refreshMock).toHaveBeenCalledWith(false) }) - it('persists setEnabled via async durable write without leaving tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.setEnabled(true) - - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - const persisted = JSON.parse( - readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - ) - expect(persisted.scanState.enabled).toBe(true) - // Pretty-print preserved for human inspection of the analytics cache. - expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: ClaudeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('persists a successful refresh with one full-cache write', async () => { + it('adapts Claude scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => { await store.refresh(true) - // Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - ).toMatchObject({ - lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) + expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], []) + expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 180f7660b6b..66085504089 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ +/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, ClaudeUsageDailyPoint, ClaudeUsageRange, - ClaudeUsageScanState, ClaudeUsageScope, ClaudeUsageSessionRow, ClaudeUsageSnapshot, @@ -16,16 +13,15 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() @@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState { } } +function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState { + if (state.schemaVersion === SCHEMA_VERSION) { + return state + } + // Scanner changes invalidate totals, but preserving enabled keeps existing tracking on. + const defaults = getDefaultState() + return { + ...defaults, + scanState: { + ...defaults.scanState, + enabled: state.scanState.enabled ?? defaults.scanState.enabled + } + } +} + export function initClaudeUsagePath(): void { _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') } @@ -288,120 +299,22 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: ClaudeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class ClaudeUsageStore { - private state: ClaudeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): ClaudeUsagePersistedState { - try { - const usageFile = getClaudeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState - if (parsed.schemaVersion !== SCHEMA_VERSION) { - // Why: scanner semantics affect persisted totals, so old Claude caches - // must be rebuilt after parser/source changes instead of reused briefly. - // Preserve scanState.enabled so existing users keep tracking on across - // schema bumps; the next refresh will repopulate the analytics. - const defaults = getDefaultState() - return { - ...defaults, - scanState: { - ...defaults.scanState, - enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled - } - } - } - return { - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - } - } catch (error) { - // Why: Claude usage is a local analytics feature, not primary workspace - // state. A corrupt cache should degrade to a fresh rebuild instead of - // preventing Orca from booting, but we leave the file on disk for debugging. - console.error('[claude-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): ClaudeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class ClaudeUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + ClaudeUsagePersistedState, + 'hasAnyClaudeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[claude-usage]', + resolveCacheFile: getClaudeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyClaudeData', + jsonIndent: 2, + scan: scanClaudeUsageFiles + }) } getSnapshot( @@ -419,63 +332,6 @@ export class ClaudeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - // Why: assign scanPromise before any await so concurrent refresh shares one scan. - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanClaudeUsageFiles( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -843,7 +699,7 @@ export class ClaudeUsageStore { } private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -856,12 +712,12 @@ export class ClaudeUsageStore { } private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { // Why: daily aggregates use local calendar days, so session filtering has // to use the same conversion or the sessions table/counts can disagree // with the chart around UTC day boundaries. - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -883,10 +739,4 @@ export class ClaudeUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ba2dfac1351..eb8891a5bf7 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -13,6 +13,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -171,6 +172,18 @@ export class ClaudeHookService { return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent( + getManagedScriptPath(this.options.settings), + getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) + // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. + await refreshManagedScriptIfPresent( + getStatusLineScriptPath(this.options.settings), + getManagedStatusLineScript('local') + ) + } + install(): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/codex-usage/store-snapshot.benchmark.test.ts b/src/main/codex-usage/store-snapshot.benchmark.test.ts index 6ecd39963b9..dbd1482f34c 100644 --- a/src/main/codex-usage/store-snapshot.benchmark.test.ts +++ b/src/main/codex-usage/store-snapshot.benchmark.test.ts @@ -16,8 +16,8 @@ vi.mock('electron', () => ({ function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = state return store diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 83e5a43fabd..dc5d7b9fe56 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,25 +1,12 @@ /* eslint-disable max-lines */ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexUsageDailyAggregate, - CodexUsagePersistedFile, - CodexUsagePersistedState, - CodexUsageSession -} from './types' +import type { CodexUsagePersistedState } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -28,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanCodexUsageFiles: vi.fn() })) @@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({ import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' import { scanCodexUsageFiles } from './scanner' -type ScanResult = { - processedFiles: CodexUsagePersistedFile[] - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedFiles: [], sessions: [], @@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult { function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { schemaVersion: 1, @@ -118,11 +61,6 @@ describe('CodexUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) getPathMock.mockReturnValue(tempUserData) initCodexUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanCodexUsageFiles).mockReset() vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -134,7 +72,7 @@ describe('CodexUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one compact async disk write', async () => { + it('adapts Codex scans to compact cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -147,92 +85,9 @@ describe('CodexUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(scanCodexUsageFiles).toHaveBeenCalledWith([], []) expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) - expect(persistedJson).not.toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: CodexUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 10aad99fbbf..c1784f556a1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ +/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, CodexUsageDailyPoint, CodexUsageRange, - CodexUsageScanState, CodexUsageScope, CodexUsageSessionRow, CodexUsageSnapshot, @@ -16,13 +13,12 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -308,31 +304,6 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: CodexUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class CodexUsageStore { - private state: CodexUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): CodexUsagePersistedState { - try { - const usageFile = getCodexUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[codex-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan. - return this.writer.write(() => JSON.stringify(this.state)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): CodexUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class CodexUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + CodexUsagePersistedState, + 'hasAnyCodexData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[codex-usage]', + resolveCacheFile: getCodexUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyCodexData', + scan: codexUsageProvider.scan + }) } getSnapshot( @@ -434,61 +348,6 @@ export class CodexUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await codexUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -888,7 +747,7 @@ export class CodexUsageStore { } private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -901,9 +760,9 @@ export class CodexUsageStore { } private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -975,10 +834,4 @@ export class CodexUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..3bf087b14d9 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 2906ffd2da3..1e1ab9e56af 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -88,6 +89,10 @@ function buildInstalledConfig( } export class CommandCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 6a8206d0b59..332731fe352 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -17,6 +17,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CopilotHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 7b5f3b90dda..63ff9b36a71 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -15,6 +15,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CursorHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 7a697e6d96c..cbc58a44274 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1321,6 +1321,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { source: 'headless' }) }) + + it('exposes live state separately from the visible frame', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe') + + const snapshot = await adapter.getBufferSnapshot(id) + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('frame') + expect(snapshot?.data).toContain('frame') + }) }) describe('shutdown', () => { @@ -1506,6 +1518,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.snapshot).toContain('prompt$') }) + it('publishes the alt-frame payload as explicit strings', async () => { + const sessionId = 'alt-frame-boundary' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[HSTATIC-ALT-FRAME') + await new Promise((r) => setTimeout(r, 50)) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.snapshotPrefixAnsi).toContain('\x1b[?1049h') + expect(result.snapshotFrameAnsi).toContain('STATIC-ALT-FRAME') + expect(result.snapshot).toBe([result.snapshotPrefixAnsi, result.snapshotFrameAnsi].join('')) + expect(result.snapshotFrameRestoreAnsi).not.toContain('STATIC-ALT-FRAME') + }) + it('returns the preserved sequence from attach-only adoption', async () => { const sessionId = 'attach-sequence-handoff' await adapter.spawn({ cols: 80, rows: 24, sessionId }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f8bdf30deab..94d66c639a7 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -831,10 +831,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = - result.snapshot.scrollbackAnsi + - result.snapshot.rehydrateSequences + - result.snapshot.snapshotAnsi + const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences + const snapshotFrame = result.snapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { @@ -847,6 +846,14 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi + } + : {}), ...(providerSequence ? { providerSequence } : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } @@ -1216,6 +1223,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } return { data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, scrollbackAnsi: snapshot.scrollbackAnsi, cols: snapshot.cols, rows: snapshot.rows, diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts deleted file mode 100644 index 50800ffec89..00000000000 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' -import { DaemonPtyProvider } from './daemon-pty-provider' -import { DaemonServer } from './daemon-server' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) -} - -function createMockSubprocess(): SubprocessHandle & { - _simulateData: (data: string) => void - _simulateExit: (code: number) => void -} { - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), - signal: vi.fn(), - onData(cb) { - onDataCb = cb - }, - onExit(cb) { - onExitCb = cb - }, - dispose: vi.fn(), - _simulateData(data: string) { - onDataCb?.(data) - }, - _simulateExit(code: number) { - onExitCb?.(code) - } - } -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now() - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error('waitFor timed out') - } - await new Promise((r) => setTimeout(r, 10)) - } -} - -describe('DaemonPtyProvider', () => { - let dir: string - let socketPath: string - let tokenPath: string - let server: DaemonServer - let provider: DaemonPtyProvider - let lastSubprocess: ReturnType - - beforeEach(async () => { - dir = createTestDir() - socketPath = getDaemonSocketPath(dir) - tokenPath = join(dir, 'test.token') - - server = new DaemonServer({ - socketPath, - tokenPath, - spawnSubprocess: () => { - lastSubprocess = createMockSubprocess() - return lastSubprocess - } - }) - await server.start() - - provider = new DaemonPtyProvider({ socketPath, tokenPath }) - }) - - afterEach(async () => { - await provider?.cleanup() - await server?.shutdown() - rmSync(dir, { recursive: true, force: true }) - }) - - describe('spawn', () => { - it('creates a session in the daemon and returns an id', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'test-session' - }) - - expect(result.id).toBe('test-session') - }) - - it('spawns with cwd and env', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/home/user', - env: { MY_VAR: 'hello' }, - sessionId: 'session-with-env' - }) - - expect(result.id).toBe('session-with-env') - }) - }) - - describe('write', () => { - it('sends data to the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should not throw - provider.write('s1', 'ls\n') - - // Give the fire-and-forget notify time to arrive - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') - }) - }) - - describe('resize', () => { - it('resizes the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - provider.resize('s1', 120, 40) - - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) - }) - }) - - describe('shutdown (kill session)', () => { - it('kills the session in the daemon', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', { immediate: false }) - - expect(lastSubprocess.kill).toHaveBeenCalled() - }) - }) - - describe('data events', () => { - it('delivers data events from the daemon to onData listeners', async () => { - const dataPayloads: { id: string; data: string }[] = [] - provider.onData((payload) => dataPayloads.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Simulate PTY output from subprocess - lastSubprocess._simulateData('hello from shell') - - await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' }) - }) - }) - - describe('exit events', () => { - it('delivers exit events from the daemon to onExit listeners', async () => { - const exits: { id: string; code: number }[] = [] - provider.onExit((payload) => exits.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - lastSubprocess._simulateExit(42) - - await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) }) - }) - }) - - describe('cleanup', () => { - it('disconnects from daemon without killing sessions', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.cleanup() - - // Session should still be alive in the daemon — verify by connecting a new provider - const provider2 = new DaemonPtyProvider({ socketPath, tokenPath }) - const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should reattach (not create new) since the session is alive - // The daemon's createOrAttach returns isNew=false for existing sessions - expect(result.id).toBe('s1') - await provider2.cleanup() - }) - }) - - describe('multiple sessions', () => { - it('handles multiple concurrent sessions', async () => { - const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' }) - const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' }) - const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' }) - - expect(r1.id).toBe('a') - expect(r2.id).toBe('b') - expect(r3.id).toBe('c') - }) - }) -}) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts deleted file mode 100644 index 2c75f9dcfb8..00000000000 --- a/src/main/daemon/daemon-pty-provider.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { DaemonClient } from './client' -import type { DaemonEvent, CreateOrAttachResult } from './types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' - -export type DaemonPtyProviderOptions = { - socketPath: string - tokenPath: string -} - -export type DaemonSpawnOptions = { - cols: number - rows: number - sessionId: string - cwd?: string - env?: Record - envToDelete?: string[] - command?: string -} - -export type DaemonSpawnResult = { - id: string - incarnationId?: PtyIncarnationId - isNew: boolean - pid: number | null -} - -export class DaemonPtyProvider { - private client: DaemonClient - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - }) => void)[] = [] - private removeEventListener: (() => void) | null = null - - constructor(opts: DaemonPtyProviderOptions) { - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath - }) - } - - async spawn(opts: DaemonSpawnOptions): Promise { - await this.client.ensureConnected() - this.setupEventRouting() - - const result = await this.client.request('createOrAttach', { - sessionId: opts.sessionId, - cols: opts.cols, - rows: opts.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command - }) - - return { - id: opts.sessionId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - isNew: result.isNew, - pid: result.pid - } - } - - write(id: string, data: string): void { - this.client.notify('write', { sessionId: id, data }) - } - - resize(id: string, cols: number, rows: number): void { - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: convert the absolute teardown deadline to a relative timeout only here, - // at the RPC leaf; undefined keeps the DaemonClient 30s default. - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined - ) - } - - onData(callback: (payload: { id: string; data: string }) => void): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - async cleanup(): Promise { - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - } - - private setupEventRouting(): void { - if (this.removeEventListener) { - return - } - - this.removeEventListener = this.client.onEvent((raw) => { - const event = raw as DaemonEvent - if (event.type !== 'event') { - return - } - - if (event.event === 'data') { - for (const listener of this.dataListeners) { - listener({ id: event.sessionId, data: event.payload.data }) - } - } else if (event.event === 'exit') { - for (const listener of this.exitListeners) { - listener({ - id: event.sessionId, - code: event.payload.code, - ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) - }) - } - } - }) - } -} diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 37633f32334..9ee6918113f 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -575,6 +575,55 @@ describe('HeadlessEmulator', () => { }) describe('rehydration sequences', () => { + it('separates complete live state from an alternate-screen frame', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[?1004h\x1b[?25l\x1b[5;10H\x1b7\x1b[31;44;1mframe') + + const snapshot = emulator.getSnapshot() + + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1049h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot.frameRestoreAnsi).toContain('\x1b7') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[31;44;1m') + expect(snapshot.frameRestoreAnsi).not.toContain('frame') + + const restored = new HeadlessEmulator({ cols: 80, rows: 24 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + expect(restored.isAlternateScreen).toBe(true) + await restored.write('X') + expect(restored.getSnapshot().snapshotAnsi).toContain('\x1b[31;44;1mX') + restored.dispose() + }) + + it('restores current and saved cursor rows relative to origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[3;8r\x1b[?6h\x1b[2;7H\x1b7\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[3]?.[6]).toBe('S') + restored.dispose() + }) + + it('restores a saved cursor from before the current origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[1;3H\x1b7\x1b[3;8r\x1b[?6h\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[0]?.[2]).toBe('S') + restored.dispose() + }) + it('generates rehydration for non-default modes', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?2004h') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 195b97a30f6..12be643d637 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -13,6 +13,7 @@ import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' import { installTerminalViewAttributeResponder, @@ -255,6 +256,7 @@ export class HeadlessEmulator { this.restoredOscLinks ), rehydrateSequences: buildRehydrateSequences(modes), + ...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes), cwd: this.oscText.cwd, modes, cols: this.terminal.cols, @@ -266,10 +268,6 @@ export class HeadlessEmulator { ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } - if (this.partialEscapeTail.length > 0) { - // Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it. - snapshot.pendingEscapeTailAnsi = this.partialEscapeTail - } return snapshot } diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts deleted file mode 100644 index 4404c5c2c99..00000000000 --- a/src/main/daemon/production-launcher.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { createProductionLauncher } from './production-launcher' -import { startDaemon, type DaemonHandle } from './daemon-main' -import { DaemonClient } from './client' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -const { forkMock } = vi.hoisted(() => ({ - forkMock: vi.fn() -})) - -vi.mock('child_process', async () => { - const actual = await vi.importActual>('child_process') - return { ...actual, fork: forkMock } -}) - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) -} - -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 44444, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb - }, - dispose: vi.fn() - } -} - -describe('createProductionLauncher', () => { - let dir: string - let handles: DaemonHandle[] - - beforeEach(() => { - dir = createTestDir() - handles = [] - }) - - afterEach(async () => { - for (const h of handles) { - await h.shutdown().catch(() => {}) - } - forkMock.mockReset() - rmSync(dir, { recursive: true, force: true }) - }) - - it('returns a launcher function', () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - expect(typeof launcher).toBe('function') - }) - - it('rejects either ownership argument without its pair before forking', async () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) - ).rejects.toThrow('provided together') - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') - ).rejects.toThrow('provided together') - expect(forkMock).not.toHaveBeenCalled() - }) - - it('can be used with DaemonSpawner (in-process fallback)', async () => { - // Use in-process launcher for testing (same as DaemonSpawner tests) - const launcher = async (socketPath: string, tokenPath: string) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - handles.push(handle) - return { shutdown: () => handle.shutdown() } - } - - const socketPath = getDaemonSocketPath(dir) - const tokenPath = join(dir, 'test.token') - const handle = await launcher(socketPath, tokenPath) - - const client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - expect(client.isConnected()).toBe(true) - client.disconnect() - - await handle.shutdown() - handles.pop() - }) - - it('removes startup child listeners after readiness', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(), - disconnect: vi.fn(), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const pidPath = join(dir, 'daemon.pid') - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') - handlers.message[0]?.({ - type: 'ready', - startedAtMs: 123_456, - linuxStartTicks: '4242', - bootId: 'boot-a' - }) - const handle = await launch - - expect(handle.shutdown).toEqual(expect.any(Function)) - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - expect(child.disconnect).toHaveBeenCalled() - expect(child.unref).toHaveBeenCalled() - expect(forkMock).toHaveBeenCalledWith( - join(dir, 'daemon-entry.js'), - expect.arrayContaining([ - '--pid-record', - pidPath, - '--launch-nonce', - 'launch-a', - '--entry-path', - join(dir, 'daemon-entry.js'), - '--app-version', - '1.2.3' - ]), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) - ) - }) - - it('resolves shutdown only after observing child exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - once: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - off: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] - return child - }), - kill: vi.fn((signal: NodeJS.Signals) => { - if (signal === 'SIGTERM') { - queueMicrotask(() => { - child.exitCode = 0 - for (const callback of handlers.exit.slice()) { - callback(0) - } - }) - } - return true - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - await expect(handle.shutdown()).resolves.toBeUndefined() - - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(child.exitCode).toBe(0) - expect(existsSync(pidPath)).toBe(false) - }) - - it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - connected: false, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => true), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - const shutdown = expect(handle.shutdown()).rejects.toThrow( - 'Daemon did not exit after SIGKILL' - ) - expect(handlers.exit).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(6000) - await shutdown - - expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') - expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') - expect(handlers.exit).toHaveLength(0) - expect(child.unref).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('preserves readiness and signaling failures while releasing startup IPC', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('preserves exact PID ownership when child exit cannot be confirmed', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(existsSync(pidPath)).toBe(true) - }) - - it('removes exact child-side PID ownership after a pre-ready exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') - child.exitCode = 1 - handlers.exit[0]?.(1) - - await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') - expect(existsSync(pidPath)).toBe(false) - }) -}) - -function socketPathFor(dir: string): string { - return join(dir, 'test.sock') -} - -function tokenPathFor(dir: string): string { - return join(dir, 'test.token') -} diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts deleted file mode 100644 index 342ff6e94ba..00000000000 --- a/src/main/daemon/production-launcher.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process' -import { - unlinkOwnedDaemonPidFile, - type DaemonLauncher, - type DaemonProcessHandle -} from './daemon-spawner' -import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity' - -const READY_TIMEOUT_MS = 10_000 - -export type ProductionLauncherOptions = { - getDaemonEntryPath: () => string - getAppVersion: () => string -} - -export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { - return async ( - socketPath: string, - tokenPath: string, - pidPath?: string, - launchNonce?: string - ): Promise => { - if ((pidPath === undefined) !== (launchNonce === undefined)) { - // Why: partial ownership metadata would launch a v24 daemon that cannot - // prove which PID record it may remove during self-retirement. - throw new Error('Daemon PID path and launch nonce must be provided together') - } - const entryPath = opts.getDaemonEntryPath() - - const appVersion = opts.getAppVersion() - const child = fork( - entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - ...(pidPath && launchNonce - ? [ - '--pid-record', - pidPath, - '--launch-nonce', - launchNonce, - '--entry-path', - entryPath, - '--app-version', - appVersion - ] - : []) - ], - { - // Why: detached daemon output is not consumed; ignored streams cannot - // keep Electron alive after the child and IPC channel are unreferenced. - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - } - ) - - try { - await waitForReady(child) - } catch (error) { - return rejectAfterChildCleanup(child, error, pidPath, launchNonce) - } - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup( - child, - new Error('Daemon readiness identity is incomplete'), - pidPath, - launchNonce - ) - } - - // Unref so the Electron process can exit without waiting for the daemon - child.unref() - child.disconnect() - - return { - shutdown: async () => { - await shutdownChild(child) - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - } - } - } -} - -function waitForReady(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timeout) { - clearTimeout(timeout) - } - child.off('message', onMessage) - child.off('error', onError) - child.off('exit', onExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - reject(error) - } - function onMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { - if (settled) { - return - } - const readyIdentity = parseDaemonReadyIdentity(msg) - if (!readyIdentity) { - fail(new Error('Daemon readiness identity is incomplete')) - return - } - settled = true - // Why: the daemon is detached after readiness, so startup listeners - // must not keep the child process closure alive for the daemon lifetime. - cleanupStartupListeners() - resolve(readyIdentity) - } - } - function onError(err: Error): void { - fail(new Error(`Daemon process error: ${err.message}`)) - } - function onExit(code: number | null): void { - fail(new Error(`Daemon process exited prematurely with code ${code}`)) - } - - timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout')) - }, READY_TIMEOUT_MS) - - child.on('message', onMessage) - child.on('error', onError) - child.on('exit', onExit) - }) -} - -async function shutdownChild(child: ChildProcess): Promise { - try { - if ( - (child.exitCode !== null && child.exitCode !== undefined) || - (child.signalCode !== null && child.signalCode !== undefined) - ) { - return - } - await new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType - let forceTimeout: ReturnType | undefined - function finish(error?: unknown): void { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (forceTimeout) { - clearTimeout(forceTimeout) - } - child.off('exit', onExit) - if (error) { - reject(error) - } else { - resolve() - } - } - - function onExit(): void { - finish() - } - - timeout = setTimeout(() => { - try { - if (child.kill('SIGKILL') === false) { - finish(new Error('Failed to deliver SIGKILL to daemon')) - return - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - return - } - // Why: signal delivery is not process exit; keep the listener for one - // bounded interval before releasing launcher-owned handles. - if (!settled) { - forceTimeout = setTimeout( - () => finish(new Error('Daemon did not exit after SIGKILL')), - 1000 - ) - } - }, 5000) - - child.once('exit', onExit) - try { - if (child.kill('SIGTERM') === false) { - finish(new Error('Failed to deliver SIGTERM to daemon')) - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - } - }) - } finally { - if (child.connected) { - child.disconnect() - } - child.unref() - } -} - -async function rejectAfterChildCleanup( - child: ChildProcess, - launchError: unknown, - pidPath?: string, - launchNonce?: string -): Promise { - try { - await shutdownChild(child) - } catch (cleanupError) { - throw new AggregateError( - [launchError, cleanupError], - 'Daemon launch and child cleanup both failed' - ) - } - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - throw launchError -} - -function unlinkLaunchedDaemonPidFile( - child: ChildProcess, - pidPath?: string, - launchNonce?: string -): void { - if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { - unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) - } -} - -function isNoSuchProcessError(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' -} diff --git a/src/main/daemon/terminal-frame-restore-sequences.ts b/src/main/daemon/terminal-frame-restore-sequences.ts new file mode 100644 index 00000000000..6d91771c4d2 --- /dev/null +++ b/src/main/daemon/terminal-frame-restore-sequences.ts @@ -0,0 +1,97 @@ +import type { Terminal } from '@xterm/headless' +import type { SerializeAddon } from '@xterm/addon-serialize' +import { + buildAbsoluteCursorRestoreSequence, + readSavedCursorRegister +} from '../../shared/terminal-serialize-absolute-cursor' +import type { SavedCursorRegister } from '../../shared/terminal-serialize-absolute-cursor' +import type { TerminalModes } from './types' + +export function buildFrameRestoreSnapshotFields( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes +): { frameRestoreAnsi?: string } { + return modes.alternateScreen + ? { + frameRestoreAnsi: buildTerminalFrameRestoreSequences( + serializer, + terminal, + modes, + readSavedCursorRegister(terminal) + ) + } + : {} +} + +/** Restores live terminal state without replaying an alternate-screen frame. */ +function buildTerminalFrameRestoreSequences( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes, + savedCursor: SavedCursorRegister | null +): string { + const terminalModes = terminal.modes + const seqs: string[] = ['\x1b[0m\x1b[?1049h', serializeCurrentSgrState(serializer, terminal)] + if (terminalModes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + if (terminalModes.applicationKeypadMode) { + seqs.push('\x1b[?66h') + } + if (terminalModes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + if (terminalModes.insertMode) { + seqs.push('\x1b[4h') + } + if (terminalModes.reverseWraparoundMode) { + seqs.push('\x1b[?45h') + } + if (terminalModes.sendFocusMode) { + seqs.push('\x1b[?1004h') + } + if (!terminalModes.wraparoundMode) { + seqs.push('\x1b[?7l') + } + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + if (!terminalModes.showCursor) { + seqs.push('\x1b[?25l') + } + seqs.push( + buildAbsoluteCursorRestoreSequence(terminal, savedCursor, { + restoreModesWithoutCursor: true + }) + ) + return seqs.join('') +} + +function serializeCurrentSgrState(serializer: SerializeAddon, terminal: Terminal): string { + // Unsupported addon behavior under our vendored patch: an out-of-range row emits only its authoritative pen-state diff. + const emptyRow = terminal.buffer.normal.length + return serializer.serialize({ + range: { start: emptyRow, end: emptyRow }, + excludeAltBuffer: true, + excludeModes: true + }) +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts index b54227652dd..f0927fcf379 100644 --- a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -70,6 +70,24 @@ async function roundTripStyles(source: string): Promise { return replay(addon.serialize()) } +describe('out-of-range SGR state serialization', () => { + it('emits only the live pen state for a row beyond the normal buffer', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, '\x1b[31;44;1m') + + // This deliberately pins unsupported SerializeAddon behavior used by + // terminal-frame-restore-sequences under Orca's vendored addon patch. + const outOfRangeRow = terminal.buffer.normal.length + expect( + addon.serialize({ + range: { start: outOfRangeRow, end: outOfRangeRow }, + excludeAltBuffer: true, + excludeModes: true + }) + ).toBe('\x1b[31;44;1m') + }) +}) + describe('SGR intensity round-trip (BUG B, addon patch)', () => { it('restores bold set immediately after dim is cleared (minimized repro)', async () => { const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 653d8fc4bd6..6d378a8d107 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -9,6 +9,8 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** Live modes and cursor state that can be restored without the alt frame. */ + frameRestoreAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 82b00a71c3c..6cff0d2b824 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -5,6 +5,7 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeHooksJsonRemote, @@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class DevinHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 508f24e98c8..fdef56a2401 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -16,6 +16,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -159,6 +160,10 @@ function buildInstalledDroidConfig( } export class DroidHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 1615ea336f9..4e44dba7bb6 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class GeminiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 0d499aea163..b6e655efe62 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,13 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache + getSshGitCapabilityCache, + withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('shares native state while isolating each WSL distro', () => { @@ -34,4 +40,34 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) + + it('uses native capability state for a prepared host-routed WSL worktree', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + await expect( + withLocalGitCapabilityCacheForExecution( + { cwd: String.raw`C:\repo\linked`, wslDistro: 'Ubuntu' }, + async (capabilities) => capabilities + ) + ).resolves.toBe(getLocalGitCapabilityCache()) + expect(getLocalGitCapabilityCache()).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }) + ) + } finally { + platform.mockRestore() + } + }) + + it('starts non-candidate capability work without an added async turn', async () => { + let started = false + const result = withLocalGitCapabilityCacheForExecution({ cwd: '/repo' }, async () => { + started = true + return 'done' + }) + + expect(started).toBe(true) + await expect(result).resolves.toBe('done') + }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index aec7a369a26..7721df722c8 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,9 +1,14 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from './wsl-linked-worktree-git-routing' type LocalGitCapabilityTarget = { cwd?: string wslDistro?: string + signal?: AbortSignal } const localCapabilitiesByExecutionHost = new Map() @@ -29,6 +34,28 @@ export function getLocalGitCapabilityCache( return cache } +export function withLocalGitCapabilityCacheForExecution( + target: LocalGitCapabilityTarget, + run: (capabilities: GitCapabilityCache) => Promise +): Promise { + if (!target.cwd || !isWslLinkedWorktreeGitRoutingCandidate(target.cwd, target.wslDistro)) { + try { + return run(getLocalGitCapabilityCache(target)) + } catch (error) { + return Promise.reject(error) + } + } + return prepareWslLinkedWorktreeGitRouting(target.cwd, target.wslDistro, { + signal: target.signal + }).then((usesHostGit) => + run( + getLocalGitCapabilityCache( + usesHostGit ? { cwd: target.cwd } : { cwd: target.cwd, wslDistro: target.wslDistro } + ) + ) + ) +} + export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { let cache = sshCapabilitiesByProvider.get(provider) if (!cache) { diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index f90ea76ad9b..cc6e8364d7f 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,12 +17,18 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) -import { gitExecFileAsync, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests } from './wsl-git-read-environment' +import { + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' const DISTRO = 'Ubuntu' const LOGIN_ENVIRONMENT = { @@ -71,10 +77,12 @@ describe('WSL direct Git reads', () => { execFileSyncMock.mockReset() spawnMock.mockReset() resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) afterEach(() => { resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('coalesces the login-shell environment probe per distro', async () => { @@ -495,4 +503,125 @@ describe('WSL direct Git reads', () => { expect(execFileMock.mock.calls[0]?.[0]).toBe('git') }) }) + + it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe') + releaseStat?.() + await expect(pending).resolves.toBe(true) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[1]?.[0]).toBe('git') + }) + }) + + it('aborts an async Git call while linked-worktree discovery remains pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + const controller = new AbortController() + + const command = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + signal: controller.signal + }) + controller.abort() + + await expect(command).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + releaseStat?.() + await expect(discovery).resolves.toBe(true) + }) + }) + + it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => { + await withPlatform('win32', async () => { + let currentTime = 1_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => currentTime) + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => true })), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(spawnMock.mock.calls[0]?.[0]).toBe('git') + expect(spawnMock.mock.calls[1]?.[0]).toBe('wsl.exe') + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[2]?.[0]).toBe('git') + } finally { + nowSpy.mockRestore() + } + }) + }) }) diff --git a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts new file mode 100644 index 00000000000..efc37c192f7 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from 'node:events' +import type * as FsPromises from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + readFile: readFileMock +})) + +import { gitExecFileAsync } from './runner' +import { + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS +} from './wsl-linked-worktree-git-routing' + +function createMockChild(): EventEmitter & { pid: number; kill: ReturnType } { + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + } + child.pid = 1234 + child.kill = vi.fn() + return child +} + +async function withWindowsPlatform(run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetWslLinkedWorktreeGitRoutingForTests() +}) + +describe('WSL linked-worktree routing probe timeout', () => { + it('retries discovery on the next production Git call', async () => { + vi.useFakeTimers() + await withWindowsPlatform(async () => { + statMock + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue({ isDirectory: () => false, isFile: () => true }) + readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n') + execFileMock.mockImplementation((command, _args, _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + if (command === 'wsl.exe') { + callback?.( + Object.assign(new Error('not a git repository'), { code: 128 }), + '', + 'fatal: not a git repository' + ) + } else { + callback?.(null, 'host git recovered\n', '') + } + }) + return child + }) + + const first = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + const firstFailure = expect(first).rejects.toThrow('not a git repository') + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await firstFailure + + await expect( + gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' }) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls.map(([command]) => command)).toEqual(['wsl.exe', 'git']) + }) + }) +}) diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts new file mode 100644 index 00000000000..1af46a51069 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -0,0 +1,161 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitStreamStdout, + toLinuxPath, + toWindowsWslPath +} from './runner' +import { listWorktrees } from './worktree' +import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' + +const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim() +const fixtureRoots: string[] = [] +const wslFixtureRoots: string[] = [] + +function hostGit(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function wslExec(args: string[]): string { + return execFileSync('wsl.exe', ['-d', distro!, '--exec', ...args], { encoding: 'utf8' }) +} + +afterEach(async () => { + await Promise.all( + fixtureRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + for (const path of wslFixtureRoots.splice(0)) { + if (!/^\/tmp\/orca-wsl-native-[A-Za-z0-9]+$/.test(path)) { + throw new Error(`Refusing to remove unexpected WSL fixture path: ${path}`) + } + wslExec(['rm', '-rf', '--', path]) + } +}) + +describe.runIf(process.platform === 'win32' && Boolean(distro))( + 'Windows-authored linked worktree in WSL', + () => { + it('handles the worktree through the production Git runner', async () => { + const fixtureRoot = await mkdtemp(join(process.cwd(), '.wsl-linked-gitdir-')) + fixtureRoots.push(fixtureRoot) + const mainPath = join(fixtureRoot, 'main') + const linkedPath = join(fixtureRoot, 'linked') + + hostGit(['init', mainPath], fixtureRoot) + hostGit(['config', 'user.name', 'Orca Test'], mainPath) + hostGit(['config', 'user.email', 'test@invalid'], mainPath) + await writeFile(join(mainPath, 'tracked.txt'), 'tracked\n') + hostGit(['add', 'tracked.txt'], mainPath) + hostGit(['commit', '-m', 'fixture'], mainPath) + hostGit(['worktree', 'add', '-b', 'linked', linkedPath], mainPath) + await writeFile(join(linkedPath, 'untracked.txt'), 'untracked\n') + + const gitFile = await readFile(join(linkedPath, '.git'), 'utf8') + expect(gitFile).toMatch(/^gitdir: [A-Za-z]:\//) + expect(() => + execFileSync( + 'wsl.exe', + ['-d', distro!, '--exec', 'git', '-C', toLinuxPath(linkedPath), 'status', '--short'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + ).toThrow(/not a git repository/i) + + await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining(['tracked.txt', 'untracked.txt']) + ) + resetWslLinkedWorktreeGitRoutingForTests() + await expect( + searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, { + wslDistro: distro! + }) + ).resolves.toMatchObject({ totalMatches: 2 }) + + let streamedStatus = '' + await expect( + gitStreamStdout(['status', '--short'], { + cwd: linkedPath, + wslDistro: distro!, + onStdout: (chunk) => { + streamedStatus += chunk + } + }) + ).resolves.toEqual({ stoppedEarly: false }) + expect(streamedStatus).toBe('?? untracked.txt\n') + + await gitExecFileAsync(['add', '--', 'untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsyncBuffer(['show', ':untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: Buffer.from('untracked\n') }) + + const folderPath = join(linkedPath, 'packages', 'app') + await mkdir(folderPath, { recursive: true }) + await expect( + gitExecFileAsync(['rev-parse', '--show-toplevel'], { + cwd: folderPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: `${linkedPath.replace(/\\/g, '/')}\n` }) + + const linkedExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: linkedPath, + wslDistro: distro! + }) + const mainExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: mainPath, + wslDistro: distro! + }) + expect(linkedExecPath.stdout).toMatch(/^[A-Za-z]:\//) + expect(mainExecPath.stdout).toMatch(/(?:^|\n)\/[^\n]+\n$/) + await expect(listWorktrees(linkedPath, { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: expect.stringMatching(/[\\/]linked$/) }) + ]) + ) + + const nestedPath = join(linkedPath, 'nested') + wslExec(['git', 'init', toLinuxPath(nestedPath)]) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: nestedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + + it('keeps WSL-native repositories on WSL Git', async () => { + const linuxRoot = wslExec(['mktemp', '-d', '/tmp/orca-wsl-native-XXXXXX']).trim() + wslFixtureRoots.push(linuxRoot) + const windowsRoot = toWindowsWslPath(linuxRoot, distro!) + + wslExec(['git', 'init', linuxRoot]) + wslExec(['git', '-C', linuxRoot, 'config', 'user.name', 'Orca Test']) + wslExec(['git', '-C', linuxRoot, 'config', 'user.email', 'test@invalid']) + await writeFile(join(windowsRoot, 'tracked.txt'), 'tracked\n') + wslExec(['git', '-C', linuxRoot, 'add', 'tracked.txt']) + wslExec(['git', '-C', linuxRoot, 'commit', '-m', 'fixture']) + await writeFile(join(windowsRoot, 'untracked.txt'), 'untracked\n') + + await expect( + gitExecFileAsync(['status', '--short'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\?\? untracked\.txt\n$/) }) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + } +) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ccde4a492f2..cfa5aa5ca1f 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -48,6 +48,11 @@ import { peekWslGitReadEnvironment, type WslGitReadEnvironment } from './wsl-git-read-environment' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting, + usesHostGitForWslLinkedWorktree +} from './wsl-linked-worktree-git-routing' // Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. import { extractExecError, parseRetryAfterMs } from './exec-error' export { extractExecError, parseRetryAfterMs } @@ -335,6 +340,10 @@ function resolveGitCommand( options: GitExecOptions, forceLoginShell = false ): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } if (!forceLoginShell && shouldAttemptWslDirectGit(options)) { const distro = wslDistroForCommand(options.cwd, options.wslDistro) const environment = distro ? peekWslGitReadEnvironment(distro) : undefined @@ -908,13 +917,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ return { env: promptEnv, mode: 'explicit-env' } } - const resolved = resolveCommand( - 'git', - ['config', '--get', 'core.sshCommand'], - options.cwd, - options.wslDistro, - { useWslLoginShell: Boolean(options.wslDistro) } - ) + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { @@ -964,6 +967,11 @@ export async function gitExecFileAsync( return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const resolved = resolveGitCommand(args, options) const policy = options.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(options) @@ -1055,9 +1063,10 @@ export async function gitExecFileAsyncBuffer( args: string[], options: { cwd: string; maxBuffer?: number; wslDistro?: string } ): Promise<{ stdout: Buffer }> { - const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro) - }) + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + const resolved = resolveGitCommand(args, options, true) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', @@ -1098,6 +1107,11 @@ export async function gitStreamStdout( ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const gitOptions: GitExecOptions = { cwd: options.cwd, ...(options.env ? { env: options.env } : {}), @@ -1294,8 +1308,10 @@ export function gitSpawn( options: SpawnOptions & { cwd: string; wslDistro?: string } ): ChildProcess { const { wslDistro, ...spawnOptions } = options - const resolved = resolveCommand('git', args, options.cwd, wslDistro, { - useWslLoginShell: Boolean(wslDistro) + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts new file mode 100644 index 00000000000..5c92a23e10c --- /dev/null +++ b/src/main/git/status-conflict-overlap.bench.test.ts @@ -0,0 +1,250 @@ +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import type * as GitRunner from './runner' +import type { GitExec } from '../../relay/git-handler-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' + +const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ + gitStreamStdoutMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFile: readFileMock } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, gitStreamStdout: gitStreamStdoutMock } +}) + +import { getStatus } from './status' +import { getStatusOp } from '../../relay/git-handler-status-ops' + +const BENCH_DELAY_MS = 25 +const BENCH_SAMPLES = 31 +const BENCH_WARMUPS = 5 +const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +type BenchmarkResult = { + path: string + medianMs: number + p95Ms: number + minMs: number + maxMs: number +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.ceil(sorted.length * fraction) - 1]! +} + +describe('git status conflict-read overlap', () => { + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + + beforeEach(() => { + readFileMock.mockReset() + gitStreamStdoutMock.mockReset() + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) + }) + + it('starts native status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockImplementation(async () => { + statusStarted.resolve() + return { stoppedEarly: false } + }) + + const resultPromise = getStatus('/repo') + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('starts relay status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + const relayStreamGit: RelayGitStreamExec = async () => { + statusStarted.resolve() + return { stoppedEarly: false } + } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('observes an early native status rejection while marker I/O remains pending', async () => { + const markerRead = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) + let settled = false + + const resultPromise = getStatus('/repo').finally(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('keeps a synchronous native status failure fail-soft', async () => { + const statusError = new Error('status threw') + gitStreamStdoutMock.mockImplementation(() => { + throw statusError + }) + + await expect(getStatus('/repo')).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { + const statusError = new Error(`status ${mode}`) + const relayStreamGit = (() => { + if (mode === 'throw') { + throw statusError + } + return Promise.reject(statusError) + }) as RelayGitStreamExec + + await expect( + getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) + }) + + it('rethrows the original relay status failure after cancellation', async () => { + const controller = new AbortController() + const statusError = new Error('cancelled status') + const relayStreamGit: RelayGitStreamExec = async () => { + controller.abort(statusError) + throw statusError + } + + await expect( + getStatusOp( + relayGit, + relayStreamGit, + { worktreePath: '/repo' }, + { signal: controller.signal } + ) + ).rejects.toBe(statusError) + }) + + it('surfaces detector errors without waiting for a hung status read', async () => { + const statusStarted = deferred() + const relayStreamGit: RelayGitStreamExec = () => { + statusStarted.resolve() + return new Promise(() => {}) + } + const invalidPath = { toString: () => '/repo' } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { + worktreePath: invalidPath + }) + const rejection = expect(resultPromise).rejects.toThrow(TypeError) + await statusStarted.promise + await rejection + }) + + it('keeps detector errors ahead of concurrent status failures', async () => { + const statusError = new Error('status failed too') + const relayStreamGit = (() => { + throw statusError + }) as RelayGitStreamExec + const invalidPath = { toString: () => '/repo' } + + const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) + await expect(result).rejects.toBeInstanceOf(TypeError) + }) +}) + +describeBench('git status conflict-read overlap benchmark', () => { + it('measures native and relay orchestration with matched independent latency', async () => { + readFileMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return 'gitdir: /repo/.git/worktrees/feature\n' + }) + gitStreamStdoutMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + }) + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + const relayStreamGit: RelayGitStreamExec = async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + } + const cases = [ + { name: 'native', run: () => getStatus('/repo') }, + { + name: 'relay', + run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + } + ] + + const results: BenchmarkResult[] = [] + for (const benchmarkCase of cases) { + for (let index = 0; index < BENCH_WARMUPS; index += 1) { + await benchmarkCase.run() + } + const samples: number[] = [] + for (let index = 0; index < BENCH_SAMPLES; index += 1) { + const startedAt = performance.now() + await benchmarkCase.run() + samples.push(performance.now() - startedAt) + } + results.push({ + path: benchmarkCase.name, + medianMs: Number(percentile(samples, 0.5).toFixed(3)), + p95Ms: Number(percentile(samples, 0.95).toFixed(3)), + minMs: Number(Math.min(...samples).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)) + }) + } + + console.table(results) + expect(results).toHaveLength(2) + }, 10_000) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 07bf124d31c..a51d1784ddf 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -315,22 +315,32 @@ async function runGetStatus( // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const result = await gitStreamStdout(statusArgs, { + cwd: worktreePath, + wslDistro: options.wslDistro, + preferWslDirectGit: true, + // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. + env: gitOptionalLocksDisabledEnv(), + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return result + })() + ]) const conflictOperation = await conflictPromise try { - const { stoppedEarly } = await gitStreamStdout(statusArgs, { - cwd: worktreePath, - wslDistro: options.wslDistro, - preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. - env: gitOptionalLocksDisabledEnv(), - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } - didHitLimit = stoppedEarly + didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { // Why: an aborted scan must reject, not resolve as an empty result. diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 064eb0887fd..9ee8eb2e188 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -30,7 +30,7 @@ import { isUnsupportedRevParsePathFormatError, isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -410,32 +410,32 @@ async function readRepoLocation( resolveBasePath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) try { - return await capabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) + return await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. - capabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRepoLocation(resolveBasePath, stdout) - }, - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) - }, - isUnsupportedRevParsePathFormatError ) } catch { return undefined @@ -578,41 +578,44 @@ async function readWorktreeList( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) const execOptions = { cwd: repoPath, ...options, timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS } - return capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain', '-z'], - execOptions + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain', '-z'], + execOptions + ) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + execOptions + ) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting + // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). + return annotatePrunableByExistence(normalized, repoPath, options) + }, + isUnsupportedWorktreeListZError ) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - }, - async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions) - const normalized = await normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - options - ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). - return annotatePrunableByExistence(normalized, repoPath, options) - }, - isUnsupportedWorktreeListZError ) } @@ -1355,14 +1358,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. - if ( - !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( - runGit, - branchName, - targetRefs, - getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro }) - )) - ) { + const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) + ) + if (!hasNoUnmergedChanges) { return false } await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => diff --git a/src/main/git/wsl-linked-worktree-git-routing.test.ts b/src/main/git/wsl-linked-worktree-git-routing.test.ts new file mode 100644 index 00000000000..9ab07309b1f --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.test.ts @@ -0,0 +1,600 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + parseWindowsLinkedGitdir, + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests, + usesHostGitForWslLinkedWorktree, + WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS, + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' + +afterEach(() => resetWslLinkedWorktreeGitRoutingForTests()) + +const fileMarker = { isDirectory: () => false, isFile: () => true } +const directoryMarker = { isDirectory: () => true, isFile: () => false } + +function missingMarker(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('parseWindowsLinkedGitdir', () => { + it.each([ + ['gitdir: C:/repo/.git/worktrees/linked\n', 'C:/repo/.git/worktrees/linked'], + [String.raw`gitdir: D:\repo\.git\worktrees\linked`, String.raw`D:\repo\.git\worktrees\linked`] + ])('accepts a Windows drive-qualified gitdir', (content, expected) => { + expect(parseWindowsLinkedGitdir(content)).toBe(expected) + }) + + it.each([ + 'gitdir: ../main/.git/worktrees/linked', + 'gitdir: /home/dev/repo/.git/worktrees/linked', + 'gitdir: C:repo/.git/worktrees/linked', + 'not a gitdir' + ])('rejects the non-Windows-linked marker %s', (content) => { + expect(parseWindowsLinkedGitdir(content)).toBeNull() + }) +}) + +describe('usesHostGitForWslLinkedWorktree', () => { + it('scopes a cached route to the exact primed folder workspace', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked\packages\app`) + + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\packages\app`, 'Ubuntu', 'win32') + ).toBe(true) + expect(usesHostGitForWslLinkedWorktree('C:/repo/linked/packages/app/', 'Ubuntu', 'win32')).toBe( + true + ) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\main`, 'Ubuntu', 'win32')).toBe(false) + }) + + it('does not affect native Windows, WSL-native, or non-Windows execution', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, undefined, 'win32')).toBe( + false + ) + expect( + usesHostGitForWslLinkedWorktree( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + 'Ubuntu', + 'win32' + ) + ).toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, 'Ubuntu', 'linux')).toBe( + false + ) + }) +}) + +describe('prepareWslLinkedWorktreeGitRouting', () => { + it('discovers parent markers independently for sibling folder workspaces', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return fileMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('caches an ordinary repository marker without reading it', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => directoryMarker), + readFile: vi.fn(async () => '') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(fileSystem.readFile).not.toHaveBeenCalled() + }) + + it('fails closed without caching a marker read error', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('access denied'), { code: 'EACCES' })) + .mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('backs off repeated marker stat errors after one immediate retry', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + throw Object.assign(new Error('device unavailable'), { code: 'EIO' }) + }), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS - 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + + currentTime += 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(3) + }) + + it('does not inherit a linked-parent route across a nested repository boundary', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\repo\linked\nested\.git` ? directoryMarker : fileMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked\nested`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\nested`, 'Ubuntu', 'win32') + ).toBe(false) + }) + + it('does not conflate case-distinct directories on case-sensitive Windows storage', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\Repo\.git` ? fileMarker : directoryMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\Repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + }) + + it('revalidates a linked route that becomes an ordinary repository', async () => { + let linked = true + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + linked = false + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('revalidates an ordinary repository that becomes a linked worktree', async () => { + let linked = false + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + linked = true + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('does not let a sibling route supersede an unexpired nested-repository miss', async () => { + let linked = false + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return linked ? fileMarker : directoryMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + linked = true + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + }) + + it('does not evict fresh routes while pruning under working-directory churn', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + for (let index = 0; index <= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD; index += 1) { + await prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + } + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo-0`, 'Ubuntu', 'win32')).toBe(true) + expect( + usesHostGitForWslLinkedWorktree( + `C:\\repo-${WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD}`, + 'Ubuntu', + 'win32' + ) + ).toBe(true) + }) + + it('coalesces delayed discovery without blocking an event-loop turn', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + let settled = false + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }).finally(() => { + settled = true + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(settled).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('cancels a waiter without canceling or duplicating shared discovery', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const controller = new AbortController() + + const cancelled = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + signal: controller.signal + }) + controller.abort() + + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('ignores an old probe that settles after reset and a new same-path probe starts', async () => { + let releaseOld: ((marker: typeof fileMarker) => void) | undefined + const oldMarker = new Promise((resolve) => { + releaseOld = resolve + }) + const oldFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => oldMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const oldRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: oldFileSystem + }) + + resetWslLinkedWorktreeGitRoutingForTests() + let releaseCurrent: ((marker: typeof directoryMarker) => void) | undefined + const currentMarker = new Promise((resolve) => { + releaseCurrent = resolve + }) + const currentFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => currentMarker), + readFile: vi.fn(async () => '') + } + const currentRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + + releaseOld?.(fileMarker) + await expect(oldRoute).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32')).toBe(false) + const currentJoiner = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + + releaseCurrent?.(directoryMarker) + await expect(Promise.all([currentRoute, currentJoiner])).resolves.toEqual([false, false]) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('fails closed and retries after a stalled discovery deadline', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + + await expect(Promise.all([first, second])).resolves.toEqual([false, false]) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes for one working directory after repeated deadlines', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + + for (let index = 0; index < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD; index += 1) { + const route = prepare() + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(route).resolves.toBe(false) + } + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS) + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes across working directories', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const pending = Array.from( + { length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, + (_, index) => + prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ) + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\blocked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(Promise.all(pending)).resolves.toEqual( + Array.from({ length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, () => false) + ) + } finally { + vi.useRealTimers() + } + }) + + it('coalesces concurrent revalidation after a cached route expires', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockResolvedValueOnce(directoryMarker) + .mockReturnValueOnce(delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/git/wsl-linked-worktree-git-routing.ts b/src/main/git/wsl-linked-worktree-git-routing.ts new file mode 100644 index 00000000000..440307fd5f0 --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.ts @@ -0,0 +1,306 @@ +import * as fsPromises from 'node:fs/promises' +import { win32 } from 'node:path' +import type { Stats } from 'node:fs' + +type CachedRoute = { usesHostGit: boolean; expiresAt: number } +type TransientRouteFailure = { count: number; retryAfter: number } + +// Bounds stale routing after delete/recreate while amortizing async parent walks on slow storage. +export const WSL_LINKED_WORKTREE_ROUTE_TTL_MS = 30_000 +export const WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD = 512 +export const WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS = 5_000 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD = 2 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL = 32 +export const WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS = 1_000 +const WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS = 30_000 + +const routeByCwd = new Map() +const pendingRoutes = new Map>() +const transientFailuresByCwd = new Map() +const activeProbeCountByCwd = new Map() +let activeProbeCount = 0 +let routeProbeGeneration = 0 +let nextRoutePruneAt = 0 + +export function isWslLinkedWorktreeGitRoutingCandidate( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'win32' && Boolean(wslDistro) && /^[A-Za-z]:[/\\]/.test(cwd) +} + +function normalize(path: string): string { + return win32.resolve(path) +} + +function cachedRoute(cwd: string, now: number): boolean | undefined { + const exact = routeByCwd.get(cwd) + if (!exact) { + return undefined + } + if (exact.expiresAt <= now) { + routeByCwd.delete(cwd) + return undefined + } + return exact.usesHostGit +} + +export function parseWindowsLinkedGitdir(content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null +} + +export type WslLinkedWorktreeRoutingFileSystem = { + stat(path: string): Promise> + readFile(path: string): Promise +} + +export type WslLinkedWorktreeRoutingOptions = { + platform?: NodeJS.Platform + fileSystem?: WslLinkedWorktreeRoutingFileSystem + now?: () => number + signal?: AbortSignal +} + +const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: (path) => fsPromises.stat(path), + readFile: (path) => fsPromises.readFile(path, 'utf8') +} + +async function shouldUseHostGit( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem +): Promise { + let candidate = cwd + const driveRoot = win32.parse(candidate).root + while (true) { + const markerPath = win32.join(candidate, '.git') + try { + const marker = await fileSystem.stat(markerPath) + if (marker.isDirectory()) { + return false + } + if (marker.isFile()) { + return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null + } + return false + } catch (error) { + const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error + } + } + if (candidate === driveRoot) { + return false + } + candidate = win32.dirname(candidate) + } +} + +function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean { + const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + if ( + routeByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD && + now >= nextRoutePruneAt + ) { + for (const [cachedCwd, route] of routeByCwd) { + if (route.expiresAt <= now) { + routeByCwd.delete(cachedCwd) + } + } + nextRoutePruneAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + } + routeByCwd.set(cwd, { usesHostGit, expiresAt }) + return usesHostGit +} + +function routingAbortError(): Error { + return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) +} + +function rememberTransientFailure(cwd: string, now: number): void { + const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1 + const retryDelay = + count === 1 + ? 0 + : Math.min( + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS * 2 ** Math.min(count - 2, 10), + WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS + ) + if ( + !transientFailuresByCwd.has(cwd) && + transientFailuresByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD + ) { + const oldest = transientFailuresByCwd.keys().next().value + if (oldest !== undefined) { + transientFailuresByCwd.delete(oldest) + } + } + transientFailuresByCwd.delete(cwd) + transientFailuresByCwd.set(cwd, { count, retryAfter: now + retryDelay }) +} + +function canStartRouteProbe(cwd: string, now: number): boolean { + const failure = transientFailuresByCwd.get(cwd) + return Boolean( + (!failure || failure.retryAfter <= now) && + (activeProbeCountByCwd.get(cwd) ?? 0) < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD && + activeProbeCount < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL + ) +} + +function trackRouteProbe(cwd: string): () => void { + const generation = routeProbeGeneration + activeProbeCount += 1 + activeProbeCountByCwd.set(cwd, (activeProbeCountByCwd.get(cwd) ?? 0) + 1) + return () => { + if (generation !== routeProbeGeneration) { + return + } + activeProbeCount -= 1 + const remaining = (activeProbeCountByCwd.get(cwd) ?? 1) - 1 + if (remaining === 0) { + activeProbeCountByCwd.delete(cwd) + } else { + activeProbeCountByCwd.set(cwd, remaining) + } + } +} + +function waitForRouteUnlessAborted( + route: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return route + } + if (signal.aborted) { + return Promise.reject(routingAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(routingAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + route.then( + (result) => { + signal.removeEventListener('abort', onAbort) + resolve(result) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function discoverRoute( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem, + now: () => number +): Promise { + return new Promise((resolve) => { + const generation = routeProbeGeneration + let settled = false + const finish = (usesHostGit: boolean, cacheable: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (generation !== routeProbeGeneration) { + resolve(false) + return + } + if (cacheable) { + transientFailuresByCwd.delete(cwd) + resolve(rememberRoute(cwd, usesHostGit, now())) + } else { + rememberTransientFailure(cwd, now()) + resolve(usesHostGit) + } + } + const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + timer.unref() + const releaseProbe = trackRouteProbe(cwd) + void shouldUseHostGit(cwd, fileSystem).then( + (usesHostGit) => { + releaseProbe() + finish(usesHostGit, true) + }, + () => { + releaseProbe() + finish(false, false) + } + ) + }) +} + +export async function prepareWslLinkedWorktreeGitRouting( + cwd: string, + wslDistro: string | undefined, + options: WslLinkedWorktreeRoutingOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const fileSystem = options.fileSystem ?? defaultFileSystem + const now = options.now ?? Date.now + if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) { + return false + } + if (options.signal?.aborted) { + throw routingAbortError() + } + const normalizedCwd = normalize(cwd) + const cached = cachedRoute(normalizedCwd, now()) + if (cached !== undefined) { + return cached + } + const pending = pendingRoutes.get(normalizedCwd) + if (pending) { + return waitForRouteUnlessAborted(pending, options.signal) + } + if (!canStartRouteProbe(normalizedCwd, now())) { + return false + } + const discovery = discoverRoute(normalizedCwd, fileSystem, now) + const route = discovery.finally(() => { + if (pendingRoutes.get(normalizedCwd) === route) { + pendingRoutes.delete(normalizedCwd) + } + }) + pendingRoutes.set(normalizedCwd, route) + return waitForRouteUnlessAborted(route, options.signal) +} + +export function usesHostGitForWslLinkedWorktree( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform, + now: () => number = Date.now +): boolean { + return ( + isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform) && + cachedRoute(normalize(cwd), now()) === true + ) +} + +export function resetWslLinkedWorktreeGitRoutingForTests(): void { + routeByCwd.clear() + pendingRoutes.clear() + transientFailuresByCwd.clear() + activeProbeCountByCwd.clear() + activeProbeCount = 0 + routeProbeGeneration += 1 + nextRoutePruneAt = 0 +} + +export function seedWslLinkedWorktreeGitRoutingForTests(root: string): void { + const normalizedRoot = normalize(root) + routeByCwd.set(normalizedRoot, { + usesHostGit: true, + expiresAt: Number.POSITIVE_INFINITY + }) +} diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a33b9b38d20..b462b77a9cd 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,7 +91,8 @@ vi.mock('./gh-utils', () => ({ })) vi.mock('../git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock + gitExecFileAsync: gitExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ @@ -186,6 +187,7 @@ import { _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, + _resetPRStackSummaryCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' @@ -193,6 +195,7 @@ import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-me import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubPRStackCacheForTests } from './github-pr-stack' // The origin-repository cache is module-level state; reset it so slugs // resolved by one test cannot leak into the next. @@ -273,6 +276,8 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetPRStackSummaryCacheForTests() + _resetGitHubPRStackCacheForTests() __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() @@ -431,7 +436,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ @@ -453,6 +458,81 @@ describe('getPRForBranch', () => { }) }) + it('caches exact REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'open', + stack: null + }) + } + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1') + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(2) + expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3) + }) + + it('caches failed REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + throw new Error('GitHub is temporarily unavailable') + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(1) + }) + it('hydrates repository merge method settings for exact PR lookups', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -518,6 +598,54 @@ describe('getPRForBranch', () => { ) }) + it('isolates viewer-dependent merge metadata across SSH connections', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + let metadataProbe = 0 + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('graphql')) { + metadataProbe += 1 + return { + stdout: JSON.stringify({ + data: { + repository: { + autoMergeAllowed: metadataProbe === 1, + mergeQueue: null + } + } + }) + } + } + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) } + }) + + const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1') + const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2') + + expect(firstAccount?.autoMergeAllowed).toBe(true) + expect(secondAccount?.autoMergeAllowed).toBe(false) + expect(metadataProbe).toBe(2) + }) + it('treats linked PR metadata as authoritative even when the branch head differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) @@ -559,7 +687,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr?.number).toBe(99) }) @@ -1181,7 +1309,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('leaves linked merged divergence unset when the membership probe throws', async () => { @@ -1223,7 +1351,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('prefers branch lookup over a fallback PR number', async () => { @@ -2866,7 +2994,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', @@ -4244,8 +4372,252 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) }) + it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/202', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 2, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 1, + pullRequest: { + number: 201, + title: 'Stack models', + url: 'https://github.com/stablyai/orca/pull/201', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 2, + pullRequest: { + number: 202, + title: 'Stack API', + url: 'https://github.com/stablyai/orca/pull/202', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + } + ] + } + } + } + } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } } + } + } + }) + }) + + const pr = await getPRForBranch('/repo-root', 'stack/api', 202) + + expect(pr?.stack).toMatchObject({ + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { number: 201, position: 1 }, + { number: 202, position: 2 } + ] + }) + expect(pr?.mergeQueueRequired).toBe(true) + const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('graphql') && args.includes('branch=main') + ) + expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) + }) + + it('uses async merge only for GitHub-registered stacks', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + expect(mergeCall?.[0]).toEqual( + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_action=merge_queue', + 'sha=api-sha' + ]) + ) + expect(mergeCall?.[0]).not.toContain('merge_method=squash') + expect(acquireMock).toHaveBeenCalledTimes(2) + expect(releaseMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('never falls back to legacy merge after an async stack merge transport failure', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) + }) + .mockRejectedValueOnce(new Error('socket closed after request submission')) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: false, error: 'socket closed after request submission' }) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('keeps legacy merge for unregistered dependent PR chains', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'open', + head: { ref: 'feature/api', sha: 'api-sha' }, + base: { ref: 'feature/models', sha: 'models-sha' }, + stack: null + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'feature/models', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'open', + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' }, + stack: null + }) + }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4279,8 +4651,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + cwd: '/repo-root', + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, [ 'pr', 'view', @@ -4293,7 +4669,7 @@ describe('GitHub GraphQL rate-limit guard', () => { { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4302,7 +4678,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) @@ -4310,6 +4686,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4330,13 +4707,16 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining([ 'api', 'graphql', @@ -4353,7 +4733,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), @@ -4365,6 +4745,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('enables auto-merge without invoking the direct merge command', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4391,8 +4772,39 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + it('rejects auto-merge for GitHub-registered stacks', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'head-oid' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + + await expect( + setPRAutoMerge('/repo-root', 202, true, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('translates the GitHub clean-status rejection into an actionable message', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4412,6 +4824,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) }) @@ -4429,12 +4842,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4450,24 +4863,26 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('blocks direct merge when GitHub reports required approval', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'MERGEABLE', - reviewDecision: 'REVIEW_REQUIRED', - mergeStateStatus: 'CLEAN', - autoMergeRequest: null, - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) await expect( mergePR('/repo-root', 7, 'squash', undefined, { @@ -4480,8 +4895,8 @@ describe('GitHub GraphQL rate-limit guard', () => { error: 'This pull request requires review approval before it can be merged.' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql') }) it('detects merge queues once per base branch and blocks direct merges', async () => { @@ -4502,10 +4917,12 @@ describe('GitHub GraphQL rate-limit guard', () => { headRefOid: 'head-oid' } ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( @@ -4526,10 +4943,10 @@ describe('GitHub GraphQL rate-limit guard', () => { expect( ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) ).toHaveLength(1) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual( expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) ) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F') }) it('caches unknown merge queue probes after GraphQL failures', async () => { @@ -4553,7 +4970,9 @@ describe('GitHub GraphQL rate-limit guard', () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: '{}' }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ stdout: '{}' }) await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ mergeQueueRequired: null @@ -4679,21 +5098,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 7, - title: 'PR', - state: 'OPEN', - url: 'https://github.com/stablyai/orca/pull/7', - statusCheckRollup: [], - updatedAt: '2026-04-01T00:00:00Z', - isDraft: false, - mergeable: 'CONFLICTING', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) }) - }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -4716,11 +5137,12 @@ describe('GitHub GraphQL rate-limit guard', () => { '- src/conflict.ts' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('does not run merge conflict preflight for SSH-backed repos', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4746,9 +5168,9 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 4f95432fcfd..828f3290647 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -20,7 +20,8 @@ import type { GitHubPullRequestStateUpdate, GitHubRerunPRChecksResult, GitHubPRMergeMethod, - GitHubPRMergeMethodSettings + GitHubPRMergeMethodSettings, + GitHubPRStack } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { @@ -123,6 +124,7 @@ import { spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' +import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' type GhExecOptions = GitHubRepoExecOptions type HostedReviewLocalGitOptions = ReturnType @@ -161,11 +163,30 @@ const repositoryMergeMetadataCache = new Map< string, { value: GitHubRepositoryMergeMetadata; expiresAt: number } >() +const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 +const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 +const prStackSummaryCache = new Map< + string, + { value: GitHubPRStack | undefined; expiresAt: number } +>() +const prStackSummaryInFlight = new Map>() + +function githubPRStackExecutionScope( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` +} export function _resetMergeQueueCacheForTests(): void { repositoryMergeMetadataCache.clear() } +export function _resetPRStackSummaryCacheForTests(): void { + prStackSummaryCache.clear() + prStackSummaryInFlight.clear() +} + export function _getMergeQueueCacheSizeForTests(): number { return repositoryMergeMetadataCache.size } @@ -2162,6 +2183,8 @@ type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + stack?: GitHubPRStack + stackMetadataChecked?: boolean } type RestPullRequest = { @@ -2177,6 +2200,12 @@ type RestPullRequest = { mergeable_state?: string | null base?: { ref?: string; sha?: string } head?: { ref?: string; sha?: string } + stack?: { + number?: number + position?: number + size?: number + base?: { ref?: string; sha?: string } + } | null } const PR_LOOKUP_JSON_FIELDS = @@ -2216,6 +2245,19 @@ function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableSta } function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { + const stack = + typeof pr.stack?.number === 'number' && + typeof pr.stack.position === 'number' && + typeof pr.stack.size === 'number' && + typeof pr.stack.base?.ref === 'string' + ? { + number: pr.stack.number, + position: pr.stack.position, + size: pr.stack.size, + baseRefName: pr.stack.base.ref, + ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) + } + : undefined return { number: pr.number, title: pr.title, @@ -2228,7 +2270,9 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { baseRefName: pr.base?.ref, headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, - headRefOid: pr.head?.sha + headRefOid: pr.head?.sha, + stackMetadataChecked: true, + ...(stack ? { stack } : {}) } } @@ -2298,9 +2342,10 @@ function cacheRepositoryMergeMetadata( async function detectRepositoryMergeMetadata( ownerRepo: GitHubApiRepository, branchName: string | undefined, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope = 'default' ): Promise { - const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2311,7 +2356,7 @@ async function detectRepositoryMergeMetadata( return { mergeQueueRequired: null, autoMergeAllowed: null } } const query = branchName - ? `query($owner: String!, $repo: String!, $branch: String!) { + ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { repository(owner: $owner, name: $repo) { viewerDefaultMergeMethod mergeCommitAllowed @@ -2319,6 +2364,9 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed autoMergeAllowed mergeQueue(branch: $branch) { id } + ref(qualifiedName: $qualified) { + rules(first: 50) { nodes { type } } + } } }` : `query($owner: String!, $repo: String!) { @@ -2344,6 +2392,7 @@ async function detectRepositoryMergeMetadata( ] if (branchName) { args.push('-f', `branch=${branchName}`) + args.push('-f', `qualified=refs/heads/${branchName}`) } const { stdout } = await ghExecFileAsync(args, { ...ghOptions, @@ -2358,6 +2407,7 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed?: unknown autoMergeAllowed?: unknown mergeQueue?: { id?: unknown } | null + ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null } | null } } @@ -2371,7 +2421,10 @@ async function detectRepositoryMergeMetadata( }) : undefined const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null, + mergeQueueRequired: branchName + ? Boolean(repository?.mergeQueue) || + Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) + : null, autoMergeAllowed: typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, ...(mergeMethodSettings ? { mergeMethodSettings } : {}) @@ -2392,13 +2445,19 @@ async function detectRepositoryMergeMetadata( async function hydratePullRequestLookupData( ownerRepo: OwnerRepo, data: PullRequestLookupData, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data const mergeMetadata = hasRichMergeFields - ? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions) + ? await detectRepositoryMergeMetadata( + ownerRepo, + normalized.stack?.baseRefName ?? normalized.baseRefName, + ghOptions, + executionScope + ) : undefined return { ...normalized, @@ -2413,13 +2472,17 @@ async function hydratePullRequestLookupData( async function hydrateBranchLookupWithExactPR( ownerRepo: OwnerRepo, branchData: PullRequestLookupData | null, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { if (!branchData) { return null } try { - return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + return ( + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? + branchData + ) } catch { return branchData } @@ -2772,6 +2835,7 @@ async function lookupPRByBranchName(args: { headRepo: OwnerRepo | null branchName: string ghOptions: GhExecOptions + executionScope: string }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -2791,7 +2855,12 @@ async function lookupPRByBranchName(args: { ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2810,7 +2879,12 @@ async function lookupPRByBranchName(args: { args.branchName, args.ghOptions ) - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2857,10 +2931,71 @@ async function getRestPRByNumber( return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } +function prunePRStackSummaryCache(now = Date.now()): void { + for (const [key, cached] of prStackSummaryCache) { + if (cached.expiresAt <= now) { + prStackSummaryCache.delete(key) + } + } + while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prStackSummaryCache.keys().next().value + if (oldestKey === undefined) { + return + } + prStackSummaryCache.delete(oldestKey) + } +} + +async function getCachedGitHubPRStackSummary( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string +): Promise { + const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` + const now = Date.now() + prunePRStackSummaryCache(now) + const cached = prStackSummaryCache.get(key) + if (cached && cached.expiresAt > now) { + return cached.value + } + const existing = prStackSummaryInFlight.get(key) + if (existing) { + return existing + } + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack) + prStackSummaryInFlight.set(key, request) + try { + const value = await request + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + return value + } catch (err) { + // Why: avoid repeating a failed REST probe on every review poll. + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value: undefined, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + throw err + } finally { + if (prStackSummaryInFlight.get(key) === request) { + prStackSummaryInFlight.delete(key) + } + } +} + async function getPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType + ghOptions: ReturnType, + executionScope: string, + knownPullRequestData?: PullRequestLookupData | null ): Promise { try { const { stdout } = await ghExecFileAsync( @@ -2875,10 +3010,16 @@ async function getPRByNumber( ], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) + const exactData = JSON.parse(stdout) as PullRequestLookupData return hydratePullRequestLookupData( ownerRepo, - JSON.parse(stdout) as PullRequestLookupData, - ghOptions + { + ...knownPullRequestData, + ...exactData, + ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) + }, + ghOptions, + executionScope ) } catch (err) { // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -2886,8 +3027,13 @@ async function getPRByNumber( return null } try { - const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) - return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null + const restData = + knownPullRequestData === undefined + ? await getRestPRByNumber(ownerRepo, number, ghOptions) + : knownPullRequestData + return restData + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -2904,10 +3050,16 @@ async function lookupPRByNumber(args: { candidates: OwnerRepo[] number: number ghOptions: ReturnType + executionScope: string }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { try { - const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions) + const linkedData = await getPRByNumber( + candidate, + args.number, + args.ghOptions, + args.executionScope + ) if (!linkedData) { continue } @@ -3006,6 +3158,7 @@ export async function getPRForBranchOutcome( const localGitOptions = localGitArgs[0] ?? {} const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) + const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) await acquire() try { @@ -3032,6 +3185,7 @@ export async function getPRForBranchOutcome( let pendingBranchLookupError: unknown let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined + let usedExactNumberLookup = false const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 @@ -3104,10 +3258,12 @@ export async function getPRForBranchOutcome( } if (typeof linkedPRNumber === 'number') { + usedExactNumberLookup = true const exactLookup = await lookupPRByNumber({ candidates, number: linkedPRNumber, - ghOptions + ghOptions, + executionScope }) data = exactLookup.data dataRepo = exactLookup.dataRepo @@ -3117,7 +3273,8 @@ export async function getPRForBranchOutcome( candidates, headRepo, branchName, - ghOptions + ghOptions, + executionScope }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -3149,7 +3306,8 @@ export async function getPRForBranchOutcome( candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, - ghOptions + ghOptions, + executionScope }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -3172,10 +3330,12 @@ export async function getPRForBranchOutcome( dataHeadRepo = headRepo } if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, number: fallbackPRNumber, - ghOptions + ghOptions, + executionScope }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo @@ -3221,7 +3381,42 @@ export async function getPRForBranchOutcome( return { kind: 'no-pr', fetchedAt: Date.now() } } + if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) { + try { + data.stack = await getCachedGitHubPRStackSummary( + dataRepo, + data.number, + ghOptions, + executionScope + ) + data.stackMetadataChecked = true + } catch { + // Stack metadata is additive; exact PR lookup remains usable without it. + } + } const mergeable = derivePullRequestMergeable(data) + const stack = + data.stack && dataRepo + ? await hydrateGitHubPRStack( + dataRepo, + data.number, + data.stack, + ghOptions, + data.updatedAt, + executionScope + ) + : data.stack + const stackMergeQueueRequired = + stack && dataRepo + ? ( + await detectRepositoryMergeMetadata( + dataRepo, + stack.baseRefName, + ghOptions, + executionScope + ) + ).mergeQueueRequired + : undefined const conflictSummary = !connectionId && mergeable === 'CONFLICTING' && @@ -3251,13 +3446,19 @@ export async function getPRForBranchOutcome( ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), - ...(data.mergeQueueRequired !== undefined - ? { mergeQueueRequired: data.mergeQueueRequired } + ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined + ? { + mergeQueueRequired: + stackMergeQueueRequired !== undefined + ? stackMergeQueueRequired + : data.mergeQueueRequired + } : {}), ...(data.mergeMethodSettings !== undefined ? { mergeMethodSettings: data.mergeMethodSettings } : {}), ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), + ...(stack ? { stack } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), @@ -4760,7 +4961,35 @@ export async function mergePR( return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() + let concurrencySlotHeld = true try { + let stackSummary: GitHubPRStack | undefined + let stackHeadSha: string | undefined + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + stackSummary = restData?.stack + stackHeadSha = restData?.headRefOid + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + if (stackSummary) { + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + stackSummary.baseRefName, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + release() + concurrencySlotHeld = false + return await mergeGitHubPRStack({ + repository: ownerRepo, + prNumber, + method, + mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', + headSha: stackHeadSha, + ghOptions + }) + } const mergeBlocker = await getPRMergeBlocker( repoPath, prNumber, @@ -4788,7 +5017,9 @@ export async function mergePR( err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' return { ok: false, error: message } } finally { - release() + if (concurrencySlotHeld) { + release() + } } } @@ -4899,11 +5130,25 @@ async function enablePRAutoMerge( ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { + if (ownerRepo) { + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + if (restData?.stack) { + return { + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + } + } + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + } const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions) if (!pr?.id) { return { ok: false, error: 'Could not resolve GitHub pull request ID' } } - if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) { + const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions) + if (useMergeQueue) { await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) return { ok: true } } @@ -4950,7 +5195,12 @@ async function getPRMergeBlocker( } try { - const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) + const pr = await getPRByNumber( + ownerRepo, + prNumber, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) if (!pr) { return null } diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 73ec657977c..a8c52c46085 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -6,7 +6,7 @@ import { import { gitExecFileAsync } from '../git/runner' import { clearGitCapabilityStateForTests, - getLocalGitCapabilityCache + withLocalGitCapabilityCacheForExecution } from '../git/git-capability-state' import { __resetPRConflictSummaryDerivationCachesForTests, @@ -219,10 +219,6 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: localGitOptions.wslDistro - }) const modernArgs = [ 'merge-tree', '--write-tree', @@ -244,40 +240,44 @@ async function loadConflictingFiles( baseOid ] - return capabilities.runWithFallback( - 'merge-tree-write-tree', - () => + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: localGitOptions.wslDistro }, + (capabilities) => capabilities.runWithFallback( - 'merge-tree-merge-base', + 'merge-tree-write-tree', + () => + capabilities.runWithFallback( + 'merge-tree-merge-base', + async () => { + try { + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return parseMergeTreeNameOnlyOutput(result.stdout) + } catch (error) { + if (isUnsupportedMergeTreeWriteTreeError(error)) { + throw error + } + // Why: `git merge-tree --write-tree` exits 1 for conflicts but still + // writes the useful file list; only option rejection reaches fallback. + const stdoutFromError = getGitErrorOutput(error, 'stdout') + if (stdoutFromError) { + return parseMergeTreeNameOnlyOutput(stdoutFromError) + } + throw error + } + }, + () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), + isUnsupportedMergeTreeMergeBaseError + ), async () => { - try { - const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return parseMergeTreeNameOnlyOutput(result.stdout) - } catch (error) { - if (isUnsupportedMergeTreeWriteTreeError(error)) { - throw error - } - // Why: `git merge-tree --write-tree` exits 1 for conflicts but still - // writes the useful file list; only option rejection reaches fallback. - const stdoutFromError = getGitErrorOutput(error, 'stdout') - if (stdoutFromError) { - return parseMergeTreeNameOnlyOutput(stdoutFromError) - } - throw error - } + // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; + // fail closed without respawning the same rejected command every refresh. + throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') }, - () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), - isUnsupportedMergeTreeMergeBaseError - ), - async () => { - // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; - // fail closed without respawning the same rejected command every refresh. - throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') - }, - isUnsupportedMergeTreeWriteTreeError + isUnsupportedMergeTreeWriteTreeError + ) ) } diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts new file mode 100644 index 00000000000..1b3909af035 --- /dev/null +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -0,0 +1,145 @@ +import type { GitHubPRMergeMethod } from '../../shared/types' +import { ghExecFileAsync } from '../git/runner' +import { acquire, release } from './gh-utils' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' + +const POLL_INTERVAL_MS = 1_000 +const MAX_POLLS = 180 + +type AsyncMergeResponse = { + status?: unknown + details?: { + message?: unknown + uuid?: unknown + } +} + +type AsyncMergeResult = + | { kind: 'pending'; uuid: string } + | { kind: 'success' } + | { kind: 'failure'; message: string } + +export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue' + +function parseResponse(value: string): AsyncMergeResponse | null { + try { + return JSON.parse(value) as AsyncMergeResponse + } catch { + return null + } +} + +function errorResponseBody(error: unknown): AsyncMergeResponse | null { + if (!error || typeof error !== 'object' || !('stdout' in error)) { + return null + } + const stdout = (error as { stdout?: unknown }).stdout + return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout)) +} + +function classifyResult(response: AsyncMergeResponse): AsyncMergeResult { + if (response.status === 'merged' || response.status === 'enqueued') { + return { kind: 'success' } + } + if (response.status === 'pending' && typeof response.details?.uuid === 'string') { + return { kind: 'pending', uuid: response.details.uuid } + } + const message = + typeof response.details?.message === 'string' + ? response.details.message + : 'GitHub could not merge this stack.' + return { kind: 'failure', message } +} + +function waitForNextPoll(): Promise { + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +async function runStackMergeCommand( + command: string[], + options: NonNullable[1]> +) { + await acquire() + try { + return await ghExecFileAsync(command, options) + } finally { + release() + } +} + +export async function mergeGitHubPRStack(args: { + repository: GitHubApiRepository + prNumber: number + method: GitHubPRMergeMethod + mergeAction: GitHubPRStackMergeAction + headSha?: string + ghOptions: GitHubRepoExecOptions +}): Promise<{ ok: true } | { ok: false; error: string }> { + const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async` + const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`] + if (args.mergeAction === 'direct_merge') { + command.push('-f', `merge_method=${args.method}`) + } + if (args.headSha) { + command.push('-f', `sha=${args.headSha}`) + } + + let submitted: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(command, { + ...args.ghOptions, + ...githubHostExecOptions(args.repository), + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + submitted = parseResponse(stdout) + } catch (error) { + submitted = errorResponseBody(error) + if (!submitted) { + throw error + } + } + if (!submitted) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + let result = classifyResult(submitted) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + + const uuid = encodeURIComponent(result.uuid) + for (let poll = 0; poll < MAX_POLLS; poll++) { + await waitForNextPoll() + let response: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], { + ...args.ghOptions, + ...githubHostExecOptions(args.repository) + }) + response = parseResponse(stdout) + } catch (error) { + // Why: the merge was submitted; a transient poll failure cannot make it fail. + response = errorResponseBody(error) + if (!response) { + continue + } + } + if (!response) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + result = classifyResult(response) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + } + return { ok: false, error: 'GitHub is still merging this stack. Refresh to check its status.' } +} diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts new file mode 100644 index 00000000000..17cd6475941 --- /dev/null +++ b/src/main/github/github-pr-stack.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../shared/types' + +const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: rateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRateLimitSpendMock +})) + +import { + _resetGitHubPRStackCacheForTests, + hydrateGitHubPRStack, + mergeGitHubPRStack +} from './github-pr-stack' + +const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' } +const summary: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + baseSha: 'base-sha' +} + +beforeEach(() => { + vi.useRealTimers() + ghExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetGitHubPRStackCacheForTests() +}) + +describe('hydrateGitHubPRStack', () => { + it('maps and caches every entry in one GraphQL request', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + headRefName: 'stack/api', + headRefOid: 'api-sha', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 1, + pullRequest: { + number: 201, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/201', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + mergeable: 'UNKNOWN', + statusCheckRollup: { state: 'PENDING' } + } + } + ] + } + } + } + } + } + }) + }) + + const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }) + const sibling = await hydrateGitHubPRStack( + repository, + 201, + { ...summary, position: 1 }, + { cwd: '/other-worktree' } + ) + + expect(first).toMatchObject({ + number: 51, + position: 2, + baseSha: 'base-sha', + entries: [ + { number: 201, position: 1, checksStatus: 'pending' }, + { + number: 202, + position: 2, + checksStatus: 'success', + reviewDecision: 'APPROVED' + } + ] + }) + expect(sibling.position).toBe(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(noteRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'graphql', + 1, + expect.any(Object) + ) + }) + + it('refreshes cached stack details when the current PR changed', async () => { + const response = (updatedAt: string, isDraft: boolean) => ({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt, + state: 'OPEN', + isDraft, + mergeable: 'MERGEABLE' + } + } + ] + } + } + } + } + } + }) + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true)) + .mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false)) + + const draft = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:00:00Z' + ) + const ready = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:01:00Z' + ) + + expect(draft.entries?.[0]?.state).toBe('draft') + expect(ready.entries?.[0]?.state).toBe('open') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the REST summary when GraphQL is unavailable', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable')) + + await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual( + summary + ) + }) + + it('isolates cached details across execution scopes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } } + } + } + } + }) + }) + + await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host') + await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('mergeGitHubPRStack', () => { + it('submits the expected head and polls pending merges to completion', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + headSha: 'api-sha', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_method=squash', + 'merge_action=direct_merge', + 'sha=api-sha' + ]), + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'], + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + }) + + it('returns GitHub atomic failure details', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + status: 'failed', + details: { message: 'A pull request has merge conflicts.' } + }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'rebase', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' }) + }) + + it('omits the unsupported merge method when queueing a stack', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'merge_queue', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: true }) + + const command = ghExecFileAsyncMock.mock.calls[0]?.[0] + expect(command).toContain('merge_action=merge_queue') + expect(command).not.toContain('merge_method=squash') + }) + + it('continues polling after a transient transport failure', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockRejectedValueOnce(new Error('temporary gateway failure')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('reports an in-progress merge after polling is exhausted', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(180_000) + + await expect(result).resolves.toEqual({ + ok: false, + error: 'GitHub is still merging this stack. Refresh to check its status.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181) + }) +}) diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts new file mode 100644 index 00000000000..24d0773a91c --- /dev/null +++ b/src/main/github/github-pr-stack.ts @@ -0,0 +1,282 @@ +import type { + CheckStatus, + GitHubPRStack, + GitHubPRStackEntry, + PRMergeableState, + PRReviewDecision, + PRState +} from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { ghExecFileAsync } from '../git/runner' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const STACK_CACHE_TTL_MS = 30_000 +const STACK_CACHE_MAX_ENTRIES = 256 + +type CachedStackDetails = { + value: Omit | null + expiresAt: number +} + +type GraphQLStackEntry = { + position?: unknown + pullRequest?: { + number?: unknown + title?: unknown + url?: unknown + updatedAt?: unknown + state?: unknown + isDraft?: unknown + headRefName?: unknown + headRefOid?: unknown + mergeable?: unknown + reviewDecision?: unknown + mergeStateStatus?: unknown + statusCheckRollup?: { state?: unknown } | null + } | null +} + +type GraphQLStackResponse = { + data?: { + repository?: { + pullRequest?: { + stack?: { + number?: unknown + size?: unknown + baseRefName?: unknown + entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null + } | null + } | null + } | null + } +} + +const stackDetailsCache = new Map() +const stackDetailsInFlight = new Map | null>>() + +export function _resetGitHubPRStackCacheForTests(): void { + stackDetailsCache.clear() + stackDetailsInFlight.clear() +} + +function stackCacheKey( + repository: GitHubApiRepository, + stackNumber: number, + executionScope: string +): string { + return `${executionScope}\0${githubRepoIdentityKey(repository)}:${stackNumber}` +} + +function pruneStackCache(now = Date.now()): void { + for (const [key, cached] of stackDetailsCache) { + if (cached.expiresAt <= now) { + stackDetailsCache.delete(key) + } + } + while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) { + const oldestKey = stackDetailsCache.keys().next().value + if (oldestKey === undefined) { + return + } + stackDetailsCache.delete(oldestKey) + } +} + +function mapStackPRState(value: unknown, isDraft: unknown): PRState { + if (value === 'MERGED') { + return 'merged' + } + if (value === 'CLOSED') { + return 'closed' + } + return isDraft === true ? 'draft' : 'open' +} + +function mapStackCheckStatus(value: unknown): CheckStatus { + if (value === 'SUCCESS') { + return 'success' + } + if (value === 'FAILURE' || value === 'ERROR') { + return 'failure' + } + if (value === 'PENDING' || value === 'EXPECTED') { + return 'pending' + } + return 'neutral' +} + +function mapStackMergeable(value: unknown): PRMergeableState { + return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN' +} + +function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined { + if (value === null) { + return null + } + if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') { + return value + } + return undefined +} + +function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null { + const pr = entry.pullRequest + if ( + typeof entry.position !== 'number' || + typeof pr?.number !== 'number' || + typeof pr.title !== 'string' || + typeof pr.url !== 'string' + ) { + return null + } + const reviewDecision = mapReviewDecision(pr.reviewDecision) + return { + position: entry.position, + number: pr.number, + title: pr.title, + url: pr.url, + ...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}), + state: mapStackPRState(pr.state, pr.isDraft), + checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state), + mergeable: mapStackMergeable(pr.mergeable), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}), + ...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}), + ...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {}) + } +} + +function parseStackDetails( + response: GraphQLStackResponse, + expectedStackNumber: number, + fallbackBaseRefName: string, + fallbackSize: number +): Omit | null { + const stack = response.data?.repository?.pullRequest?.stack + if (!stack || stack.number !== expectedStackNumber) { + return null + } + const entries = (stack.entries?.nodes ?? []) + .flatMap((entry) => (entry ? [mapStackEntry(entry)] : [])) + .filter((entry): entry is GitHubPRStackEntry => entry !== null) + .sort((a, b) => a.position - b.position) + return { + number: expectedStackNumber, + size: typeof stack.size === 'number' ? stack.size : fallbackSize, + baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName, + ...(entries.length > 0 ? { entries } : {}) + } +} + +const STACK_DETAILS_QUERY = ` +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + stack { + number + size + baseRefName + entries(first: 100) { + nodes { + position + pullRequest { + number + title + url + updatedAt + state + isDraft + headRefName + headRefOid + mergeable + reviewDecision + mergeStateStatus + statusCheckRollup { state } + } + } + } + } + } + } +}` + +async function fetchStackDetails( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions +): Promise | null> { + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${STACK_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `pr=${prNumber}` + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + return parseStackDetails( + JSON.parse(stdout) as GraphQLStackResponse, + summary.number, + summary.baseRefName, + summary.size + ) +} + +export async function hydrateGitHubPRStack( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions, + prUpdatedAt?: string, + executionScope = 'local:host' +): Promise { + const key = stackCacheKey(repository, summary.number, executionScope) + const now = Date.now() + pruneStackCache(now) + const cached = stackDetailsCache.get(key) + const cachedPRUpdatedAt = cached?.value?.entries?.find( + (entry) => entry.number === prNumber + )?.updatedAt + const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt + if (cached && cached.expiresAt > now && cachedMatchesPR) { + return cached.value + ? { ...cached.value, position: summary.position, baseSha: summary.baseSha } + : summary + } + const existing = stackDetailsInFlight.get(key) + if (existing) { + const value = await existing + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } + const request = fetchStackDetails(repository, prNumber, summary, ghOptions).catch(() => null) + stackDetailsInFlight.set(key, request) + try { + const value = await request + stackDetailsCache.delete(key) + stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS }) + pruneStackCache() + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } finally { + if (stackDetailsInFlight.get(key) === request) { + stackDetailsInFlight.delete(key) + } + } +} + +export { mergeGitHubPRStack } from './github-pr-stack-async-merge' diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 51a6150eab6..1b6e8bba165 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -182,6 +183,10 @@ function buildInstalledConfig( } export class GrokHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/ipc/claude-usage.ts b/src/main/ipc/claude-usage.ts deleted file mode 100644 index 3496706e8e3..00000000000 --- a/src/main/ipc/claude-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { ClaudeUsageStore } from '../claude-usage/store' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageRange, - ClaudeUsageScope -} from '../../shared/claude-usage-types' - -export function registerClaudeUsageHandlers(claudeUsage: ClaudeUsageStore): void { - ipcMain.handle('claudeUsage:getScanState', () => claudeUsage.getScanState()) - ipcMain.handle('claudeUsage:setEnabled', (_event, args: { enabled: boolean }) => - claudeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('claudeUsage:refresh', (_event, args?: { force?: boolean }) => - claudeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'claudeUsage:getSnapshot', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'claudeUsage:getSummary', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getDaily', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getBreakdown', - ( - _event, - args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; kind: ClaudeUsageBreakdownKind } - ) => claudeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'claudeUsage:getRecentSessions', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/codex-usage.ts b/src/main/ipc/codex-usage.ts deleted file mode 100644 index 3db15a92076..00000000000 --- a/src/main/ipc/codex-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { CodexUsageStore } from '../codex-usage/store' -import type { - CodexUsageBreakdownKind, - CodexUsageRange, - CodexUsageScope -} from '../../shared/codex-usage-types' - -export function registerCodexUsageHandlers(codexUsage: CodexUsageStore): void { - ipcMain.handle('codexUsage:getScanState', () => codexUsage.getScanState()) - ipcMain.handle('codexUsage:setEnabled', (_event, args: { enabled: boolean }) => - codexUsage.setEnabled(args.enabled) - ) - ipcMain.handle('codexUsage:refresh', (_event, args?: { force?: boolean }) => - codexUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'codexUsage:getSnapshot', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'codexUsage:getSummary', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getDaily', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getBreakdown', - ( - _event, - args: { scope: CodexUsageScope; range: CodexUsageRange; kind: CodexUsageBreakdownKind } - ) => codexUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'codexUsage:getRecentSessions', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 0cf672fa30f..69dde7afaf3 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -1,17 +1,19 @@ -import { execFile as execFileCallback } from 'node:child_process' +import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' -const { checkRgAvailableMock } = vi.hoisted(() => ({ - checkRgAvailableMock: vi.fn() +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock })) import { listQuickOpenFiles } from './filesystem-list-files' @@ -50,6 +52,16 @@ async function initRepo(repoPath: string): Promise { describe('filesystem-list-files real git fallback', () => { let tempDir: string | null = null + beforeEach(() => { + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { + cwd: options.cwd, + stdio: options.stdio + }) + ) + }) + afterEach(async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }) @@ -59,7 +71,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('returns real paths for UTF-8 filenames from the git fallback', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) const repoPath = join(tempDir, 'repo') await execFile('git', ['init', '-q', repoPath]) @@ -71,7 +82,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) const repoPath = join(tempDir, 'parent') const appPath = join(repoPath, 'packages', 'app') @@ -117,7 +127,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('walks a non-git root instead of returning an empty git fallback result', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) await writeRel(tempDir, 'folder/file.ts') @@ -127,7 +136,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('bounds a non-git readdir fallback without treating the limit as an error', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) await writeRel(tempDir, 'a.ts') await writeRel(tempDir, 'b.ts') @@ -139,7 +147,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) @@ -151,7 +158,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('resolves an empty repo as an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index ff390d13caf..78e03f56065 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,5 +1,9 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' import { buildGitLsFilesArgsForQuickOpen, shouldExcludeQuickOpenRelPath, @@ -25,6 +29,19 @@ async function isInsideGitWorkTree( localGitOptions: { wslDistro?: string }, signal?: AbortSignal ): Promise { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + try { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) + } catch (error) { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + throw error + } + } if (signal?.aborted) { throw fileListingCancellationError(signal) } diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts index 7f4c34f5eda..f0221d8e24e 100644 --- a/src/main/ipc/filesystem-list-files-install-rg.test.ts +++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts @@ -1,16 +1,21 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' const { listFilesWithGitMock, resolveAuthorizedPathMock, checkRgAvailableMock, - getLocalGitOptionsForRegisteredWorktreeMock + getLocalGitOptionsForRegisteredWorktreeMock, + wslAwareSpawnMock } = vi.hoisted(() => ({ listFilesWithGitMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), - getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + wslAwareSpawnMock: vi.fn() })) vi.mock('./filesystem-list-files-git-fallback', () => ({ @@ -29,14 +34,39 @@ vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + import { listQuickOpenFiles } from './filesystem-list-files' +function createStartedRipgrepProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess + ;(child as unknown as Record).stdout = new EventEmitter() + ;( + (child as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(child as unknown as Record).stderr = new EventEmitter() + ;(child as unknown as Record).kill = vi.fn() + ;(child as unknown as Record).exitCode = null + ;(child as unknown as Record).signalCode = null + Object.defineProperty(child, 'pid', { value: 1 }) + return child +} + describe('filesystem-list-files ripgrep guidance', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(false) + checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { stdio: options.stdio }) + ) }) it('turns only a readdir budget failure into install guidance', async () => { @@ -51,6 +81,31 @@ describe('filesystem-list-files ripgrep guidance', () => { ) }) + it('keeps the WSL preflight and falls back before starting real rg', async () => { + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts']) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const primary = createStartedRipgrepProcess() + const ignored = createStartedRipgrepProcess() + let callIndex = 0 + wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + const listing = listQuickOpenFiles('/workspace', {} as Store) + await Promise.resolve() + primary.emit('close', 127, null) + + await expect(listing).resolves.toEqual(['src/index.ts']) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + it('keeps cancellation and Git errors unchanged', async () => { const cancellation = new Error('File listing cancelled') listFilesWithGitMock.mockRejectedValueOnce(cancellation) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 946f8653dd6..7debd516760 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,6 +36,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' +import { FileListingCancelledError } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -55,10 +56,24 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } +function createMissingRipgrepProcess(): ChildProcess { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + void Promise.resolve().then(() => child.emit('close', -2, null)) + return child +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 12; index++) { + await Promise.resolve() + } +} + function isIgnoredRgPass(args: string[]): boolean { return args.includes('--no-ignore-vcs') } @@ -93,6 +108,8 @@ describe('filesystem-list-files', () => { expect(p1.kill).toHaveBeenCalled() expect(p2.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) it('merges normal files and ignored files and filters correctly', async () => { @@ -108,6 +125,8 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) // Simulate stdout output for normal files setTimeout(() => { @@ -192,6 +211,24 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + Object.defineProperty(p1, 'pid', { value: 1 }) + Object.defineProperty(p2, 'pid', { value: 2 }) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + + const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) + setTimeout(() => { + p1.emit('close', 127, null) + p2.emit('close', 0, null) + }, 0) + + await expect(promise).rejects.toThrow('rg exited with code 127') + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -330,16 +367,90 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['valid.ts']) }) - describe('git ls-files fallback', () => { - it('falls back to git ls-files when rg is not available', async () => { - checkRgAvailableMock.mockResolvedValue(false) + it('lets cancellation win a native-unavailable race before Git starts', async () => { + const first = createMockProcess() + Object.defineProperty(first, 'pid', { value: undefined }) + spawnMock.mockReturnValue(first) + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await flushMicrotasks() + controller.abort(cancellation) + first.emit('close', -2, null) + + await expect(promise).rejects.toBe(cancellation) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => first.emit('error', error)).not.toThrow() + expect(first.listenerCount('error')).toBe(0) + }) + + describe('git ls-files fallback', () => { + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const primary = createMockProcess() + const missingIgnored = createMockProcess() + const revParse = createMockProcess() + const gitPrimary = createMockProcess() + const gitIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + let gitPassIndex = 0 + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return isIgnoredRgPass(args) ? missingIgnored : primary + } + if (args.includes('rev-parse')) { + return revParse + } + if (args.includes('ls-files')) { + return gitPassIndex++ === 0 ? gitPrimary : gitIgnored + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + await flushMicrotasks() + expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2) + missingIgnored.emit('close', -2, null) + + await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled()) + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + await vi.waitFor(() => + expect( + spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse')) + ).toBe(true) + ) + revParse.emit('close', 0, null) + await vi.waitFor(() => + expect( + spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files')) + ).toHaveLength(2) + ) + gitPrimary.emit('close', 0, null) + gitIgnored.emit('close', 0, null) + + await expect(promise).resolves.toEqual([]) + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('falls back to git ls-files when rg is not available', async () => { let callIndex = 0 const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -378,9 +489,10 @@ describe('filesystem-list-files', () => { const result = await promise - // Verify rg was never called + // The primary real command doubles as the availability check. const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.length).toBe(0) + expect(rgCalls.length).toBe(1) + expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true) // Verify git ls-files was called const gitCalls = spawnMock.mock.calls.filter( @@ -402,13 +514,15 @@ describe('filesystem-list-files', () => { }) it('stops after primary Git files fill the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -439,10 +553,12 @@ describe('filesystem-list-files', () => { }) it('does not let a discarded Git directory placeholder consume the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const primary = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -469,14 +585,15 @@ describe('filesystem-list-files', () => { }) it('git fallback applies hidden dir blocklist', async () => { - checkRgAvailableMock.mockResolvedValue(false) - const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -518,7 +635,6 @@ describe('filesystem-list-files', () => { }) it('settles and detaches git fallback scans that ignore timeout kills', async () => { - checkRgAvailableMock.mockResolvedValue(false) vi.useFakeTimers() try { @@ -528,6 +644,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -541,13 +660,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') @@ -567,7 +682,6 @@ describe('filesystem-list-files', () => { }) it('keeps primary results when only the ignored pass times out', async () => { - checkRgAvailableMock.mockResolvedValue(false) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.useFakeTimers() @@ -578,6 +692,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -591,13 +708,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit( 'data', diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 7edb688de72..c1666234cd0 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -17,6 +17,12 @@ import { import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' import { listFilesWithGit } from './filesystem-list-files-git-fallback' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' export async function listQuickOpenFiles( rootPath: string, @@ -37,13 +43,9 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - // Why: checking rg availability upfront avoids a race condition where - // spawn('rg') emits 'close' before 'error' on some platforms, causing - // the handler to resolve with empty results before the git fallback - // can run. - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const listWithoutRipgrep = async (): Promise => { try { return await listFilesWithGit( authorizedRootPath, @@ -59,6 +61,12 @@ export async function listQuickOpenFiles( throw new Error(await buildInstallRgMessage(err)) } } + if ( + wslDistroForOutput && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return listWithoutRipgrep() + } const files = new Set() const children: { @@ -68,8 +76,6 @@ export async function listQuickOpenFiles( }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. - const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` @@ -86,6 +92,8 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false const processLine = (rawLine: string): boolean => { const translated = @@ -138,12 +146,27 @@ export async function listQuickOpenFiles( /* drain */ } const handleError = (): void => { + processErrorObserved = true // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' + if (isRipgrepUnavailableExit(child, null, null)) { + finish(new RipgrepUnavailableError()) + return + } finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + buf = '' + finish(new RipgrepUnavailableError()) + return + } if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -179,6 +202,10 @@ export async function listQuickOpenFiles( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) if (err) { reject(err) } else { @@ -197,7 +224,7 @@ export async function listQuickOpenFiles( // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = '' - child.kill() + killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) }, 10000) }) @@ -213,7 +240,7 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } @@ -225,24 +252,30 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } - try { + const primaryRun = runRg(primary) if (maxResults === undefined) { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. + await (children[0]?.child.pid === undefined + ? primaryRun + : Promise.all([primaryRun, runRg(ignoredPass)])) } else { // Why: ignored-file output can be much larger and faster than the primary // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) + await primaryRun if (files.size < maxResults) { await runRg(ignoredPass) } } } catch (err) { killSurvivors() + if (err instanceof RipgrepUnavailableError) { + return listWithoutRipgrep() + } throw err } return Array.from(files).slice(0, maxResults) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 065dbd11dad..9b0c91a6c65 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -8,6 +8,10 @@ import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' import { gitSpawn } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' /** * Fallback text search using git grep. Used when rg is not available. @@ -16,12 +20,15 @@ import { gitSpawn } from '../git/runner' * is launched from a desktop entry (which inherits a minimal system PATH). * git grep is always available since this is a git-focused app. */ -export function searchWithGitGrep( +export async function searchWithGitGrep( rootPath: string, args: SearchOptions, maxResults: number, localGitOptions: { wslDistro?: string } = {} ): Promise { + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro) + } return new Promise((resolve) => { const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 8d9881b2261..9e0a259d2d3 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -7,6 +7,7 @@ const { resolveAuthorizedPathMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, toWindowsWslPathMock } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), toWindowsWslPathMock: vi.fn((value: string) => value) })) @@ -56,7 +58,7 @@ vi.mock('./filesystem-mutations', () => ({ })) vi.mock('./filesystem-search-git', () => ({ - searchWithGitGrep: vi.fn() + searchWithGitGrep: searchWithGitGrepMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -87,6 +89,12 @@ function createMockProcess(): ChildProcess { return p } +async function flushMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('filesystem rg search timeout', () => { beforeEach(() => { handlers.clear() @@ -120,6 +128,7 @@ describe('filesystem rg search timeout', () => { const result = await promise expect(result.truncated).toBe(true) + expect(checkRgAvailableMock).not.toHaveBeenCalled() expect(child.kill).toHaveBeenCalled() expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) @@ -130,8 +139,77 @@ describe('filesystem rg search timeout', () => { } }) + it.each(['error-first', 'close-first'] as const)( + 'falls back once when a native launch failure is %s', + async (order) => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it('keeps post-spawn errors on the existing empty-result path', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise<{ files: unknown[] }> + await flushMicrotasks() + child.emit('error', new Error('post-spawn failure')) + + await expect(promise).resolves.toMatchObject({ files: [] }) + expect(searchWithGitGrepMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + child.emit('close', 127, null) + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) registerFilesystemHandlers({} as never) @@ -142,12 +220,13 @@ describe('filesystem rg search timeout', () => { ) as Promise setTimeout(() => { - child.emit('close') + child.emit('close', 127, null) }, 10) await promise expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -158,6 +237,23 @@ describe('filesystem rg search timeout', () => { ) }) + it('keeps the WSL search preflight and falls back before starting real rg', async () => { + const fallback = { files: [], totalMatches: 0, truncated: false } + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'ok' } + ) as Promise + + await expect(promise).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 4ca9547b77c..d5fb98086f8 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -112,6 +112,11 @@ import { } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { getSshFilesystemProvider, requireSshFilesystemProvider @@ -980,32 +985,35 @@ export function registerFilesystemHandlers( Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - // Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs. - const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - activeTextSearches.get(searchKey)?.kill() + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null let killTimeout: ReturnType - // Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro const transformAbsPath = wslDistroForOutput ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1019,13 +1027,22 @@ export function registerFilesystemHandlers( child?.stderr?.off('data', handleStderrData) child?.off('error', handleError) child?.off('close', handleClose) - resolvePromise(finalize(acc)) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1049,9 +1066,24 @@ export function registerFilesystemHandlers( // Drain stderr so rg cannot block on a full pipe. } const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } resolveOnce() } - const handleClose = (): void => { + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -1067,7 +1099,9 @@ export function registerFilesystemHandlers( // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts deleted file mode 100644 index 47b3c95dea7..00000000000 --- a/src/main/ipc/opencode-usage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ipcMain } from 'electron' -import type { OpenCodeUsageStore } from '../opencode-usage/store' -import type { - OpenCodeUsageBreakdownKind, - OpenCodeUsageRange, - OpenCodeUsageScope -} from '../../shared/opencode-usage-types' - -export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { - ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) - ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => - openCodeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => - openCodeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'openCodeUsage:getSnapshot', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'openCodeUsage:getSummary', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getDaily', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getBreakdown', - ( - _event, - args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - } - ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'openCodeUsage:getRecentSessions', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 6fa35a6171b..03453c3aeeb 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5682,6 +5682,7 @@ export function registerPtyHandlers( args: { id?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 55043e2b526..b9aa768213b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -6,9 +6,7 @@ const { callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, - registerClaudeUsageHandlersMock, - registerCodexUsageHandlersMock, - registerOpenCodeUsageHandlersMock, + registerUsageProviderHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -72,9 +70,7 @@ const { callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), - registerClaudeUsageHandlersMock: vi.fn(), - registerCodexUsageHandlersMock: vi.fn(), - registerOpenCodeUsageHandlersMock: vi.fn(), + registerUsageProviderHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -180,16 +176,8 @@ vi.mock('./preflight', () => ({ registerPreflightHandlers: registerPreflightHandlersMock })) -vi.mock('./claude-usage', () => ({ - registerClaudeUsageHandlers: registerClaudeUsageHandlersMock -})) - -vi.mock('./codex-usage', () => ({ - registerCodexUsageHandlers: registerCodexUsageHandlersMock -})) - -vi.mock('./opencode-usage', () => ({ - registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +vi.mock('./usage-provider-handlers', () => ({ + registerUsageProviderHandlers: registerUsageProviderHandlersMock })) vi.mock('./github', () => ({ @@ -395,9 +383,7 @@ describe('registerCoreHandlers', () => { callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() - registerClaudeUsageHandlersMock.mockReset() - registerCodexUsageHandlersMock.mockReset() - registerOpenCodeUsageHandlersMock.mockReset() + registerUsageProviderHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -494,9 +480,11 @@ describe('registerCoreHandlers', () => { result: { sessions: 'bad-shape' } }) - expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) - expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) - expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) + expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({ + claudeUsage, + codexUsage, + openCodeUsage + }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith( codexAccounts, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 607aa08ce3e..f372cb3f772 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -8,9 +8,7 @@ import type { StatsCollector } from '../stats/collector' import { registerFilesystemHandlers } from './filesystem' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' -import { registerClaudeUsageHandlers } from './claude-usage' -import { registerCodexUsageHandlers } from './codex-usage' -import { registerOpenCodeUsageHandlers } from './opencode-usage' +import { registerUsageProviderHandlers } from './usage-provider-handlers' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' @@ -143,9 +141,7 @@ export function registerCoreHandlers( registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() - registerClaudeUsageHandlers(claudeUsage) - registerCodexUsageHandlers(codexUsage) - registerOpenCodeUsageHandlers(openCodeUsage) + registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) diff --git a/src/main/ipc/usage-provider-handlers.test.ts b/src/main/ipc/usage-provider-handlers.test.ts new file mode 100644 index 00000000000..54733b87c26 --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handle } = vi.hoisted(() => ({ handle: vi.fn() })) + +vi.mock('electron', () => ({ ipcMain: { handle } })) + +import { registerUsageProviderHandlers } from './usage-provider-handlers' + +describe('usage provider IPC handlers', () => { + it('registers every provider route and forwards query arguments', () => { + const createUsage = () => ({ + getScanState: vi.fn(), + setEnabled: vi.fn(), + refresh: vi.fn(), + getSnapshot: vi.fn(), + getSummary: vi.fn(), + getDaily: vi.fn(), + getBreakdown: vi.fn(), + getRecentSessions: vi.fn() + }) + const claudeUsage = createUsage() + const codexUsage = createUsage() + const openCodeUsage = createUsage() + registerUsageProviderHandlers({ + claudeUsage: claudeUsage as never, + codexUsage: codexUsage as never, + openCodeUsage: openCodeUsage as never + }) + + const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage'] + const suffixes = Object.keys(claudeUsage) + expect(handle.mock.calls.map(([channel]) => channel)).toEqual( + prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`)) + ) + + const call = (prefix: string, suffix: string, args?: unknown): unknown => { + const handler = handle.mock.calls.find( + ([channel]) => channel === `${prefix}:${suffix}` + )?.[1] as ((event: unknown, args?: unknown) => unknown) | undefined + return handler?.({}, args) + } + call('claudeUsage', 'getScanState') + call('codexUsage', 'getScanState') + call('openCodeUsage', 'getScanState') + call('claudeUsage', 'setEnabled', { enabled: true }) + call('claudeUsage', 'refresh') + call('claudeUsage', 'refresh', { force: true }) + call('claudeUsage', 'getSnapshot', { scope: 'orca', range: '30d', limit: 7 }) + call('claudeUsage', 'getSummary', { scope: 'all', range: '7d' }) + call('claudeUsage', 'getDaily', { scope: 'orca', range: '90d' }) + call('claudeUsage', 'getBreakdown', { scope: 'all', range: 'all', kind: 'model' }) + call('claudeUsage', 'getRecentSessions', { scope: 'orca', range: '30d', limit: 4 }) + + expect(claudeUsage.getScanState).toHaveBeenCalledWith() + expect(codexUsage.getScanState).toHaveBeenCalledWith() + expect(openCodeUsage.getScanState).toHaveBeenCalledWith() + expect(claudeUsage.setEnabled).toHaveBeenCalledWith(true) + expect(claudeUsage.refresh.mock.calls).toEqual([[false], [true]]) + expect(claudeUsage.getSnapshot).toHaveBeenCalledWith('orca', '30d', 7) + expect(claudeUsage.getSummary).toHaveBeenCalledWith('all', '7d') + expect(claudeUsage.getDaily).toHaveBeenCalledWith('orca', '90d') + expect(claudeUsage.getBreakdown).toHaveBeenCalledWith('all', 'all', 'model') + expect(claudeUsage.getRecentSessions).toHaveBeenCalledWith('orca', '30d', 4) + }) +}) diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts new file mode 100644 index 00000000000..58496f1c8ad --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.ts @@ -0,0 +1,68 @@ +import { ipcMain } from 'electron' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' + +type UsageProviderStores = { + claudeUsage: ClaudeUsageStore + codexUsage: CodexUsageStore + openCodeUsage: OpenCodeUsageStore +} + +type UsageProviderChannelPrefix = keyof UsageProviderStores + +type UsageProviderHandlerStore = { + getScanState: () => unknown + setEnabled: (enabled: boolean) => unknown + refresh: (force?: boolean) => unknown + getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown + getSummary: (scope: Scope, range: Range) => unknown + getDaily: (scope: Scope, range: Range) => unknown + getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown + getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown +} + +type UsageRangeArgs = { + scope: Scope + range: Range +} + +function registerProviderHandlers( + prefix: UsageProviderChannelPrefix, + usage: UsageProviderHandlerStore +): void { + ipcMain.handle(`${prefix}:getScanState`, () => usage.getScanState()) + ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) => + usage.setEnabled(args.enabled) + ) + ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) => + usage.refresh(args?.force ?? false) + ) + ipcMain.handle( + `${prefix}:getSnapshot`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getSnapshot(args.scope, args.range, args.limit) + ) + ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) => + usage.getSummary(args.scope, args.range) + ) + ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) => + usage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + `${prefix}:getBreakdown`, + (_event, args: UsageRangeArgs & { kind: BreakdownKind }) => + usage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + `${prefix}:getRecentSessions`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getRecentSessions(args.scope, args.range, args.limit) + ) +} + +export function registerUsageProviderHandlers(stores: UsageProviderStores): void { + registerProviderHandlers('claudeUsage', stores.claudeUsage) + registerProviderHandlers('codexUsage', stores.codexUsage) + registerProviderHandlers('openCodeUsage', stores.openCodeUsage) +} diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 88caac3c938..cc0b4b0452f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -142,6 +142,7 @@ import { registerSshProviderRequestAbort } from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -522,15 +523,18 @@ type WorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget } -const preservedBranchCleanupByWorktreeId = new Map() +const preservedBranchCleanupByScope = new Map() function rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -542,14 +546,16 @@ function rememberPreservedBranchCleanupTarget( `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - preservedBranchCleanupByWorktreeId.set(worktreeId, { + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, branchName: result.preservedBranch.branchName, head, ...(pushTarget ? { pushTarget } : {}) }) return } - preservedBranchCleanupByWorktreeId.delete(worktreeId) + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) } function preserveBranchHeadFallback( @@ -571,9 +577,21 @@ function preserveBranchHeadFallback( function getPreservedBranchCleanupTarget( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: ExecutionHostId ): PreservedBranchCleanupTarget { - const target = preservedBranchCleanupByWorktreeId.get(worktreeId) + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if (!target || target.branchName !== branchName || target.head !== expectedHead) { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } @@ -2400,7 +2418,9 @@ export function registerWorktreeHandlers( await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2509,7 +2529,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2554,7 +2579,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2586,7 +2616,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2634,6 +2669,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2731,6 +2767,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2884,7 +2921,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true @@ -2908,6 +2950,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -3011,7 +3054,17 @@ export function registerWorktreeHandlers( removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } notifyWorktreesChanged(mainWindow, repoId) return {} })() @@ -3030,15 +3083,21 @@ export function registerWorktreeHandlers( 'worktrees:forceDeletePreservedBranch', async ( _event, - args: { worktreeId: string; branchName: string; expectedHead: string } + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const cleanupTarget = getPreservedBranchCleanupTarget( args.worktreeId, args.branchName, - args.expectedHead + args.expectedHead, + args.hostId ) - const repo = store.getRepo(repoId) + const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -3081,7 +3140,12 @@ export function registerWorktreeHandlers( ) } - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } ) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..8036330272a 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -18,6 +18,7 @@ import { wrapPosixHookCommand, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeManagedScriptRemote, @@ -155,6 +156,10 @@ function buildStatus(present: Set, configPath: string): AgentHookInstall } export class KimiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const text = readConfigToml(configPath) diff --git a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts index cf3eaf47e7f..d211805337f 100644 --- a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts +++ b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts @@ -1,5 +1,5 @@ import type * as NodeFsPromisesModule from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const fsMocks = vi.hoisted(() => ({ open: vi.fn(), @@ -15,19 +15,33 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { readNativeChatTranscriptTail } from './transcript-tail-reader' describe('native chat transcript tail cancellation', () => { - it('rejects after pending tail I/O closes when the request is canceled', async () => { - let finishRead: (() => void) | undefined + beforeEach(() => { + fsMocks.open.mockReset() + fsMocks.stat.mockReset() + }) + + it('closes after a pending tail read without reading another chunk when canceled', async () => { + let finishTailRead: (() => void) | undefined + let readCount = 0 const close = vi.fn(async () => {}) - const read = vi.fn( - (buffer: Buffer) => - new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { - finishRead = () => { - buffer[0] = 0x0a - resolve({ bytesRead: 1, buffer }) - } - }) - ) - fsMocks.stat.mockResolvedValue({ size: 1 }) + const read = vi.fn((buffer: Buffer) => { + readCount++ + if (readCount === 1) { + buffer[0] = 0x0a + return Promise.resolve({ bytesRead: 1, buffer }) + } + if (readCount > 2) { + buffer.fill(0x0a) + return Promise.resolve({ bytesRead: buffer.length, buffer }) + } + return new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { + finishTailRead = () => { + buffer.fill(0x0a) + resolve({ bytesRead: buffer.length, buffer }) + } + }) + }) + fsMocks.stat.mockResolvedValue({ size: 8 * 64 * 1024 + 1 }) fsMocks.open.mockResolvedValue({ close, read }) const controller = new AbortController() const canceled = new Error('request canceled') @@ -41,12 +55,46 @@ describe('native chat transcript tail cancellation', () => { controller.signal ) const rejection = expect(pending).rejects.toBe(canceled) - await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) controller.abort(canceled) - finishRead?.() + finishTailRead?.() await rejection + expect(read).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a handle opened after cancellation without starting a read', async () => { + let finishOpen: (() => void) | undefined + const close = vi.fn(async () => {}) + const read = vi.fn() + fsMocks.stat.mockResolvedValue({ size: 1 }) + fsMocks.open.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = () => resolve({ close, read }) + }) + ) + const controller = new AbortController() + const canceled = new Error('request canceled while opening') + const pending = readNativeChatTranscriptTail( + { + agent: 'claude', + sessionId: 'session-id', + filePath: 'transcript.jsonl', + limit: 40 + }, + controller.signal + ) + const rejection = expect(pending).rejects.toBe(canceled) + await vi.waitFor(() => expect(fsMocks.open).toHaveBeenCalledOnce()) + + controller.abort(canceled) + finishOpen?.() + + await rejection + expect(read).not.toHaveBeenCalled() expect(close).toHaveBeenCalledOnce() }) }) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index f5cbed8efdd..c7b1527858a 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -46,7 +46,8 @@ export async function readNativeChatTranscriptTailFile( decode: NativeChatLineDecoder, includeTrailingLine = false, endOffset?: number, - decodeLifecycle?: NativeChatTurnLifecycleDecoder | null + decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, + signal?: AbortSignal ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -56,7 +57,9 @@ export async function readNativeChatTranscriptTailFile( malformedRecordCount?: number oversizedRecordCount?: number }> { + signal?.throwIfAborted() const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } @@ -69,19 +72,25 @@ export async function readNativeChatTranscriptTailFile( let oversizedRecordCount = 0 let ignoreNextMalformedRecord = false try { - const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + signal?.throwIfAborted() + const consumedTo = includeTrailingLine + ? end + : await findLastCompleteLineEnd(handle, end, signal) if (consumedTo === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) + signal?.throwIfAborted() ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { @@ -175,18 +184,23 @@ export async function readNativeChatTranscriptTailFile( async function findLastCompleteLineEnd( handle: Awaited>, - end: number + end: number, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const lastByte = Buffer.allocUnsafe(1) await handle.read(lastByte, 0, 1, end - 1) + signal?.throwIfAborted() if (lastByte[0] === 0x0a) { return end } let cursor = end while (cursor > 0) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) if (newline >= 0) { return start + newline + 1 @@ -235,7 +249,8 @@ export async function readNativeChatTranscriptTail( decode, true, args.beforeOffset, - decodeLifecycle + decodeLifecycle, + signal ) signal?.throwIfAborted() return { diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 6f3759efa7f..d565327fd18 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -1,24 +1,15 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenCodeUsageDailyAggregate, - OpenCodeUsagePersistedDatabase, OpenCodeUsagePersistedState, OpenCodeUsageSession } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -27,29 +18,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) @@ -57,24 +25,7 @@ vi.mock('./scanner', () => ({ import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' import { scanOpenCodeUsageDatabases } from './scanner' -type ScanResult = { - processedDatabases: OpenCodeUsagePersistedDatabase[] - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedDatabases: [], sessions: [], @@ -101,9 +52,8 @@ function getDefaultState(): OpenCodeUsagePersistedState { function createStoreWithState(state: Partial): OpenCodeUsageStore { const store = new OpenCodeUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { ...getDefaultState(), @@ -212,11 +162,6 @@ describe('OpenCodeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-')) getPathMock.mockReturnValue(tempUserData) initOpenCodeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanOpenCodeUsageDatabases).mockReset() vi.mocked(scanOpenCodeUsageDatabases).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -228,7 +173,7 @@ describe('OpenCodeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one full-cache write', async () => { + it('adapts OpenCode scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ scanState: { enabled: true, @@ -240,89 +185,9 @@ describe('OpenCodeUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole multi-MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8') + expect(scanOpenCodeUsageDatabases).toHaveBeenCalledWith([], []) expect(persistedJson).toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanOpenCodeUsageDatabases).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: OpenCodeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-opencode-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index c85fa519ba5..b87357f9b0a 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,27 +1,23 @@ -/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, - OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -70,47 +66,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function getRangeCutoff(range: OpenCodeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null @@ -148,62 +103,22 @@ function normalizeSessionCost( } } -export class OpenCodeUsageStore { - private state: OpenCodeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the multi-MB usage JSON must not block the Electron main thread; the writer serializes - // writes and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[opencode-usage]', getOpenCodeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): OpenCodeUsagePersistedState { - try { - const usageFile = getOpenCodeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): OpenCodeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< + 'processedDatabases', + OpenCodeUsagePersistedState, + 'hasAnyOpenCodeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[opencode-usage]', + resolveCacheFile: getOpenCodeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedDatabases', + dataPresenceKey: 'hasAnyOpenCodeData', + jsonIndent: 2, + scan: openCodeUsageProvider.scan + }) } getSnapshot( @@ -221,63 +136,6 @@ export class OpenCodeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole cache before a single result - // changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await openCodeUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint - ? this.state.processedDatabases - : [] - ) - this.state.processedDatabases = result.processedDatabases - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange @@ -479,7 +337,7 @@ export class OpenCodeUsageStore { scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false @@ -492,13 +350,13 @@ export class OpenCodeUsageStore { } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day || day < cutoff) { return false } @@ -506,9 +364,4 @@ export class OpenCodeUsageStore { return true }) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 02b1d9acf1e..398729efe17 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -19,6 +19,8 @@ export type { export type PtyProviderBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string /** Authoritative normal buffer captured beside an alternate-screen frame. */ scrollbackAnsi?: string cols: number diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 235672e05e7..66fec74a482 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -32,6 +32,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame, separate so newer clients can omit it safely. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string /** Provider sequence at the attach boundary. `reset` starts a new provider * generation; `continued` resumes the existing absolute domain. */ providerSequence?: { diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..e1ace3350ca 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -18,8 +18,26 @@ import { readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +85,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +103,26 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +130,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +147,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +154,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +169,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +176,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +187,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +217,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +226,9 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..8fad453cab5 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,10 @@ -import { execFile, execFileSync } from 'node:child_process' +import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { readWindowsPathRegistry } from './windows-path-registry-reader' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,10 +17,7 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} +type RegistryPathRead = { failed: boolean; segments: string[] } const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], @@ -71,6 +69,19 @@ function registryOutputSegments( : [] } +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) +} + function keepLastGoodSegments(segments: string[], failedReads: number): string[] { if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { return [...persistedWindowsPathCache.segments] @@ -145,34 +156,39 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) + const failedReads = reads.filter((read) => read.failed).length if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. return cachePersistedWindowsPathSegments(segments, failedReads) } @@ -202,14 +218,22 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..634cd30e72a --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value as a failed query', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..6f98c45e390 --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,67 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const entry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + )?.[1] + if (!entry) { + return { failed: true, value: null } + } + if ( + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 71c18b9412a..d986fa54938 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -134,6 +134,126 @@ describe('folder workspaces sharing one directory', () => { expect([...(inventory?.livePtyIds ?? [])]).toEqual([PTY_B]) }) + it('keeps the first resolved worktree when normalized identities collide', async () => { + const firstId = `${ROOT_ID}/duplicate/` + const equivalentId = `${ROOT_ID}/duplicate` + const laterId = `${ROOT_ID}/later` + const internals = createRuntimeInternals({ + sessions: [ + { id: 'later-owner-pty', worktreeId: laterId, cwd: FOLDER_PATH, title: 'shell' }, + { id: 'duplicate-owner-pty', worktreeId: equivalentId, cwd: FOLDER_PATH, title: 'shell' } + ] + }) + const resolvedWorktrees = [firstId, equivalentId, laterId].map((id) => + internals.buildResolvedWorktreeFromId(id) + ) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('duplicate-owner-pty')?.worktreeId).toBe(firstId) + }) + + it('stops indexing after a sparse owner match', async () => { + const ownerId = `${ROOT_ID}/first` + const internals = createRuntimeInternals({ + sessions: [{ id: 'sparse-owner-pty', worktreeId: ownerId, cwd: FOLDER_PATH, title: 'shell' }] + }) + let identityReads = 0 + const resolvedWorktrees = [ownerId, `${ROOT_ID}/unused-a`, `${ROOT_ID}/unused-b`].map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('sparse-owner-pty')?.worktreeId).toBe(ownerId) + expect(identityReads).toBe(2) + }) + + it('keeps parsed and raw identity domains distinct', async () => { + const parsedId = `${ROOT_ID}/collision` + const rawId = `${REPO_ID}\0${FOLDER_PATH}/collision` + const internals = createRuntimeInternals({ + sessions: [{ id: 'raw-owner-pty', worktreeId: rawId, cwd: FOLDER_PATH, title: 'shell' }] + }) + const parsedWorktree = internals.buildResolvedWorktreeFromId(parsedId) + if (!parsedWorktree || typeof parsedWorktree !== 'object') { + throw new Error(`Failed to resolve ${parsedId}`) + } + const rawWorktree = { ...parsedWorktree, id: rawId } + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([parsedWorktree, rawWorktree]) + + expect(internals.ptysById.get('raw-owner-pty')?.worktreeId).toBe(rawId) + }) + + it('bounds provider and persisted worktree resolution to linear identity reads', async () => { + const worktreeCount = 128 + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `${ROOT_ID}/worktree-${index}` + ) + const persistedSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: Object.fromEntries( + worktreeIds.map((worktreeId, index) => [ + worktreeId, + [ + { + id: `indexed-tab-${index}`, + ptyId: `indexed-pty-${index}`, + worktreeId, + title: 'shell' + } + ] + ]) + ) as never + } + const internals = createRuntimeInternals({ + session: persistedSession, + sessions: worktreeIds.map((worktreeId, index) => ({ + id: `indexed-pty-${index}`, + worktreeId, + cwd: `${FOLDER_PATH}/worktree-${index}`, + title: 'shell' + })) + }) + let identityReads = 0 + const resolvedWorktrees = worktreeIds.map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.size).toBe(worktreeCount) + expect(internals.ptysById.get('indexed-pty-0')?.worktreeId).toBe(worktreeIds[0]) + expect(internals.ptysById.get(`indexed-pty-${worktreeCount - 1}`)?.worktreeId).toBe( + worktreeIds[worktreeCount - 1] + ) + expect(identityReads).toBe(worktreeCount * 2) + }) + it('reports live PTYs per workspace instance and for the folder root separately', () => { const internals = createRuntimeInternals() internals.recordPtyWorktree(PTY_A, WORKSPACE_A, { connected: true }) diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e5c8a18479e..ce96eddb5e0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -22,6 +22,7 @@ const { closeWatcherInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ @@ -35,6 +36,7 @@ const { statMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), closeWatcherInWatcherProcessMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -91,6 +93,10 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../ipc/filesystem-search-git', () => ({ + searchWithGitGrep: searchWithGitGrepMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), onSshFilesystemProviderRegistered: () => () => undefined, @@ -202,6 +208,12 @@ function createRuntimeSearchChild(): MockRuntimeSearchChild { return child } +async function flushRuntimeSearchMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -217,6 +229,7 @@ describe('RuntimeFileCommands', () => { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + searchWithGitGrepMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() @@ -754,6 +767,68 @@ describe('RuntimeFileCommands', () => { expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + }) + + it.each(['error-first', 'close-first'] as const)( + 'falls back once when runtime rg native launch failure is %s', + async (order) => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: undefined }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + child.emit('close', 127, null) + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) }) it('routes runtime rg searches through the registered WSL project runtime', async () => { @@ -767,6 +842,7 @@ describe('RuntimeFileCommands', () => { })) const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) @@ -779,7 +855,7 @@ describe('RuntimeFileCommands', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - child.emit('close') + child.emit('close', 127, null) await expect(resultPromise).resolves.toMatchObject({ files: [] }) expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( @@ -788,6 +864,7 @@ describe('RuntimeFileCommands', () => { 'C:\\repo' ) expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -798,6 +875,25 @@ describe('RuntimeFileCommands', () => { ) }) + it('keeps the runtime WSL preflight and falls back before starting real rg', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const fallback = { files: [], totalMatches: 0, truncated: false } + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + + await expect( + commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 }) + ).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + describe('resolveTerminalPath', () => { let tempDirs: string[] = [] diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 68000f1b297..f85bff8abde 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -58,6 +58,11 @@ import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths @@ -1920,26 +1925,33 @@ export class RuntimeFileCommands { 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const wslInfo = parseWslPath(authorizedRootPath) + if ( + (wslInfo || localGitOptions.wslDistro) && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - this.activeRuntimeTextSearches.get(searchKey)?.kill() + const previousChild = this.activeRuntimeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null - const wslInfo = parseWslPath(authorizedRootPath) const transformAbsPath = wslInfo ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1948,8 +1960,11 @@ export class RuntimeFileCommands { this.activeRuntimeTextSearches.delete(searchKey) } cleanupListeners() - resolvePromise(finalize(acc)) + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { @@ -1961,6 +1976,12 @@ export class RuntimeFileCommands { child?.stderr?.off('data', onStderrData) child?.off('error', onError) child?.off('close', onClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } } const processLine = (line: string): void => { @@ -1971,8 +1992,8 @@ export class RuntimeFileCommands { maxResults, transformAbsPath ) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1996,8 +2017,25 @@ export class RuntimeFileCommands { const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const onError = (): void => resolveOnce() - const onClose = (): void => { + const onError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -2011,7 +2049,9 @@ export class RuntimeFileCommands { killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3336bd7c01f..031c9be80c5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11043,6 +11043,19 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.cwd).toBe('/home/me/repo/src') }) + it('projects frame-independent live state through main buffer snapshots', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-frame-state', TEST_WORKTREE_ID) + runtime.onPtyData('pty-frame-state', '\x1b[?1049h\x1b[?1004h\x1b[?25lSTATIC-FRAME', 123) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-frame-state') + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('STATIC-FRAME') + expect(snapshot?.data).toContain('STATIC-FRAME') + }) + it('keeps Windows SSH OSC7 cwd as a drive path when the desktop runtime is POSIX', () => { setPlatform('darwin') const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 212917476fc..43662a99441 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -259,6 +259,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, @@ -1605,6 +1606,8 @@ type ProviderBufferAcquisition = { type RuntimeTerminalBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -2129,6 +2132,8 @@ type RuntimeWorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId?: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget @@ -3192,7 +3197,7 @@ export class OrcaRuntimeService { private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() - private preservedBranchCleanupByWorktreeId = new Map() + private preservedBranchCleanupByScope = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null @@ -11293,6 +11298,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11311,6 +11317,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11761,6 +11768,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11811,6 +11819,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -12231,6 +12240,7 @@ export class OrcaRuntimeService { return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { data, + frameRestoreAnsi: snapshot.frameRestoreAnsi, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), @@ -24326,6 +24336,7 @@ export class OrcaRuntimeService { private rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId | undefined, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -24337,14 +24348,21 @@ export class OrcaRuntimeService { `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - this.preservedBranchCleanupByWorktreeId.set(worktreeId, { - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) + this.preservedBranchCleanupByScope.set( + preservedBranchCleanupScopeKey({ worktreeId, hostId }), + { + worktreeId, + ...(hostId ? { hostId } : {}), + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + } + ) return } - this.preservedBranchCleanupByWorktreeId.delete(worktreeId) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId, hostId }) + ) } private preserveBranchHeadFallback( @@ -24366,15 +24384,29 @@ export class OrcaRuntimeService { async forceDeletePreservedBranch( worktreeSelector: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const cleanupTarget = removalTarget - ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + const normalizedHostId = parseExecutionHostId(hostId)?.id + const exactTarget = removalTarget + ? this.preservedBranchCleanupByScope.get( + preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) + ) : undefined + const legacyMatches = + removalTarget && !hostId + ? [...this.preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === removalTarget.id && + target.branchName === branchName && + target.head === expectedHead + ) + : [] + const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if ( !removalTarget || !cleanupTarget || @@ -24428,7 +24460,12 @@ export class OrcaRuntimeService { ) } - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } @@ -24438,13 +24475,19 @@ export class OrcaRuntimeService { runHooks = false, // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` // alone is already set by the ordinary delete confirmation. - allowUnverifiedPtyStop = false + allowUnverifiedPtyStop = false, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store + const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const cleanupScopeKey = preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupHostId + }) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) if (inFlightRemoval) { @@ -24508,7 +24551,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24551,7 +24594,7 @@ export class OrcaRuntimeService { }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) return {} @@ -24656,7 +24699,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24705,7 +24748,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24741,7 +24784,7 @@ export class OrcaRuntimeService { )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24788,6 +24831,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24833,6 +24877,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24995,7 +25040,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -25022,6 +25067,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -29548,6 +29594,7 @@ export class OrcaRuntimeService { for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) const persistedIndexesByHostId = new Map< ExecutionHostId, { @@ -29579,14 +29626,12 @@ export class OrcaRuntimeService { ) const controllerIdentity = controllerIdentityByPtyId.get(session.id) const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) - const providerWorktree = resolvedWorktrees.find( - (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) - ) + const providerWorktree = session.worktreeId + ? findResolvedWorktree(session.worktreeId) + : undefined const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) const persistedWorktree = persistedWorktreeId - ? resolvedWorktrees.find((worktree) => - runtimeWorktreeIdsEqual(worktree.id, persistedWorktreeId) - ) + ? findResolvedWorktree(persistedWorktreeId) : undefined const hasMigrationEvidence = Boolean(session.worktreeId) && @@ -37152,6 +37197,42 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string { : worktreeId } +function runtimeWorktreeLookupKey(worktreeId: string): string { + const parsed = splitWorktreeId(worktreeId) + return JSON.stringify( + parsed + ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] + : ['raw', worktreeId] + ) +} + +function createIncrementalResolvedWorktreeLookup( + resolvedWorktrees: ResolvedWorktree[] +): (worktreeId: string) => ResolvedWorktree | undefined { + const worktreeByIdentity = new Map() + let indexedCount = 0 + return (worktreeId) => { + const lookupKey = runtimeWorktreeLookupKey(worktreeId) + const indexed = worktreeByIdentity.get(lookupKey) + if (indexed) { + return indexed + } + while (indexedCount < resolvedWorktrees.length) { + const worktree = resolvedWorktrees[indexedCount] + indexedCount += 1 + const key = runtimeWorktreeLookupKey(worktree.id) + // Why: preserve Array.find's first match when normalized identities collide. + if (!worktreeByIdentity.has(key)) { + worktreeByIdentity.set(key, worktree) + } + if (key === lookupKey) { + return worktreeByIdentity.get(key) + } + } + return undefined + } +} + function resolveTerminalSessionWorktreeId( session: WorkspaceSessionState, targetWorktreeId: string diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 7049d59469d..259664c5544 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -57,6 +57,10 @@ import { releaseContextOnlyDispatch, type ContextOnlyDispatchReleaseResult } from './context-only-dispatch-release' +import { + ensureMutationReceiptCapacity, + migrateMutationReceiptCapacity +} from './mutation-receipt-capacity' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -261,9 +265,6 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -const MUTATION_RECEIPT_MAX_ROWS = 10_000 -const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 - export type RunListPage = { runs: RunRow[] nextCursor: string | null @@ -281,8 +282,8 @@ type RunListCursor = { id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. -const SCHEMA_VERSION = 25 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity. +const SCHEMA_VERSION = 26 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -983,6 +984,9 @@ export class OrchestrationDb { WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); `) } + if (current < 26) { + migrateMutationReceiptCapacity(this.db) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) @@ -1398,44 +1402,6 @@ export class OrchestrationDb { // ── Durable mutation receipts ── - private ensureMutationReceiptCapacity(): void { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE state = 'completed' - AND updated_at < datetime('now', ?)` - ) - .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) - - const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 - if (completedToRemove > 0) { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE rowid IN ( - SELECT rowid FROM mutation_receipts - WHERE state = 'completed' - ORDER BY updated_at ASC, rowid ASC - LIMIT ? - )` - ) - .run(completedToRemove) - } - - const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { - throw new OrchestrationError( - 'mutation_ledger_full', - 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' - ) - } - } - beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1458,7 +1424,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4009,7 +3975,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4642,7 +4608,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts new file mode 100644 index 00000000000..6907d73ba81 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { MUTATION_RECEIPT_MAX_ROWS } from './mutation-receipt-capacity' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertReceipts( + sqlite: Database.Database, + count: number, + state: 'pending' | 'completed' +): void { + sqlite + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +function beginReceipt(db: OrchestrationDb, requestId: string): void { + db.beginMutationReceipt({ + callerFingerprint: 'new-caller', + requestId, + method: 'orchestration.send', + payloadHash: `hash-${requestId}` + }) +} + +describe('mutation receipt capacity schema', () => { + let db: OrchestrationDb | undefined + let secondDb: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + secondDb?.close() + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('uses the completed receipt index for age and capacity pruning', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, 10, 'completed') + + const agePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .all('-30 days') as { detail: string }[] + const capacityPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ?` + ) + .all(64) as { detail: string }[] + const details = [...agePlan, ...capacityPlan].map((row) => row.detail).join('\n') + + expect(details).toContain('idx_mutation_receipts_completed_updated') + expect(details).not.toContain('USE TEMP B-TREE') + expect(details).not.toMatch(/SCAN mutation_receipts(?:\n|$)/) + }) + + it('migrates a populated v25 database and tracks writes from older connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + insertReceipts(sqliteFor(db), 20, 'completed') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec(` + DROP TRIGGER mutation_receipts_count_insert; + DROP TRIGGER mutation_receipts_count_delete; + DROP TABLE mutation_receipt_ledger; + DROP INDEX idx_mutation_receipts_completed_updated; + `) + oldDb.pragma('user_version = 25') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + + const olderConnection = new Database(dbPath) + olderConnection.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('old-client', 'inserted', 'orchestration.send', 'hash', 'pending'); + DELETE FROM mutation_receipts + WHERE caller_fingerprint = 'old-client' AND request_id = 'inserted'; + `) + olderConnection.close() + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + }) + + it('amortizes capacity pruning while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + beginReceipt(db, 'first') + const afterFirst = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + beginReceipt(db, 'second') + const afterSecond = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + + expect(afterFirst.receipt_count).toBe(MUTATION_RECEIPT_MAX_ROWS - 63) + expect(afterSecond.receipt_count).toBe(afterFirst.receipt_count + 1) + expect(db.getMutationReceipt('caller', 'request_00064')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_00065')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + }) + + it('keeps the row limit exact across independent database connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-concurrency-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + secondDb = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS - 1, 'pending') + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('caller', 'completed-slot', 'orchestration.send', 'hash', 'completed') + `) + + beginReceipt(db, 'first-connection') + expect(() => beginReceipt(secondDb!, 'second-connection')).toThrowError( + expect.objectContaining({ code: 'mutation_ledger_full' }) + ) + db.discardPendingMutationReceipt('new-caller', 'first-connection') + beginReceipt(secondDb, 'second-connection') + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: MUTATION_RECEIPT_MAX_ROWS + }) + }) +}) diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.ts new file mode 100644 index 00000000000..2ba735adad0 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.ts @@ -0,0 +1,79 @@ +import type Database from '../../sqlite/sync-database' +import { OrchestrationError } from './orchestration-error' + +export const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 +const MUTATION_RECEIPT_PRUNE_BATCH_SIZE = 64 + +export function migrateMutationReceiptCapacity(db: Database.Database): void { + // Why: database triggers keep the count exact for concurrent connections and older binaries. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + + CREATE TABLE IF NOT EXISTS mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + receipt_count INTEGER NOT NULL CHECK(receipt_count >= 0) + ); + + INSERT INTO mutation_receipt_ledger (singleton, receipt_count) + SELECT 1, COUNT(*) FROM mutation_receipts + -- SQLite needs a WHERE to disambiguate SELECT UPSERT syntax. + WHERE true + ON CONFLICT(singleton) DO UPDATE SET receipt_count = excluded.receipt_count; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_insert + AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count + 1 + WHERE singleton = 1; + END; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_delete + AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count - 1 + WHERE singleton = 1; + END; + `) +} + +function receiptCount(db: Database.Database): number { + const row = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() as { receipt_count: number } | undefined + if (!row) { + throw new Error('Mutation receipt ledger metadata is missing.') + } + return row.receipt_count +} + +export function ensureMutationReceiptCapacity(db: Database.Database): void { + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ).run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const count = receiptCount(db) + if (count >= MUTATION_RECEIPT_MAX_ROWS) { + db.prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ).run(count - MUTATION_RECEIPT_MAX_ROWS + MUTATION_RECEIPT_PRUNE_BATCH_SIZE) + } + + if (receiptCount(db) >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } +} diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 8cb0547ee27..0b9c5ad7097 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -74,7 +74,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) @@ -117,7 +117,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() @@ -240,7 +240,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: null, @@ -277,7 +277,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) @@ -309,7 +309,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: 'tab_creator:leaf_creator', created_by_process_incarnation: 'pty_creator:incarnation-a', @@ -328,7 +328,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') }) }) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 57b8a9202a5..a3e562e8516 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -275,6 +275,7 @@ export const WorktreeSet = WorktreeSelector.extend({ }) export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalString, force: OptionalBoolean, // Why (#11960): the CLI's --force is an unambiguous force affordance, but the // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop @@ -284,6 +285,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalString, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..acc2dab0537 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -271,12 +271,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - const result = await runtime.removeManagedWorktree( + const removalArgs = [ params.worktree, params.force === true, params.runHooks === true, params.allowUnverifiedPtyStop === true - ) + ] as const + const result = params.hostId + ? await runtime.removeManagedWorktree(...removalArgs, params.hostId) + : await runtime.removeManagedWorktree(...removalArgs) return { removed: true, ...result } } }), @@ -284,6 +287,17 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => - runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead) + params.hostId + ? runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead, + params.hostId + ) + : runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead + ) }) ] diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 09be7c1f686..f903d71acf0 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -235,6 +235,40 @@ describe('PortScanner', () => { expect(scanner.getDetectedPorts('t1')).toEqual([]) }) + it('aborts in-flight requests before stopping or replacing a target scan', async () => { + const harness = createVisibilityHarness(true) + const signals: AbortSignal[] = [] + const request = vi.fn( + (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + reject(new Error('missing request signal')) + return + } + signals.push(signal) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const mux = { request } as unknown as SshChannelMultiplexer + const scanner = new PortScanner(harness.visibility) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(1) + expect(signals[0].aborted).toBe(false) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(2) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + + scanner.stopScanning('t1') + expect(signals[1].aborted).toBe(true) + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) + it('keeps targets independent: stopping one host leaves the other scanning', async () => { const harness = createVisibilityHarness(true) let nextA = 3000 diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 6404d035f22..2dbabff597a 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -19,6 +19,7 @@ export type PortScannerWindowVisibility = { type ScanHandle = { timer: ReturnType | null + requestAbortController: AbortController | null intervalMs: number // Why: while the window is hidden the scan chain is parked outright — no // timer wakeups, no remote requests — and the visibility listener resumes @@ -48,6 +49,7 @@ export class PortScanner { const handle: ScanHandle = { timer: null, + requestAbortController: null, intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, parkedWhileHidden: false, unsubscribeVisibility: () => {}, @@ -65,8 +67,12 @@ export class PortScanner { return } polling = true + const requestAbortController = new AbortController() + handle.requestAbortController = requestAbortController try { - const result = (await mux.request('ports.detect')) as { + const result = (await mux.request('ports.detect', undefined, { + signal: requestAbortController.signal + })) as { ports: DetectedPort[] platform: string } @@ -94,6 +100,9 @@ export class PortScanner { } catch { // Relay disconnected or request timed out — retry on next interval } finally { + if (handle.requestAbortController === requestAbortController) { + handle.requestAbortController = null + } polling = false } } @@ -142,6 +151,8 @@ export class PortScanner { if (handle.timer) { clearTimeout(handle.timer) } + handle.requestAbortController?.abort() + handle.requestAbortController = null handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts index 9e70466ab31..5053426131d 100644 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ b/src/main/ssh/ssh-relay-session-relay-loss.test.ts @@ -232,6 +232,53 @@ describe('SshRelaySession relay loss during setup', () => { expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') }) + // #13548 follow-up: cancelling the in-flight port scan emits rpc.cancel on the control + // lane, which a saturated writer turns into mux.dispose('connection_lost'). That fires + // during our own teardown, so it must not be reported as a lost relay. + it.each([ + ['detach', (session: SshRelaySession) => session.beginShutdownDetach()], + ['dispose', (session: SshRelaySession) => session.dispose()] + ])( + 'does not report relay loss when the port-scan cancel kills the mux on %s', + async (_path, teardown) => { + const { session, onRelayLost } = createSession() + // Mirrors SshChannelMultiplexer.request: an in-flight request whose signal aborts + // emits rpc.cancel, and the mock mux dies on that notify like a saturated writer. + muxRequestMock.mockImplementation( + async (method: string, _params?: unknown, options?: { signal?: AbortSignal }) => { + if (method !== 'ports.detect') { + return [] + } + const mux = session.getMux() as unknown as { + failNotifyMethod: string | null + notify: (method: string, params?: unknown) => void + } + mux.failNotifyMethod = 'rpc.cancel' + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + mux.notify('rpc.cancel', { id: 1 }) + reject(new Error('cancelled')) + }, + { once: true } + ) + }) + } + ) + + await session.establish({} as SshConnection) + expect(session.getState()).toBe('ready') + expect(muxRequestMock).toHaveBeenCalledWith('ports.detect', undefined, expect.anything()) + const mux = session.getMux() as unknown as { notify: ReturnType } + + teardown(session) + + expect(mux.notify).toHaveBeenCalledWith('rpc.cancel', { id: 1 }) + expect(onRelayLost).not.toHaveBeenCalled() + } + ) + // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the // reattach burst itself never reaches the catch — the post-reattach gate has to notice. it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 8cb6e642812..e002c334aad 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -640,6 +640,7 @@ export class SshRelaySession { return } + this.releaseRelayLossWatcher() // Cancel any in-flight reconnect this.abortController?.abort() const abortController = new AbortController() @@ -835,6 +836,7 @@ export class SshRelaySession { private runDisposal(pendingDetach: Promise | null): Promise { // Why: the whole in-memory half runs before any await so a concurrent connect can never observe // a half-torn session; only the durability barriers below are deferred onto the returned promise. + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -908,6 +910,7 @@ export class SshRelaySession { return } detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -927,6 +930,7 @@ export class SshRelaySession { // step — a fast reconnect must reclaim this identity instead of minting one, even if a // teardown call below throws unexpectedly. detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) + this.releaseRelayLossWatcher() this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() @@ -947,9 +951,21 @@ export class SshRelaySession { // ── Private ─────────────────────────────────────────────────────── + // Why: teardown itself can kill the mux — an aborted request emits rpc.cancel, and a saturated + // control lane turns that admission failure into mux.dispose('connection_lost'). Every teardown + // path must release the watcher before its first mux write, or our own shutdown re-enters + // recovery as a spurious relay loss. teardownProviders is not early enough: stopPortScanning + // runs ahead of it and is what emits that frame. Call this ahead of abortController.abort() + // too — that signal reaches no mux request today, but plumbing it into one would otherwise + // reopen the same hole silently. + private releaseRelayLossWatcher(): void { + this.muxDisposeCleanup?.() + this.muxDisposeCleanup = null + } + // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { - this.muxDisposeCleanup?.() + this.releaseRelayLossWatcher() this.muxDisposeCleanup = mux.onDispose((reason) => { if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { console.warn( @@ -1560,8 +1576,7 @@ export class SshRelaySession { reason: 'shutdown' | 'connection_lost', outputGenerationReason: string = reason ): void { - this.muxDisposeCleanup?.() - this.muxDisposeCleanup = null + this.releaseRelayLossWatcher() this.muxNotificationCleanup?.() this.muxNotificationCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/usage-calendar-range.test.ts new file mode 100644 index 00000000000..73645c2ccc1 --- /dev/null +++ b/src/main/usage/usage-calendar-range.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +describe('usage calendar ranges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 10, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['7d', '2026-04-04'], + ['30d', '2026-03-12'], + ['90d', '2026-01-11'], + ['all', null] + ] as const)('uses an inclusive local-calendar cutoff for %s', (range, expected) => { + expect(getUsageRangeCutoff(range)).toBe(expected) + }) + + it('maps timestamps to local calendar days and rejects invalid values', () => { + const localTimestamp = new Date(2026, 3, 4, 23, 30).toISOString() + + expect(getLocalUsageDay(localTimestamp)).toBe('2026-04-04') + expect(getLocalUsageDay('not-a-date')).toBeNull() + }) +}) diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/usage-calendar-range.ts new file mode 100644 index 00000000000..255ef245be5 --- /dev/null +++ b/src/main/usage/usage-calendar-range.ts @@ -0,0 +1,24 @@ +type UsageCalendarRange = '7d' | '30d' | '90d' | 'all' + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getUsageRangeCutoff(range: UsageCalendarRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const cutoff = new Date() + cutoff.setHours(0, 0, 0, 0) + cutoff.setDate(cutoff.getDate() - (days - 1)) + return formatLocalDay(cutoff) +} + +export function getLocalUsageDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + return Number.isNaN(parsed.getTime()) ? null : formatLocalDay(parsed) +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..02a5571363d --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -0,0 +1,293 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle' + +const { writeProbe } = vi.hoisted(() => ({ + writeProbe: { + opens: 0, + renames: 0, + blocked: false, + waiters: [] as (() => void)[] + } +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + open: (async (...args: Parameters) => { + if (args[1] === 'w') { + writeProbe.opens += 1 + if (writeProbe.blocked) { + await new Promise((resolve) => writeProbe.waiters.push(resolve)) + } + } + return actual.open(...args) + }) as typeof actual.open, + rename: ((...args: Parameters) => { + writeProbe.renames += 1 + return actual.rename(...args) + }) as typeof actual.rename + } +}) + +type TestSource = { id: string } +type TestSession = { id: string } +type TestDailyAggregate = { day: string } +type TestScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} +type TestState = { + schemaVersion: number + worktreeFingerprint: string | null + processedSources: TestSource[] + sessions: TestSession[] + dailyAggregates: TestDailyAggregate[] + scanState: TestScanState +} +type TestScanResult = Pick +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + previous: TestSource[] +) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') +const EMPTY_WORKTREE_FINGERPRINT = '[]' + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): TestState { + const { scanState, ...stateOverrides } = overrides + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedSources: [], + sessions: [], + dailyAggregates: [], + ...stateOverrides, + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + ...scanState + } + } +} + +function emptyScanResult(): TestScanResult { + return { processedSources: [], sessions: [], dailyAggregates: [] } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class TestUsageStore extends UsageProviderStoreLifecycle< + 'processedSources', + TestState, + 'hasAnyTestData' +> { + constructor(cacheFile: string, scan: TestScan) { + super( + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}) + }, + { + logTag: '[test-usage]', + resolveCacheFile: () => cacheFile, + createDefaultState: makeState, + normalizeState: (state) => state, + sourceKey: 'processedSources', + dataPresenceKey: 'hasAnyTestData', + scan + } + ) + } + + replaceState(state: TestState): void { + this.state = state + } + + getState(): TestState { + return this.state + } +} + +describe('UsageProviderStoreLifecycle', () => { + let tempDirectory: string + let stores: TestUsageStore[] + let scan: ReturnType> + + function createStore( + cacheFile = join(tempDirectory, `usage-${stores.length}.json`) + ): TestUsageStore { + const store = new TestUsageStore(cacheFile, scan) + stores.push(store) + return store + } + + beforeEach(() => { + tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-')) + stores = [] + writeProbe.opens = 0 + writeProbe.renames = 0 + writeProbe.blocked = false + writeProbe.waiters = [] + scan = vi.fn().mockResolvedValue(emptyScanResult()) + vi.spyOn(Date, 'now').mockReturnValue(NOW) + }) + + afterEach(async () => { + await Promise.all(stores.map((store) => store.flush())) + rmSync(tempDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('skips disabled and fresh matching states', async () => { + const store = createStore() + + await store.refresh() + expect(scan).not.toHaveBeenCalled() + + store.replaceState( + makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + await store.refresh() + + expect(scan).not.toHaveBeenCalled() + }) + + it('invalidates prior sources on fingerprint changes and reuses them for forced scans', async () => { + const store = createStore() + const refreshedSources = [{ id: 'refreshed' }] + scan.mockResolvedValueOnce({ + processedSources: refreshedSources, + sessions: [], + dailyAggregates: [] + }) + store.replaceState( + makeState({ + worktreeFingerprint: 'outdated', + processedSources: [{ id: 'stale' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + + await store.refresh() + expect(scan).toHaveBeenLastCalledWith([], []) + + scan.mockClear() + await store.refresh(true) + + expect(scan).toHaveBeenCalledWith([], refreshedSources) + }) + + it('shares one in-flight scan and exposes its live state', async () => { + const pendingScan = createDeferred() + scan.mockReturnValueOnce(pendingScan.promise) + const store = createStore() + store.replaceState( + makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } }) + ) + + const firstRefresh = store.refresh(true) + const secondRefresh = store.refresh(true) + await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1)) + + expect(store.getScanState()).toMatchObject({ + isScanning: true, + lastScanStartedAt: NOW, + lastScanError: null + }) + expect(writeProbe.opens).toBe(0) + + pendingScan.resolve({ + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [] + }) + await Promise.all([firstRefresh, secondRefresh]) + + expect(store.getScanState()).toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW, + hasAnyTestData: true + }) + expect(writeProbe.opens).toBe(1) + expect(store.getState().processedSources).toEqual([{ id: 'source' }]) + }) + + it('vetoes a superseded generation before rename', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + + writeProbe.blocked = true + const first = store.setEnabled(true) + await vi.waitFor(() => expect(writeProbe.waiters).toHaveLength(1)) + const second = store.setEnabled(false) + writeProbe.blocked = false + writeProbe.waiters.splice(0).forEach((resolve) => resolve()) + await Promise.all([first, second]) + + expect(writeProbe.opens).toBe(2) + expect(writeProbe.renames).toBe(1) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.enabled).toBe(false) + expect(readdirSync(tempDirectory).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + }) + + it('sweeps a temp file orphaned before rename', async () => { + const cacheFile = join(tempDirectory, 'orphaned-usage.json') + const orphan = `${cacheFile}.${process.pid + 1}.1.test.tmp` + writeFileSync(orphan, '{}') + + createStore(cacheFile) + + await vi.waitFor(() => expect(existsSync(orphan)).toBe(false)) + }) + + it('retains the last successful projection when a scan fails', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + const previousState = makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [{ day: '2026-04-09' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1_000 } + }) + store.replaceState(previousState) + scan.mockRejectedValueOnce(new Error('scan exploded')) + + await expect(store.refresh(true)).resolves.toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW - 1_000, + lastScanError: 'scan exploded' + }) + + expect(store.getState()).toMatchObject({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: previousState.processedSources, + sessions: previousState.sessions, + dailyAggregates: previousState.dailyAggregates + }) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.lastScanError).toBe( + 'scan exploded' + ) + }) +}) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts new file mode 100644 index 00000000000..850d9e61bda --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { Store } from '../persistence' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { createWorktreeRefs, getUsageWorktreeFingerprint } from './usage-worktree-refs' + +const STALE_MS = 5 * 60_000 + +type UsageProviderScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} + +type UsageProviderStoreState = { + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +type UsageProviderScanProjection< + SourceKey extends string, + State extends UsageProviderStoreState +> = Pick + +type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + jsonIndent?: number + scan: ( + worktrees: UsageScanWorktreeRef[], + previous: State[SourceKey] + ) => Promise> +} + +type PublicUsageProviderScanState = UsageProviderScanState & { + isScanning: boolean +} & Record + +export abstract class UsageProviderStoreLifecycle< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> { + protected state: State + private scanPromise: Promise | null = null + private readonly writer: UsageCacheSnapshotWriter + + constructor( + private readonly store: Pick, + private readonly config: UsageProviderStoreLifecycleConfig + ) { + this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) + this.state = this.load() + } + + getScanState(): PublicUsageProviderScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + [this.config.dataPresenceKey]: + this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } as PublicUsageProviderScanState + } + + /** Await queued cache writes so quit does not drop the final snapshot. */ + flush(): Promise { + return this.writer.flush() + } + + async setEnabled(enabled: boolean): Promise> { + this.state.scanState.enabled = enabled + await this.writeToDisk() + return this.getScanState() + } + + async refresh(force = false): Promise> { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + protected writeToDisk(): Promise { + return this.writer.write(() => JSON.stringify(this.state, null, this.config.jsonIndent)) + } + + private load(): State { + const defaults = this.config.createDefaultState() + try { + const cacheFile = this.config.resolveCacheFile() + if (!existsSync(cacheFile)) { + return defaults + } + const parsed = JSON.parse(readFileSync(cacheFile, 'utf-8')) as State + return this.config.normalizeState({ + ...defaults, + ...parsed, + scanState: { ...defaults.scanState, ...parsed.scanState } + }) + } catch (error) { + console.error(`${this.config.logTag} Failed to load persisted state, starting fresh:`, error) + return defaults + } + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + + // Assign before yielding so concurrent refreshes share one scan. + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo) + const result = await this.config.scan( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state[this.config.sourceKey] + : this.config.createDefaultState()[this.config.sourceKey] + ) + this.state[this.config.sourceKey] = result[this.config.sourceKey] + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + // Persistence failures do not turn a successful source scan into a scan failure. + await this.writeToDisk().catch(() => {}) + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + await this.writeToDisk().catch(() => {}) + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/usage/usage-worktree-refs.test.ts b/src/main/usage/usage-worktree-refs.test.ts new file mode 100644 index 00000000000..56efe7e4fb8 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' +import { getUsageWorktreeFingerprint } from './usage-worktree-refs' + +function worktree(worktreeId: string, path: string, displayName: string): UsageWorktreeRef { + return { worktreeId, path, displayName } +} + +describe('getUsageWorktreeFingerprint', () => { + it('is stable across repo and worktree ordering but changes with identity metadata', () => { + const first = new Map([ + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two'), + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One') + ] + ], + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]] + ]) + const reordered = new Map([ + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]], + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One'), + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two') + ] + ] + ]) + const renamed = new Map(reordered) + renamed.set('repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'Renamed A')]) + + expect(getUsageWorktreeFingerprint(first)).toBe(getUsageWorktreeFingerprint(reordered)) + expect(getUsageWorktreeFingerprint(renamed)).not.toBe(getUsageWorktreeFingerprint(first)) + }) +}) diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index 0b4347a90a5..f76ed7c0a58 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,6 +1,25 @@ import type { Repo } from '../../shared/types' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' +export function getUsageWorktreeFingerprint( + worktreesByRepo: Map +): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1cae8cd2359..ea5ba93a99c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -443,17 +443,7 @@ import type { ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../shared/claude-usage-types' +import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../shared/claude-usage-types' import type { CodexRateLimitResetResult, GrokAccountStatus, @@ -479,27 +469,10 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - CodexUsageBreakdownKind, - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../shared/codex-usage-types' +import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../shared/codex-usage-types' import type { OpenCodeUsageBreakdownKind, - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary + OpenCodeUsageSnapshot } from '../shared/opencode-usage-types' import type { AiVaultDeleteSessionArgs, @@ -836,92 +809,39 @@ export type MemoryApi = { getSnapshot: () => Promise } -export type ClaudeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getDaily: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getBreakdown: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - kind: ClaudeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise +type UsageProviderSnapshot = { + scanState: unknown + summary: { scope: string; range: string } + daily: unknown[] + modelBreakdown: unknown[] + recentSessions: unknown[] } -export type CodexUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getDaily: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getBreakdown: (args: { - scope: CodexUsageScope - range: CodexUsageRange - kind: CodexUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise +type UsageQueryArgs = Pick< + Snapshot['summary'], + 'scope' | 'range' +> + +type UsageProviderApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSummary: (args: UsageQueryArgs) => Promise + getDaily: (args: UsageQueryArgs) => Promise + getBreakdown: ( + args: UsageQueryArgs & { kind: BreakdownKind } + ) => Promise + getRecentSessions: ( + args: UsageQueryArgs & { limit?: number } + ) => Promise } -export type OpenCodeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getDaily: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getBreakdown: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise -} +export type ClaudeUsageApi = UsageProviderApi + +export type CodexUsageApi = UsageProviderApi + +export type OpenCodeUsageApi = UsageProviderApi export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise @@ -1482,6 +1402,7 @@ export type PreloadApi = { worktreeId: string branchName: string expectedHead: string + hostId?: ExecutionHostId }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise<{ @@ -1566,6 +1487,9 @@ export type PreloadApi = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1632,6 +1556,7 @@ export type PreloadApi = { opts?: { scrollbackRows?: number } ) => Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/preload/index.ts b/src/preload/index.ts index a87b51ad8b3..f6b527cd6b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -227,6 +227,7 @@ import type { } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { createUsageProviderApi } from './usage-provider-api' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -964,6 +965,9 @@ const api = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1082,6 +1086,7 @@ const api = { opts?: { scrollbackRows?: number } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -4222,59 +4227,9 @@ const api = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') }, - claudeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('claudeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('claudeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getRecentSessions', args) - }, - - codexUsage: { - getScanState: (): Promise => ipcRenderer.invoke('codexUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('codexUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('codexUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('codexUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getRecentSessions', args) - }, - - openCodeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) - }, + claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), + codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), + openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), aiVault: { listSessions: (args?: AiVaultListArgs): Promise => diff --git a/src/preload/usage-provider-api.test.ts b/src/preload/usage-provider-api.test.ts new file mode 100644 index 00000000000..0ce594cbb9f --- /dev/null +++ b/src/preload/usage-provider-api.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { createUsageProviderApi } from './usage-provider-api' + +describe('usage provider preload API', () => { + it('keeps every provider on its existing IPC channels', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + const query = { scope: 'orca' as const, range: '30d' as const } + + for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) { + const api = createUsageProviderApi({ invoke } as never, prefix) + await api.getScanState() + await api.setEnabled({ enabled: true }) + await api.refresh() + await api.getSnapshot({ ...query, limit: 7 }) + await api.getSummary(query) + await api.getDaily(query) + await api.getBreakdown({ ...query, kind: 'project' }) + await api.getRecentSessions({ ...query, limit: 4 }) + + expect(invoke.mock.calls).toEqual([ + [`${prefix}:getScanState`], + [`${prefix}:setEnabled`, { enabled: true }], + [`${prefix}:refresh`, undefined], + [`${prefix}:getSnapshot`, { ...query, limit: 7 }], + [`${prefix}:getSummary`, query], + [`${prefix}:getDaily`, query], + [`${prefix}:getBreakdown`, { ...query, kind: 'project' }], + [`${prefix}:getRecentSessions`, { ...query, limit: 4 }] + ]) + invoke.mockClear() + } + }) +}) diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts new file mode 100644 index 00000000000..b80d72850c0 --- /dev/null +++ b/src/preload/usage-provider-api.ts @@ -0,0 +1,29 @@ +import type { IpcRenderer } from 'electron' +import type { PreloadApi } from './api-types' + +type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' +type UsageProviderApi = PreloadApi[UsageProviderApiKey] +type UsageRangeArgs = { scope: string; range: string } + +export function createUsageProviderApi( + ipc: Pick, + prefix: Key +): PreloadApi[Key] +export function createUsageProviderApi( + ipc: Pick, + prefix: UsageProviderApiKey +): UsageProviderApi { + return { + getScanState: () => ipc.invoke(`${prefix}:getScanState`), + setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args), + refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args), + getSnapshot: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getSnapshot`, args), + getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args), + getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args), + getBreakdown: (args: UsageRangeArgs & { kind: string }) => + ipc.invoke(`${prefix}:getBreakdown`, args), + getRecentSessions: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getRecentSessions`, args) + } +} diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 02cd8da6afc..baf6101600c 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -31,6 +31,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 1ca5c2f5327..b819d3ac85c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,6 +16,11 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { + ListFilesScanCoordinator, + LIST_FILES_SUPERSEDED_MESSAGE +} from './fs-list-files-scan-coordinator' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -36,6 +41,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } @@ -72,6 +78,7 @@ describe('relay quick open ignored file listing', () => { }) const promise = listFilesWithRg('/remote/root', ['packages/other']) + expect(spawnMock).toHaveBeenCalledTimes(2) setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') @@ -115,6 +122,82 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it.each(['error-first', 'close-first'] as const)( + 'tags a %s pre-spawn listing failure without starting the ignored pass', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValue(missing) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + + const promise = listFilesWithRg(root) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + } else { + missing.emit('close', -2, null) + } + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + if (order === 'error-first') { + missing.emit('close', -2, null) + } else { + expect(() => missing.emit('error', error)).not.toThrow() + } + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const root = await makeTempRoot() + const primary = createMockProcess() + const missingIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? missingIgnored : primary + ) + + const promise = listFilesWithRg(root) + expect(spawnMock).toHaveBeenCalledTimes(2) + missingIgnored.emit('close', -2, null) + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(primary.kill).toHaveBeenCalled() + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('does not signal failed-spawn passes when a same-client scan is superseded', async () => { + const root = await makeTempRoot() + const firstChild = createMockProcess() + Object.defineProperty(firstChild, 'pid', { value: undefined }) + spawnMock.mockReturnValue(firstChild) + const coordinator = new ListFilesScanCoordinator() + const first = coordinator.run({ + clientId: 1, + key: 'first', + start: (signal) => listFilesWithRg(root, [], { signal }) + }) + const firstOutcome = first.catch((error: unknown) => error) + + const second = coordinator.run({ + clientId: 1, + key: 'second', + start: async () => ['second.ts'] + }) + + await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE }) + await expect(second).resolves.toEqual(['second.ts']) + expect(firstChild.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => firstChild.emit('error', error)).not.toThrow() + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -430,4 +513,101 @@ describe('relay quick open ignored file listing', () => { vi.useRealTimers() } }) + + it.each(['error-first', 'close-first'] as const)( + 'tags only a %s pre-spawn rg search failure as unavailable', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(missing) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + missing.emit('close', -2, null) + } else { + missing.emit('close', -2, null) + expect(() => missing.emit('error', error)).not.toThrow() + } + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('tags unsupported native launcher exits as unavailable', async () => { + const root = await makeTempRoot() + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(child) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + + child.emit('close', 127, null) + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + }) + + it('keeps missing-root launch errors on their prior non-fallback paths', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const listFirst = createMockProcess() + const listProbe = createMockProcess() + Object.defineProperty(listFirst, 'pid', { value: undefined }) + Object.defineProperty(listProbe, 'pid', { value: 1 }) + let callIndex = 0 + spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) + const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + listFirst.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }]) + listProbe.emit('close', 0, null) + await expect(listing).rejects.toBe(listError) + + spawnMock.mockReset() + const searchChild = createMockProcess() + const searchProbe = createMockProcess() + Object.defineProperty(searchChild, 'pid', { value: undefined }) + Object.defineProperty(searchProbe, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) + const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) + searchChild.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + searchProbe.emit('close', 0, null) + await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) + + it('keeps missing-rg precedence when the root also disappeared', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const first = createMockProcess() + const probe = createMockProcess() + for (const child of [first, probe]) { + Object.defineProperty(child, 'pid', { value: undefined }) + } + let callIndex = 0 + spawnMock.mockImplementation(() => [first, probe][callIndex++]) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + first.emit('error', error) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + probe.emit('close', -2, null) + + await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(() => probe.emit('error', error)).not.toThrow() + }) + + it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + const started = createMockProcess() + Object.defineProperty(started, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(started) + const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) + started.emit('error', new Error('post-spawn failure')) + await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) }) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index c63a352786e..3ec7b99026d 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,7 +1,5 @@ /** * Ripgrep-based file listing for Quick Open. - * Extracted from fs-handler-utils.ts to keep it under 300 lines (oxlint max-lines). - * * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir * worktree over SSH, rg descended into every dotfile cache, hit the timeout, * and silently resolved with a partial list — Quick Open then showed "No @@ -23,6 +21,13 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -78,6 +83,9 @@ export function listFilesWithRg( let passBuf = '' let passDone = false let passFileCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // --no-messages: permission-denied noise on the remote (e.g. .ssh, // root-owned mounts) would otherwise flood stderr. // cwd: rootPath — root-relative exclude globs like `!packages/app/**` @@ -98,6 +106,10 @@ export function listFilesWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) } const rejectPass = (error: Error): void => { if (passDone) { @@ -116,6 +128,16 @@ export function listFilesWithRg( cleanup() passResolve() } + const rejectLaunchFailure = (error: Error): void => { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then( + (unavailable) => { + rejectPass(unavailable ? new RipgrepUnavailableError() : error) + } + ) + } children.push({ child, isDone: () => passDone, @@ -125,7 +147,7 @@ export function listFilesWithRg( timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. - child.kill() + killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) @@ -149,12 +171,28 @@ export function listFilesWithRg( /* drain to prevent backpressure stalls */ } function handleError(err: Error): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + passBuf = '' + rejectLaunchFailure(err) + return + } rejectPass(err) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (passDone) { return } + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + passBuf = '' + rejectLaunchFailure(new Error(`rg exited with code ${code}`)) + return + } // Why signal != null is a failure: the only way spawn gets a signal // is if the process was killed (timeout, OOM, external SIGKILL). // Trusting its stdout could surface a truncated list as a success. @@ -199,7 +237,7 @@ export function listFilesWithRg( continue } if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } entry.reject(new Error(reason)) } @@ -228,12 +266,15 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + const primaryPass = runPass(primary) const passes = maxResults === undefined - ? Promise.all([runPass(primary), runPass(ignoredPass)]) + ? children[0]?.child.pid === undefined + ? primaryPass + : Promise.all([primaryPass, runPass(ignoredPass)]) : // Why: deterministic primary-first budgeting prevents a large ignored // tree from starving ordinary source paths on a remote host. - runPass(primary).then(() => + primaryPass.then(() => files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() ) diff --git a/src/relay/fs-handler-rg-availability.test.ts b/src/relay/fs-handler-rg-availability.test.ts deleted file mode 100644 index 8b14a3d61af..00000000000 --- a/src/relay/fs-handler-rg-availability.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -const { execFileMock, spawnMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - execFile: execFileMock, - spawn: spawnMock -})) - -import { checkRgAvailable } from './fs-handler-utils' - -class FakeChildProcess extends EventEmitter { - kill = vi.fn() -} - -describe('relay rg availability', () => { - it('removes listeners after a successful probe', async () => { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - child.emit('close', 0) - - await expect(result).resolves.toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('settles and detaches when a wedged probe ignores timeout kill', async () => { - vi.useFakeTimers() - try { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - await vi.advanceTimersByTimeAsync(5000) - - await expect(result).resolves.toBe(false) - expect(child.kill).toHaveBeenCalledTimes(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts new file mode 100644 index 00000000000..93d89fd9008 --- /dev/null +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsHandlerGitFallback from './fs-handler-git-fallback' +import type * as FsHandlerUtils from './fs-handler-utils' + +const { + execFileMock, + listFilesWithGitMock, + listFilesWithRgMock, + searchWithGitGrepMock, + searchWithRgMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listFilesWithGitMock: vi.fn(), + listFilesWithRgMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), + searchWithRgMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +vi.mock('./fs-handler-utils', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithRg: listFilesWithRgMock, + searchWithRg: searchWithRgMock +})) + +vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithGit: listFilesWithGitMock, + searchWithGitGrep: searchWithGitGrepMock +})) + +import { FileListingCancelledError } from '../shared/file-listing-cancellation' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' + +type FsHandlerInternals = { + search(params: Record): Promise + runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal, + maxResults?: number + ): Promise +} + +function createHandler(): FsHandlerInternals { + const dispatcher = { + onRequest: vi.fn(), + onNotification: vi.fn(), + onClientDetached: vi.fn(() => () => undefined) + } + const watcherPool = { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: vi.fn() + } + return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never +} + +describe('relay direct ripgrep admission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back only for a tagged search launch failure', async () => { + const handler = createHandler() + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + searchWithGitGrepMock.mockResolvedValueOnce(fallback) + + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback) + expect(searchWithRgMock).toHaveBeenCalledTimes(1) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + + const ordinaryFailure = new Error('rg failed after spawn') + searchWithRgMock.mockRejectedValueOnce(ordinaryFailure) + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe( + ordinaryFailure + ) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + + it('falls back only for a tagged listing launch failure', async () => { + const handler = createHandler() + const controller = new AbortController() + listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null) + return undefined + }) + listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts']) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([ + 'src/index.ts' + ]) + expect(listFilesWithRgMock).toHaveBeenCalledTimes(1) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + + it('lets cancellation win an unavailable-listing race before Git starts', async () => { + const handler = createHandler() + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + listFilesWithRgMock.mockImplementationOnce(async () => { + controller.abort(cancellation) + throw new RipgrepUnavailableError() + }) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).rejects.toBe( + cancellation + ) + expect(execFileMock).not.toHaveBeenCalled() + expect(listFilesWithGitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 3fcf50a6eb2..83726c686bf 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -5,7 +5,7 @@ * These functions depend only on their arguments (plus `rg` being on PATH), * so they are straightforward to test independently. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { open } from 'node:fs/promises' import { buildRgArgs, @@ -16,6 +16,13 @@ import { } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/types' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' // ─── Constants ─────────────────────────────────────────────────────── @@ -89,11 +96,14 @@ export function searchWithRg( query: string, opts: SearchOptions ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() let buffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), // which would leak out of the `new Promise` executor and leave the @@ -124,13 +134,47 @@ export function searchWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) resolve(finalize(acc)) } + function rejectUnavailable(): void { + if (resolved) { + return + } + resolved = true + clearTimeout(killTimeout) + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + reject(new RipgrepUnavailableError()) + } + + function settleLaunchFailure(): void { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => { + if (unavailable) { + rejectUnavailable() + } else { + resolveOnce() + } + }) + } + function processLine(line: string): void { const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) if (verdict === 'stop') { - child.kill() + killSpawnedRipgrepProcess(child) } } @@ -148,10 +192,24 @@ export function searchWithRg( } function handleError(): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + settleLaunchFailure() + return + } resolveOnce() } - function handleClose(): void { + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + settleLaunchFailure() + return + } if (buffer) { processLine(buffer) } @@ -166,57 +224,11 @@ export function searchWithRg( killTimeout = setTimeout(() => { acc.truncated = true - child.kill() + killSpawnedRipgrepProcess(child) resolveOnce() }, SEARCH_TIMEOUT_MS) }) } -// ─── rg availability check ────────────────────────────────────────── - -// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the -// result caused a footgun — a negative cache persisted across rg installs -// (forcing a relay restart), while a positive cache could mask an rg that -// was uninstalled or broken mid-session. The `settled` flag below closes -// the original race between 'error' and 'close' that the cache was added -// to paper over, so re-checking per call is both simpler and safer. -const RG_AVAILABILITY_TIMEOUT_MS = 5000 - -export function checkRgAvailable(): Promise { - return new Promise((resolve) => { - let settled = false - const child = execFile('rg', ['--version']) - let timeout: ReturnType | null = null - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - child.off('error', onError) - child.off('close', onClose) - } - const settle = (available: boolean, options?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - cleanup() - if (options?.kill) { - child.kill() - } - resolve(available) - } - const onError = (): void => settle(false) - const onClose = (code: number | null): void => settle(code === 0) - - child.once('error', onError) - child.once('close', onClose) - timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - // Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint) export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d07e2bf397f..b7dd2dfa6b5 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -10,12 +10,7 @@ import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' -import { - DEFAULT_MAX_RESULTS, - searchWithRg, - listFilesWithRg, - checkRgAvailable -} from './fs-handler-utils' +import { DEFAULT_MAX_RESULTS, searchWithRg, listFilesWithRg } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' @@ -35,6 +30,7 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' @@ -328,26 +324,22 @@ export class FsHandler { DEFAULT_MAX_RESULTS ) - const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { - return searchWithGitGrep(rootPath, query, { - caseSensitive, - wholeWord, - useRegex, - includePattern, - excludePattern, - maxResults - }) - } - - return searchWithRg(rootPath, query, { + const options = { caseSensitive, wholeWord, useRegex, includePattern, excludePattern, maxResults - }) + } + try { + return await searchWithRg(rootPath, query, options) + } catch (error) { + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } + return searchWithGitGrep(rootPath, query, options) + } } private listFiles(params: Record, context?: RequestContext): Promise { @@ -380,10 +372,14 @@ export class FsHandler { signal: AbortSignal, maxResults?: number ): Promise { - const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) - if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + try { + return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + } catch (error) { + throwIfFileListingCancelled(signal) + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 588ddb5fe51..f54ebaea369 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -54,7 +54,6 @@ vi.mock('./fs-handler-utils', async (importOriginal) => { const original = (await importOriginal()) as Record return { ...original, - checkRgAvailable: () => Promise.resolve(true), listFilesWithRg: fakeListFiles } }) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 60ea5913139..a9d0b63ea8a 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -91,7 +91,36 @@ export async function getStatusOp( ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) - const conflictOperation = await detectConflictOperation(worktreePath) + const conflictPromise = detectConflictOperation(worktreePath) + // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const parser = new StatusPorcelainParser() + const result = await streamGit(statusArgs, worktreePath, { + // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. + disableOptionalLocks: true, + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return { parser, stoppedEarly: result.stoppedEarly } + })() + ]) + const conflictOperation = await conflictPromise const entries: Record[] = [] let head: string | undefined let branch: string | undefined @@ -103,28 +132,11 @@ export async function getStatusOp( let branchLineTotal: GitBranchLineTotal | undefined try { - // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. - const statusArgs = [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ] - if (includeIgnored) { - statusArgs.push('--ignored=matching') - } - const parser = new StatusPorcelainParser() - const { stoppedEarly } = await streamGit(statusArgs, worktreePath, { - // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. - disableOptionalLocks: true, - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } + const { parser, stoppedEarly } = statusResult.value head = parser.branch.head branch = parser.branch.branch ignoredPaths = parser.ignoredPaths diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index e1c18a78d33..43f1d53d5d1 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' describe('parseWorktreeList', () => { @@ -105,72 +104,3 @@ describe('isUnsupportedWorktreeListZError', () => { expect(isUnsupportedWorktreeListZError(error)).toBe(false) }) }) - -describe('parseStatusOutput', () => { - it('parses upstream ahead/behind from porcelain v2 branch headers', () => { - const result = parseStatusOutput( - [ - '# branch.oid abcdef1234567890', - '# branch.head feature/prompts', - '# branch.upstream origin/feature/prompts', - '# branch.ab +2 -3', - '' - ].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 2, - behind: 3 - }) - }) - - it('reports no upstream when porcelain v2 omits branch.upstream', () => { - const result = parseStatusOutput( - ['# branch.oid abcdef1234567890', '# branch.head feature/prompts', ''].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - }) - - it('parses ignored porcelain records separately from actionable entries', () => { - const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) - - expect(result.ignoredPaths).toEqual(['dist/', '.env']) - expect(result.entries).toEqual([ - { path: 'scratch.txt', status: 'untracked', area: 'untracked' } - ]) - }) - - it('parses rename records with spaces in the paths', () => { - const result = parseStatusOutput( - '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' - ) - - expect(result.entries).toEqual([ - { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' } - ]) - }) - - it('parses submodule dirtiness flags from porcelain records', () => { - const result = parseStatusOutput( - '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' - ) - - expect(result.entries).toEqual([ - { - path: 'nested-repo', - status: 'added', - area: 'staged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - }, - { - path: 'nested-repo', - status: 'modified', - area: 'unstaged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.test.ts b/src/relay/git-status-output-parser.test.ts deleted file mode 100644 index ec25e9b72ba..00000000000 --- a/src/relay/git-status-output-parser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' - -describe('parseStatusOutput', () => { - it('marks staged S... submodule rows as commit-changed gitlinks', () => { - const parsed = parseStatusOutput( - '1 M. S... 160000 160000 160000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb flutter_mine\n' - ) - - expect(parsed.entries).toEqual([ - { - path: 'flutter_mine', - status: 'modified', - area: 'staged', - submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts deleted file mode 100644 index 9bb1d344073..00000000000 --- a/src/relay/git-status-output-parser.ts +++ /dev/null @@ -1,177 +0,0 @@ -export function parseStatusChar(char: string): string { - switch (char) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - default: - return 'modified' - } -} - -/** - * Parse `git status --porcelain=v2` output into structured entries. - * Does NOT handle unmerged entries (those require worktree access). - */ -export function parseStatusOutput(stdout: string): { - entries: Record[] - unmergedLines: string[] - ignoredPaths: string[] - head?: string - branch?: string - upstreamStatus: { - hasUpstream: boolean - upstreamName?: string - ahead: number - behind: number - } -} { - const entries: Record[] = [] - const unmergedLines: string[] = [] - const ignoredPaths: string[] = [] - let head: string | undefined - let branch: string | undefined - let upstreamName: string | undefined - let upstreamAheadBehind: { ahead: number; behind: number } | null = null - - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - - if (line.startsWith('# branch.oid ')) { - head = line.slice('# branch.oid '.length).trim() - continue - } - - if (line.startsWith('# branch.head ')) { - const branchHead = line.slice('# branch.head '.length).trim() - branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : '' - continue - } - - if (line.startsWith('# branch.upstream ')) { - upstreamName = line.slice('# branch.upstream '.length).trim() || undefined - continue - } - - if (line.startsWith('# branch.ab ')) { - upstreamAheadBehind = parseBranchAheadBehind(line) - continue - } - - if (line.startsWith('1 ') || line.startsWith('2 ')) { - const parts = line.split(' ') - const xy = parts[1] - const indexStatus = xy[0] - const worktreeStatus = xy[1] - - if (line.startsWith('2 ')) { - // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. - // The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab. - const tabParts = line.split('\t') - const filePath = tabParts[0].split(' ').slice(9).join(' ') - const oldPath = tabParts.slice(1).join('\t') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - oldPath, - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - oldPath, - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } else { - const filePath = parts.slice(8).join(' ') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } - } else if (line.startsWith('? ')) { - entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) - } else if (line.startsWith('! ')) { - ignoredPaths.push(line.slice(2)) - } else if (line.startsWith('u ')) { - unmergedLines.push(line) - } - } - - return { - entries, - unmergedLines, - ignoredPaths, - head, - branch, - upstreamStatus: upstreamName - ? { - hasUpstream: true, - upstreamName, - ahead: upstreamAheadBehind?.ahead ?? 0, - behind: upstreamAheadBehind?.behind ?? 0 - } - : { hasUpstream: false, ahead: 0, behind: 0 } - } -} - -function parseSubmoduleStatus( - submoduleField: string | undefined, - statusChar = '.' -): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined { - if (!submoduleField?.startsWith('S')) { - return undefined - } - return { - commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'), - trackedChanges: submoduleField[2] === 'M', - untrackedChanges: submoduleField[3] === 'U' - } -} - -function submoduleStatusField( - submoduleField: string | undefined, - statusChar: string -): - | { submodule: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } } - | {} { - const submodule = parseSubmoduleStatus(submoduleField, statusChar) - return submodule ? { submodule } : {} -} - -function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null { - const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/) - if (!match) { - return null - } - return { - ahead: Number.parseInt(match[1], 10), - behind: Number.parseInt(match[2], 10) - } -} diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 9df947b1aad..4ed3bce6ecd 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,7 +1,155 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseHexAddress } from './port-scan-handler' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' + +const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + readdirMock: vi.fn(), + readlinkMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + readdir: readdirMock, + readlink: readlinkMock +})) + +import { parseHexAddress, PortScanHandler } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function capturePortDetectHandler(): MethodHandler { + let handler: MethodHandler | undefined + new PortScanHandler({ + onRequest: (method, nextHandler) => { + expect(method).toBe('ports.detect') + handler = nextHandler + } + }) + if (!handler) { + throw new Error('ports.detect handler was not registered') + } + return handler +} + +function requestContext(signal?: AbortSignal): RequestContext { + return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve = (_value: T): void => { + throw new Error('deferred promise was not initialized') + } + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function mockLinuxProcScan({ + pidCount, + fdCount, + firstReadlink +}: { + pidCount: number + fdCount: number + firstReadlink?: Promise +}): void { + const tcpHeader = + 'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode' + const tcpRow = + '0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111' + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp') { + return `${tcpHeader}\n${tcpRow}\n` + } + if (path === '/proc/net/tcp6') { + return `${tcpHeader}\n` + } + if (path.endsWith('/cmdline')) { + return '/usr/bin/node\0server.js' + } + throw new Error(`unexpected readFile: ${path}`) + }) + + const pids = Array.from({ length: pidCount }, (_, index) => String(1_000 + index)) + const fds = Array.from({ length: fdCount }, (_, index) => String(index)) + readdirMock.mockImplementation(async (path: string) => { + if (path === '/proc') { + return pids + } + if (path.endsWith('/fd')) { + return fds + } + throw new Error(`unexpected readdir: ${path}`) + }) + + let first = true + readlinkMock.mockImplementation(() => { + if (first && firstReadlink) { + first = false + return firstReadlink + } + first = false + return Promise.resolve('socket:[11111]') + }) +} + +describe('PortScanHandler Linux cancellation', () => { + it('does not touch procfs for an already-cancelled request', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + capturePortDetectHandler()({}, requestContext(controller.signal)) + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readdirMock).not.toHaveBeenCalled() + expect(readlinkMock).not.toHaveBeenCalled() + }) + + it('stops a large pid and fd walk at the filesystem operation already in flight', async () => { + const firstReadlink = createDeferred() + mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise }) + const controller = new AbortController() + const scan = capturePortDetectHandler()({}, requestContext(controller.signal)) + + await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1)) + controller.abort() + firstReadlink.resolve('socket:[11111]') + + await expect(scan).rejects.toMatchObject({ name: 'AbortError' }) + expect(readdirMock).toHaveBeenCalledTimes(2) + expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc') + expect(readdirMock).toHaveBeenNthCalledWith(2, '/proc/1000/fd') + expect(readlinkMock).toHaveBeenCalledTimes(1) + expect(readlinkMock).toHaveBeenCalledWith('/proc/1000/fd/0') + }) + + it('preserves detected port results when the request stays live', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + + await expect( + capturePortDetectHandler()({}, requestContext(new AbortController().signal)) + ).resolves.toEqual({ + ports: [{ host: '127.0.0.1', port: 3000, pid: 1_000, processName: 'node' }], + platform: 'linux' + }) + }) +}) + describe('parseHexAddress', () => { it('parses IPv4 localhost (127.0.0.1)', () => { // 127.0.0.1 in little-endian hex: 0100007F diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a576d8921fa..40150133a04 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -16,11 +16,11 @@ const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 export class PortScanHandler { - constructor(dispatcher: RelayDispatcher) { + constructor(dispatcher: Pick) { dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { if (process.platform === 'linux') { return { - ports: await this.scanLinuxListeningPorts(), + ports: await this.scanLinuxListeningPorts(context.signal), platform: process.platform } } @@ -37,11 +37,13 @@ export class PortScanHandler { }) } - private async scanLinuxListeningPorts(): Promise { + private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp'), - this.readProcNet('/proc/net/tcp6') + this.readProcNet('/proc/net/tcp', signal), + this.readProcNet('/proc/net/tcp6', signal) ]) + signal?.throwIfAborted() const listeningSockets = [...tcp4, ...tcp6] if (listeningSockets.length === 0) { @@ -49,7 +51,7 @@ export class PortScanHandler { } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet) + const inodeToPid = await this.mapInodesToPids(inodeSet, signal) const seen = new Set() const results: DetectedPort[] = [] @@ -57,6 +59,7 @@ export class PortScanHandler { const relayParentPid = process.ppid for (const socket of listeningSockets) { + signal?.throwIfAborted() const key = `${socket.host}:${socket.port}` if (seen.has(key)) { continue @@ -72,7 +75,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid) : undefined + const processName = pid != null ? await this.getProcessName(pid, signal) : undefined if (processName === 'sshd') { continue @@ -93,14 +96,18 @@ export class PortScanHandler { } private async readProcNet( - path: string + path: string, + signal?: AbortSignal ): Promise<{ port: number; host: string; inode: number }[]> { + signal?.throwIfAborted() let content: string try { content = await readFile(path, 'utf-8') } catch { + signal?.throwIfAborted() return [] } + signal?.throwIfAborted() const lines = content.split('\n') const results: { port: number; host: string; inode: number }[] = [] @@ -133,7 +140,11 @@ export class PortScanHandler { return results } - private async mapInodesToPids(inodes: Set): Promise> { + private async mapInodesToPids( + inodes: Set, + signal?: AbortSignal + ): Promise> { + signal?.throwIfAborted() const result = new Map() if (inodes.size === 0) { return result @@ -143,27 +154,35 @@ export class PortScanHandler { try { pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) } catch { + signal?.throwIfAborted() return result } + signal?.throwIfAborted() for (const pidStr of pids) { + signal?.throwIfAborted() const fdDir = `/proc/${pidStr}/fd` let fds: string[] try { fds = await readdir(fdDir) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const pid = Number.parseInt(pidStr, 10) for (const fd of fds) { + signal?.throwIfAborted() let link: string try { link = await readlink(`${fdDir}/${fd}`) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const match = link.match(/^socket:\[(\d+)\]$/) if (!match) { @@ -180,9 +199,11 @@ export class PortScanHandler { return result } - private async getProcessName(pid: number): Promise { + private async getProcessName(pid: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() try { const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') + signal?.throwIfAborted() if (!cmdline) { return undefined } @@ -195,6 +216,7 @@ export class PortScanHandler { const parts = exe.split('/') return parts.at(-1) } catch { + signal?.throwIfAborted() return undefined } } diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7529436ca93..87ddfd2ee3a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -361,6 +361,9 @@ const AddProjectFromFolderDialog = lazy( ) const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog')) const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) +const PreservedBranchBatchReviewModal = lazy( + () => import('./components/sidebar/PreservedBranchBatchReviewModal') +) const DictationController = lazy(() => import('./components/dictation/DictationController').then((module) => ({ default: module.DictationController @@ -742,14 +745,7 @@ function App(): React.JSX.Element { const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) const unmountAddRepoDialogTimerRef = useRef | null>(null) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) - const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) - const onboardingSettingsDetourActive = - onboardingSettingsDetour && activeView === 'settings' && shouldRenderOnboarding - if (onboardingSettingsDetour && !onboardingSettingsDetourActive) { - // Why: the detour is valid only while Settings is onscreen; clear it during render so onboarding resumes without an extra Effect pass. - setOnboardingSettingsDetour(false) - } useEffect(() => { if (activeModal === 'add-repo') { @@ -872,10 +868,6 @@ function App(): React.JSX.Element { settings ]) - const beginOnboardingSettingsDetour = useCallback(() => { - setOnboardingSettingsDetour(true) - }, []) - // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. useLayoutEffect(() => { window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) @@ -2347,7 +2339,8 @@ function App(): React.JSX.Element { ) ) : null}
- {stackedSidebarOpen ? ( + {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} + {stackedSidebarOpen && activeView !== 'automations' ? (
{titlebarMainStrip}
) : null}
@@ -2703,6 +2696,16 @@ function App(): React.JSX.Element { ) : null} + {activeModal === 'preserved-branch-review' ? ( + + + + ) : null} {hasSshCredentialRequest ? ( @@ -2738,23 +2741,18 @@ function App(): React.JSX.Element { > - {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( + {onboarding && shouldRenderOnboarding ? ( - + ) : null} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 85d6a2c116a..099478745e4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1027,11 +1027,6 @@ html.native-shell .app-layout { color: var(--muted-foreground); } -.titlebar-profile-switcher { - -webkit-app-region: no-drag; - flex-shrink: 0; -} - .editor-header { display: flex; align-items: center; @@ -2599,106 +2594,6 @@ html.native-shell .app-layout { animation: feature-wall-meta-pulse 1100ms ease-out 1; } -/* Onboarding step 5: the compact "Take the tour" intro expands into the - full tour shell, so animate the browser snapshots to avoid a hard jump. */ -html.onboarding-tour-start-transition::view-transition-old(root), -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-duration: 760ms; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; - mix-blend-mode: normal; -} - -html.onboarding-tour-start-transition::view-transition-old(root) { - animation-name: onboarding-tour-start-old; -} - -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-name: onboarding-tour-start-new; -} - -@keyframes onboarding-tour-start-old { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 28% { - opacity: 0.9; - transform: translateY(-2px) scale(0.998); - } - 42% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } - 100% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } -} - -@keyframes onboarding-tour-start-new { - 0% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 34% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* ── Feature tour nudge: cycling preview ─────────────────────────── */ - -/* Why: the bottom-right nudge cycles through 4 silhouette frames - (Workspace → Orchestration → Tasks → Terminal). Each frame is visible for - 1/4 of the cycle. Holding fade-out late (24%–25%) gives the user dwell - time on the resolved state before swapping. Uses CSS-driven timing rather - than React state so we stay cheap when the popup is mounted but idle. */ -:root { - --feature-tour-cycle: 16s; -} - -.feature-tour-frame { - position: absolute; - inset: 0; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-frame[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-frame[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-frame[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-frame[data-frame='4'] { - animation-delay: 12s; -} - -@keyframes feature-tour-frame-fade { - 0% { - opacity: 0; - } - 1.5% { - opacity: 1; - } - 24% { - opacity: 1; - } - 25% { - opacity: 0; - } - 100% { - opacity: 0; - } -} - .orca-contextual-tour-target-rings { border-radius: 9999px; pointer-events: none; @@ -2736,520 +2631,6 @@ html.onboarding-tour-start-transition::view-transition-new(root) { } } -.feature-tour-copy-slide { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 4px; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-copy-slide[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-copy-slide[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-copy-slide[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-copy-slide[data-frame='4'] { - animation-delay: 12s; -} - -.feature-tour-dot { - width: 5px; - height: 5px; - border-radius: 9999px; - background: color-mix(in srgb, var(--foreground) 18%, transparent); - animation: feature-tour-dot-pulse var(--feature-tour-cycle) linear infinite; -} -.feature-tour-dot[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-dot[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-dot[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-dot[data-frame='4'] { - animation-delay: 12s; -} -@keyframes feature-tour-dot-pulse { - 0%, - 24% { - background: var(--primary); - transform: scale(1.05); - } - 25%, - 100% { - background: color-mix(in srgb, var(--foreground) 18%, transparent); - transform: scale(1); - } -} - -/* Frame 1 — Workspace: agent-state dot pulses while a "fix login race" - workspace shows two agent rows working in parallel. */ -.feature-tour-ws-status { - animation: feature-tour-status-pulse 1.6s ease-in-out infinite; -} -@keyframes feature-tour-status-pulse { - 0%, - 100% { - opacity: 0.95; - transform: scale(1); - } - 50% { - opacity: 0.55; - transform: scale(0.85); - } -} - -/* Frame 2 — Orchestration: child cards flash as bubbles land along the - dashed paths fanning down from the coordinator. */ -/* Why: bubbles use percent-based left/top so they always travel from the - root card's right edge (34%) to the child card's left edge (64%), - regardless of preview width. The previous offset-path used hardcoded - pixels and drifted off the dashed lines once the preview resized. */ -.feature-tour-orch-bubble { - position: absolute; - left: 34%; - top: 50%; - width: 12px; - height: 12px; - margin-left: -6px; - margin-top: -6px; - border-radius: 3px; - background: var(--primary); - color: var(--primary-foreground); - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - z-index: 4; -} -.feature-tour-orch-bubble.top { - animation: feature-tour-orch-travel-top var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: 4s; -} -.feature-tour-orch-bubble.mid { - animation: feature-tour-orch-travel-mid var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-bubble.bot { - animation: feature-tour-orch-travel-bot var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-travel-top { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 18%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 18%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 18%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-mid { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 6.5% { - left: 64%; - top: 50%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 50%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 50%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-bot { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 82%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 82%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 82%; - opacity: 0; - } -} - -.feature-tour-orch-child { - animation: feature-tour-orch-child-flash var(--feature-tour-cycle) linear infinite; -} -.feature-tour-orch-child.top { - animation-delay: 4s; -} -.feature-tour-orch-child.mid { - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-child.bot { - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-child-flash { - 0%, - 6% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 7% { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); - border-color: color-mix(in srgb, var(--foreground) 38%, transparent); - } - 9% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 25%, - 100% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } -} - -/* Frame 3 — Tasks: cursor lands on the second row, the "Open" pill morphs - to a dark "Start workspace →" pill, then a workspace card slides in. */ -.feature-tour-tasks-cursor { - /* Why: anchored from the row's right edge so the cursor sits just past the - pill's right edge (12px row padding + ~6px past pill end) instead of - covering the label text. The arrow tip (top-left of the SVG) lands on the - dark pill's trailing edge with the body of the cursor extending right. */ - position: absolute; - top: 50%; - right: 14px; - width: 14px; - height: 14px; - pointer-events: none; - z-index: 8; - transform: translate(120px, -30%); - opacity: 0; - animation: feature-tour-tasks-cursor var(--feature-tour-cycle) cubic-bezier(0.45, 0.05, 0.2, 1) - infinite; - animation-delay: 8s; -} -.feature-tour-tasks-cursor svg { - width: 14px; - height: 14px; - display: block; -} -@keyframes feature-tour-tasks-cursor { - 0% { - transform: translate(160px, -10%); - opacity: 0; - } - 2% { - opacity: 1; - } - 5% { - transform: translate(60px, -30%); - opacity: 1; - } - 8% { - transform: translate(0px, -30%); - opacity: 1; - } - 9% { - transform: translate(0px, -10%); - opacity: 1; - } - 10.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 23.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - transform: translate(0px, -30%); - opacity: 0; - } -} - -.feature-tour-tasks-row { - animation: feature-tour-tasks-row-hover var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-row-hover { - 0%, - 4% { - background: transparent; - box-shadow: none; - } - 6% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 23.5% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 25%, - 100% { - background: transparent; - box-shadow: none; - } -} - -.feature-tour-tasks-pill { - animation: feature-tour-tasks-pill-morph var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-morph { - 0%, - 6% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } - 8% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 23.5% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 25%, - 100% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } -} - -.feature-tour-tasks-pill-label { - opacity: 0; - animation: feature-tour-tasks-pill-label var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-label { - 0%, - 7% { - opacity: 0; - } - 9% { - opacity: 1; - } - 23.5% { - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - opacity: 0; - } -} - -/* Click ring fires when the cursor "clicks" the morphed pill on the tasks - row. Positioned to overlay the right-side pill of the second task. */ -.feature-tour-tasks-click-ring { - /* Why: ring is centered on the cursor tip (right=28 from row's right edge) - so the click feedback lands where the cursor is, on the arrow icon — not - centered on the pill, which would visually disconnect from the cursor. */ - position: absolute; - top: 50%; - right: 14px; - width: 28px; - height: 28px; - margin-top: -14px; - border-radius: 9999px; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - opacity: 0; - z-index: 7; - pointer-events: none; - animation: feature-tour-tasks-click-ring var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-click-ring { - 0%, - 7% { - opacity: 0; - transform: scale(0.4); - } - 8% { - opacity: 0.85; - transform: scale(0.6); - } - 10% { - opacity: 0; - transform: scale(1.4); - } - 25%, - 100% { - opacity: 0; - transform: scale(1.4); - } -} - -/* Newly-created workspace card slides in beneath the task row once the - user clicks "Start workspace" — sells the "task → workspace" jump. */ -.feature-tour-tasks-workspace { - opacity: 0; - transform: translateY(-6px); - animation: feature-tour-tasks-workspace var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-workspace { - 0%, - 10% { - opacity: 0; - transform: translateY(-6px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(-6px); - } -} - -/* Frame 4 — Terminal: command stays anchored while the split panes stream - Playwright and Claude activity. */ -.feature-tour-terminal-line { - width: 23ch; -} - -.feature-tour-terminal-output { - opacity: 0; - transform: translateY(2px); - animation: feature-tour-terminal-output var(--feature-tour-cycle) linear infinite; - animation-delay: 12s; -} -.feature-tour-terminal-output[data-line='1'] { - animation-delay: calc(12s + 0.05s); -} -.feature-tour-terminal-output[data-line='2'] { - animation-delay: calc(12s + 0.25s); -} -.feature-tour-terminal-output[data-line='3'] { - animation-delay: calc(12s + 0.45s); -} -@keyframes feature-tour-terminal-output { - 0%, - 10% { - opacity: 0; - transform: translateY(2px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(2px); - } -} - -@media (prefers-reduced-motion: reduce) { - .feature-tour-frame, - .feature-tour-copy-slide, - .feature-tour-dot, - .feature-tour-ws-status, - .feature-tour-orch-bubble, - .feature-tour-orch-child, - .feature-tour-tasks-cursor, - .feature-tour-tasks-row, - .feature-tour-tasks-pill, - .feature-tour-tasks-pill-label, - .feature-tour-tasks-click-ring, - .feature-tour-tasks-workspace, - .feature-tour-terminal-output { - animation: none !important; - } - .feature-tour-frame[data-frame='1'], - .feature-tour-copy-slide[data-frame='1'] { - opacity: 1; - } - .feature-tour-dot[data-frame='1'] { - background: var(--primary); - } -} - /* ── Diff comment decorations ────────────────────────────────────── */ /* Why: the "+" button is appended into Monaco's editor DOM node by diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0e50c3bbc50..0d415d8d2d8 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -113,10 +113,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -124,11 +126,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { getCommentReplyTargetCandidates, resolveCommentReplyTarget diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 4b46d0148e5..3dc3dcc7531 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -109,10 +109,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -120,11 +122,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { attachPRReviewReplyParent, diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx new file mode 100644 index 00000000000..fa0c57dfcf8 --- /dev/null +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import QuickOpen from './QuickOpen' + +const contentProbe = vi.hoisted(() => ({ + enabled: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + subscriptions: vi.fn(), + unsubscriptions: vi.fn() +})) + +vi.mock('@/components/quick-open-file-list', async () => { + const React = await import('react') + return { + useRuntimeFileListForWorktree: ({ enabled }: { enabled: boolean }) => { + contentProbe.enabled(enabled) + contentProbe.renders() + React.useEffect(() => { + contentProbe.subscriptions() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unsubscriptions() + unsubscribe() + } + }, []) + return { files: [], loading: false, loadError: null } + } + } +}) + +vi.mock('@/hooks/useModalReturnFocus', () => ({ + useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/command', () => { + return { + CommandDialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: () => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandItem: ({ children }: { children: ReactNode }) =>
{children}
+ } +}) + +const initialAppState = useAppStore.getInitialState() +let testContainer: HTMLDivElement +let testRoot: Root + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeContentSubscriptions(): number { + return ( + contentProbe.subscriptions.mock.calls.length - contentProbe.unsubscriptions.mock.calls.length + ) +} + +async function churnClosedStore(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ activeWorktreeId: `remote-background-${index}` }) + } + }) +} + +describe('QuickOpen mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + contentProbe.enabled.mockClear() + contentProbe.renders.mockClear() + contentProbe.storeNotifications.mockClear() + contentProbe.subscriptions.mockClear() + contentProbe.unsubscriptions.mockClear() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ activeModal: 'quick-open', activeWorktreeId: null }) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts subscribed content after the close linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).toBeNull() + expect(contentProbe.enabled).toHaveBeenLastCalledWith(false) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(activeContentSubscriptions()).toBe(0) + + const rendersAfterUnmount = contentProbe.renders.mock.calls.length + const notificationsAfterUnmount = contentProbe.storeNotifications.mock.calls.length + await churnClosedStore(1_000) + + expect(contentProbe.renders).toHaveBeenCalledTimes(rendersAfterUnmount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(notificationsAfterUnmount) + }) + + it('cancels the pending unmount when reopened during the linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await act(async () => useAppStore.setState({ activeModal: 'quick-open' })) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 05e2613ce0d..cb7c8847642 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' @@ -21,6 +21,8 @@ import { QuickOpenInstallRgGuidance } from '@/components/quick-open-install-rg-guidance' +const QUICK_OPEN_CLOSE_LINGER_MS = 300 + function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element { return ( @@ -31,6 +33,24 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme export default function QuickOpen(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'quick-open') + const [lingering, setLingering] = useState(visible) + useEffect(() => { + if (visible) { + setLingering(true) + return + } + // Why: keep scan cancellation and the dialog exit animation mounted before releasing remote file state. + const timer = window.setTimeout(() => setLingering(false), QUICK_OPEN_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [visible]) + + if (!visible && !lingering) { + return null + } + return +} + +function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element { const closeModal = useAppStore((s) => s.closeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const openFile = useAppStore((s) => s.openFile) diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index e036dcef419..25a9f137397 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,6 +7,14 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { ExternalAutomationAction, @@ -22,6 +30,13 @@ import { } from './external-automation-display' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListExternalRows({ @@ -52,10 +67,8 @@ export function AutomationListExternalRows({ {entries.map((entry) => { const providerLabel = getExternalProviderLabel(entry.manager) const targetKindLabel = getExternalTargetKindLabel(entry.manager) - const nextRunLabel = entry.job.enabled - ? formatExternalDate(entry.job.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const entrySshStatus = + const isSelected = selectedExternalKey === entry.key + const sshStatus = entry.manager.target.type === 'ssh' ? sshConnectionStates.get(entry.manager.target.connectionId)?.status : undefined @@ -63,66 +76,140 @@ export function AutomationListExternalRows({ manager: entry.manager, providerLabel, targetKindLabel, - sshStatus: entrySshStatus, + sshStatus, actionInProgress: externalActionKey !== null }) const actionDisabled = disabledMessage !== null - const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label + const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}` + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + return ( - + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate( + 'auto.components.automations.AutomationsPage.f4612e3f78', + 'Edit' + )} + + ) : null} + + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume' + ) + } + > + {entry.job.enabled ? ( + + ) : ( + + )} + {entry.job.enabled ? translate( - 'auto.components.automations.AutomationsPage.runCount', - '{{count}} runs', - { count: entry.job.runCount } + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' ) - : entry.manager.canManage - ? translate( - 'auto.components.automations.AutomationsPage.aecdc3681f', - 'Manageable' - ) - : translate( - 'auto.components.automations.AutomationsPage.e059042585', - 'Read-only' - )} - - - - - - {nextRunLabel} - - + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'automation-1', + name: 'test', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'worktree-1', + baseBranch: null, + reuseSession: false, + timezone: 'America/Los_Angeles', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: Date.now() + 60_000, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeExternalJob(overrides: Partial = {}): ExternalAutomationJob { + return { + id: 'job-1', + managerId: 'manager-1', + provider: 'hermes', + name: 'Nightly', + schedule: '0 0 * * *', + rawSchedule: null, + enabled: true, + state: 'enabled', + prompt: null, + promptPreview: '', + nextRunAt: new Date(Date.now() + 60_000).toISOString(), + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [], + ...overrides + } +} + +function makeExternalManager(): ExternalAutomationManager { + return { + id: 'manager-1', + provider: 'hermes', + label: 'Local Hermes', + targetLabel: 'Local', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [] + } +} + +function renderLocalRows(handlers: { + onSelect: (automationId: string) => void + onDelete?: (automation: Automation) => void +}) { + return render( + + ) +} + +function renderExternalRows(handlers: { + onSelect: (entryKey: string) => void + onRequestAction?: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: 'run' | 'pause' | 'resume' | 'delete' + ) => void +}) { + const manager = makeExternalManager() + const job = makeExternalJob() + return render( + + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('Automation list row selection', () => { + it('does not open local detail when a quick-action menu item is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Selecting Delete must not call + // onSelect (which opens the detail page behind the confirm dialog). + const onSelect = vi.fn() + const onDelete = vi.fn() + renderLocalRows({ onSelect, onDelete }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('still selects when the local row itself is clicked', async () => { + const onSelect = vi.fn() + const { container } = renderLocalRows({ onSelect }) + const user = userEvent.setup() + + const row = container.querySelector('[data-slot="context-menu-trigger"]') + expect(row).not.toBeNull() + await user.click(row as Element) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith('automation-1') + }) + + it('opens the actions menu on keyboard activation without selecting the row', async () => { + // The row handles Enter/Space for its own activation. Those keys bubble + // from the actions trigger too, so an unguarded row handler would + // preventDefault the button's native activation and open detail instead. + const onSelect = vi.fn() + renderLocalRows({ onSelect }) + const user = userEvent.setup() + + screen.getByRole('button', { name: 'Automation actions' }).focus() + await user.keyboard('{Enter}') + + expect(await screen.findByRole('menuitem', { name: 'Delete' })).toBeTruthy() + expect(onSelect).not.toHaveBeenCalled() + }) + + it('does not open external detail when a quick-action menu item is chosen', async () => { + const onSelect = vi.fn() + const onRequestAction = vi.fn() + renderExternalRows({ onSelect, onRequestAction }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestAction).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 1a5a4994aac..97ec2c7bb5f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,24 +7,41 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import { + getExecutionHostLabel, + getLocalExecutionHostLabel, + getRepoExecutionHostId +} from '../../../../shared/execution-host' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAgentLabel } from './automation-draft-model' +import { formatAutomationCost } from './automation-usage-model' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListLocalRows({ @@ -40,6 +57,7 @@ export function AutomationListLocalRows({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, onSelect, onRunNow, onEdit, @@ -61,6 +79,7 @@ export function AutomationListLocalRows({ > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap onSelect: (automationId: string) => void onRunNow: (automation: Automation) => void onEdit: (automation: Automation) => void @@ -69,18 +88,17 @@ export function AutomationListLocalRows({ }): React.JSX.Element { // Why: one pass over runs instead of a full scan per rendered automation — // this list re-renders on the relativeNow timer. - const runsByAutomationId = React.useMemo(() => { - const grouped = new Map() + const lastRunByAutomationId = React.useMemo(() => { + const latest = new Map() for (const run of runs) { - const existing = grouped.get(run.automationId) - if (existing) { - existing.push(run) - } else { - grouped.set(run.automationId, [run]) + const existing = latest.get(run.automationId) + if (!existing || run.createdAt > existing.createdAt) { + latest.set(run.automationId, run) } } - return grouped + return latest }, [runs]) + return ( <> {automations.map((automation) => { @@ -98,148 +116,237 @@ export function AutomationListLocalRows({ automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) - const baseRefLabel = - automation.baseBranch ?? - automationRepo?.worktreeBaseRef ?? - translate( - 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', - 'project default' - ) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.createFromBaseRef', - 'Create from {{baseRef}}', - { baseRef: baseRefLabel } - ) - : (automationWorktree?.displayName ?? - translate( - 'auto.components.automations.AutomationsPage.missingWorkspace', - 'Missing workspace' - )) - const usageSummary = summarizeAutomationRunUsage( - runsByAutomationId.get(automation.id) ?? [] - ) - const usageText = - usageSummary.knownRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.runUsageSummary', - '{{cost}} est. · {{tokens}} tokens', - { - cost: formatAutomationCost(usageSummary.estimatedCostUsd), - tokens: formatAutomationTokens(usageSummary.totalTokens) - } - ) - : usageSummary.unavailableRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.usageUnavailable', - 'Usage unavailable' - ) - : translate( - 'auto.components.automations.AutomationsPage.noRunUsageYet', - 'No run usage yet' - ) + const projectLabel = + automationRepo?.displayName ?? + translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') + const scheduleLabel = formatAutomationSchedule(automation.rrule) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const scheduleLabel = formatAutomationSchedule(automation.rrule) + const isSelected = isSelectedLocal && selectedId === automation.id + const agentLabel = getAgentLabel(automation.agentId) + const hostId = + automation.runContext?.hostId ?? + (automationRepo ? getRepoExecutionHostId(automationRepo) : null) + const hostLabel = hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel() + const lastRun = lastRunByAutomationId.get(automation.id) + const lastRunCost = + lastRun?.usage?.status === 'known' + ? formatAutomationCost(lastRun.usage.estimatedCostUsd) + : formatAutomationCost(null) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${lastRunCost}` + + const actionItems = ( + <> + onRunNow(automation)} + /> + } + label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + onSelect={() => onEdit(automation)} + /> + : + } + label={ + automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') + } + onSelect={() => onToggle(automation)} + /> + + } + label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + variant="destructive" + onSelect={() => onDelete(automation)} + /> + + ) + return ( - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() +
{ + // Why: Radix portals menus out of the row DOM, but React still + // bubbles those clicks here — ignore so menu actions don't open detail. + if (isPortaledRowMenuClick(event)) { return } - onRunNow(automation) + onSelect(automation.id) }} - > - - - {automationRunAvailability.canRunNow - ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') - : automationRunAvailability.message} - - - onEdit(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - onToggle(automation)}> - {automation.enabled ? ( - - ) : ( - + onKeyDown={(event) => { + if (!isRowActivationKey(event)) { + return + } + event.preventDefault() + onSelect(automation.id) + }} + className={cn( + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + isSelected && AUTOMATIONS_TABLE_ROW_SELECTED_CLASS )} - {automation.enabled - ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') - : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} - - - onDelete(automation)}> - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} - - + > + {automation.name} + + {scheduleLabel} + + + {projectLabel} + + + {nextRunLabel} + + + + + + + + + + {agentTooltipLabel} + + + + + + + + { + if (automationRunAvailability.canRunNow) { + onRunNow(automation) + } + }} + > + + + {automationRunAvailability.canRunNow + ? translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + ) + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate( + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' + ) + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onDelete(automation)}> + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
+ + {actionItems}
) })} ) } + +function MenuRunItem({ + disabled, + label, + onSelect +}: { + disabled: boolean + label: string + onSelect: () => void +}): React.JSX.Element { + return ( + { + if (disabled) { + event.preventDefault() + return + } + onSelect() + }} + > + + {label} + + ) +} + +function MenuItem({ + icon, + label, + onSelect, + variant +}: { + icon: React.ReactNode + label: string + onSelect: () => void + variant?: 'destructive' +}): React.JSX.Element { + return ( + + {icon} + {label} + + ) +} + +function MenuSeparator(): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index d1691a49036..fead100d80e 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -34,6 +34,7 @@ export function AutomationListSearchField({ onQueryChange(event.target.value)} diff --git a/src/renderer/src/components/automations/AutomationListStatusCell.tsx b/src/renderer/src/components/automations/AutomationListStatusCell.tsx new file mode 100644 index 00000000000..b8da08336de --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListStatusCell.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { X } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function AutomationListStatusCell({ enabled }: { enabled: boolean }): React.JSX.Element { + if (enabled) { + return ( + + + {translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')} + + ) + } + return ( + + + {translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 1864e085680..48ffceba88f 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Eye, RefreshCw } from 'lucide-react' +import { ArrowLeft, Eye, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' @@ -80,6 +80,7 @@ type AutomationsDetailPaneProps = { rerunAutomationRun: (automation: Automation, run: AutomationRun) => void openRunWorkspace: (run: AutomationRun) => void openAutomationRunPage: (run: AutomationRun) => void + onBackToList: () => void } export function AutomationsDetailPane({ @@ -114,12 +115,22 @@ export function AutomationsDetailPane({ requestDeleteAutomation, rerunAutomationRun, openRunWorkspace, - openAutomationRunPage + openAutomationRunPage, + onBackToList }: AutomationsDetailPaneProps): React.JSX.Element { return ( -
+
{selectedExternal ? ( -
+
+
+ +
{selectedExternalRunPage ? (
+ {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 31d9cc9d6d9..a9c246fe782 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -1,6 +1,8 @@ import React from 'react' -import { Plus } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { Automation, AutomationRun, @@ -20,6 +22,11 @@ import { getAutomationTemplates, type AutomationTemplate } from './automation-te import type { ExternalAutomationListEntry } from './external-automation-list-entries' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' import { translate } from '@/i18n/i18n' type AutomationsListPanelProps = { @@ -31,8 +38,10 @@ type AutomationsListPanelProps = { onListSearchQueryChange: (query: string) => void filteredAutomations: readonly Automation[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] - selected: Automation | null - selectedExternal: ExternalAutomationListEntry | null + // Why: use explicit ids, not resolved detail selection (which falls back to the + // first row) — list highlight should only mark a user-chosen row. + selectedId: string | null + selectedExternalKey: string | null runs: readonly AutomationRun[] relativeNow: number repoMap: ReadonlyMap @@ -45,6 +54,7 @@ type AutomationsListPanelProps = { > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap externalActionKey: string | null selectAutomationId: (automationId: string | null) => void selectExternalKey: (externalKey: string | null) => void @@ -60,6 +70,9 @@ type AutomationsListPanelProps = { ) => void openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void openCreateDialog: (template?: AutomationTemplate) => void + onOpenDetail: () => void + onRefresh: () => void + isRefreshing: boolean } export function AutomationsListPanel({ @@ -71,8 +84,8 @@ export function AutomationsListPanel({ onListSearchQueryChange, filteredAutomations, filteredExternalAutomationEntries, - selected, - selectedExternal, + selectedId, + selectedExternalKey, runs, relativeNow, repoMap, @@ -82,6 +95,7 @@ export function AutomationsListPanel({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, externalActionKey, selectAutomationId, selectExternalKey, @@ -92,81 +106,19 @@ export function AutomationsListPanel({ requestDeleteAutomation, requestExternalAction, openEditExternalDialog, - openCreateDialog + openCreateDialog, + onOpenDetail, + onRefresh, + isRefreshing }: AutomationsListPanelProps): React.JSX.Element { return (
- {hasListItems ? ( -
- - onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) - } - onClear={() => onListSearchQueryChange('')} - /> -
- ) : null} -
- {hasFilteredListItems ? ( -
- - {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - { - selectExternalKey(null) - selectAutomationId(automationId) - }} - onRunNow={runNow} - onEdit={openEditDialog} - onToggle={toggleAutomation} - onDelete={requestDeleteAutomation} - /> - { - selectExternalKey(entryKey) - setActivePaneTab('overview') - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> - {hasListItems && isListSearchActive && !hasFilteredListItems ? ( -
- {translate( - 'auto.components.automations.AutomationsPage.noSearchMatches', - 'No automations match your search.' - )} -
- ) : null} - {!hasListItems ? ( -
+ {!hasListItems ? ( +
+
{translate( 'auto.components.automations.AutomationsPage.d207ab4c25', @@ -199,8 +151,157 @@ export function AutomationsListPanel({ {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
- ) : null} -
+
+ ) : ( +
+
+
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> + + + + + + {translate( + 'auto.components.automations.AutomationsPage.19a6e30eae', + 'Refresh automations' + )} + + +
+ +
+ +
+ {hasFilteredListItems ? ( + <> +
+ + {translate('auto.components.automations.AutomationsPage.tableName', 'Name')} + + + {translate( + 'auto.components.automations.AutomationDetail.18763ded26', + 'Schedule' + )} + + + {translate( + 'auto.components.automations.AutomationsPage.tableProject', + 'Project' + )} + + + {translate( + 'auto.components.automations.AutomationDetail.578ff46987', + 'Next run' + )} + + + {translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')} + + + {translate('auto.components.automations.AutomationDetail.2df8970cd5', 'Agent')} + + + {translate( + 'auto.components.automations.AutomationsPage.tableActions', + 'Actions' + )} + +
+
+ { + selectExternalKey(null) + selectAutomationId(automationId) + onOpenDetail() + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectAutomationId(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> +
+ + ) : isListSearchActive ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} +
+
+ )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7293b79ad22..996f474a7c9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,16 +1,12 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' -import { cn } from '@/lib/utils' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -108,10 +104,12 @@ import { isMissingExternalRunsApiError } from './external-automation-display' import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' import { useAutomationListSearch } from './use-automation-list-search' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' import { AutomationsListPanel } from './AutomationsListPanel' import { AutomationsDetailPane } from './AutomationsDetailPane' +import { AutomationsPageSkeleton } from './AutomationsPageSkeleton' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' @@ -186,6 +184,17 @@ export default function AutomationsPage(): React.JSX.Element { const [selectedExternalKey, setSelectedExternalKey] = useState(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) + // Why: list is the primary surface; detail is a full-page drill-in, not a side pane. + const [isDetailOpen, setIsDetailOpen] = useState(false) + const selectedExternalKeyRef = useRef(null) + const isDetailOpenRef = useRef(false) + // Keep async refresh/delete handlers reading the latest selection without render-time mutation. + useEffect(() => { + selectedExternalKeyRef.current = selectedExternalKey + }, [selectedExternalKey]) + useEffect(() => { + isDetailOpenRef.current = isDetailOpen + }, [isDetailOpen]) const runtimePreflightMountedRef = useRef(true) const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< @@ -210,7 +219,7 @@ export default function AutomationsPage(): React.JSX.Element { } | null>(null) useContextualTour( 'automations', - !createOpen && !deleteTarget && !externalDeleteTarget, + !isLoading && !createOpen && !deleteTarget && !externalDeleteTarget, 'automations_open' ) const [editingExternalTarget, setEditingExternalTarget] = useState<{ @@ -429,6 +438,11 @@ export default function AutomationsPage(): React.JSX.Element { const pendingAutomation = automations.find( (automation) => automation.id === pending.automationId ) + // Why: external selection wins over local in detail resolution; clear it so + // pending local navigation cannot open the wrong automation. + if (selectedExternalKey !== null) { + selectExternalKey(null) + } if (!pendingAutomation) { // Why: stale provenance should not silently select the first automation. setSelectedId(pending.automationId) @@ -444,9 +458,11 @@ export default function AutomationsPage(): React.JSX.Element { } if (selectedId !== pending.automationId) { setSelectedId(pending.automationId) + setIsDetailOpen(true) return } if (!pending.runId) { + setIsDetailOpen(true) setActivePaneTab('overview') setSelectedAutomationRunPageId(null) setPendingAutomationRunNavigation(null) @@ -455,6 +471,7 @@ export default function AutomationsPage(): React.JSX.Element { if (selectedAutomationRuns.automationId !== pending.automationId) { return } + setIsDetailOpen(true) setActivePaneTab('runs') const pendingRun = selectedRuns.find((run) => run.id === pending.runId) if (pendingRun) { @@ -475,7 +492,9 @@ export default function AutomationsPage(): React.JSX.Element { automationHostTargetKey, isLoading, pendingAutomationRunNavigation, + selectExternalKey, selectedAutomationRuns.automationId, + selectedExternalKey, selectedId, selectedRuns, setPendingAutomationRunNavigation, @@ -761,6 +780,21 @@ export default function AutomationsPage(): React.JSX.Element { setExternalManagers(nextExternalManagers) if (!hasCurrentSelection && !pendingNavigation) { selectAutomationId(nextAutomations[0]?.id ?? null) + if ( + shouldCloseDetailForLostSelection({ + isDetailOpen: isDetailOpenRef.current, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: buildExternalAutomationListEntries( + nextExternalManagers + ).some((entry) => entry.key === selectedExternalKeyRef.current) + }) + ) { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + } } } finally { setIsLoading(false) @@ -1468,6 +1502,9 @@ export default function AutomationsPage(): React.JSX.Element { await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setActivePaneTab('overview') } await refresh() } @@ -1608,6 +1645,16 @@ export default function AutomationsPage(): React.JSX.Element { useAppStore.getState().recordFeatureInteraction('automation-run') } await refresh() + // Why: full-page detail keeps selection when the deleted external was open; + // without this, detail can fall through to an unrelated local automation. + if (action === 'delete') { + const deletedKey = getExternalAutomationKey(manager, job) + if (selectedExternalKeyRef.current === deletedKey) { + selectExternalKey(null) + setIsDetailOpen(false) + setActivePaneTab('overview') + } + } toast.success( action === 'delete' ? translate( @@ -1800,91 +1847,53 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: detail is a full-page drill-in; step out of nested run views first, + // then return to the table before leaving Automations. + if (isDetailOpen) { + event.preventDefault() + if (selectedExternalRunPage) { + setSelectedExternalRunPage(null) + return + } + if (selectedAutomationRunPageId) { + setSelectedAutomationRunPageId(null) + return + } + setIsDetailOpen(false) + setActivePaneTab('overview') + return + } + event.preventDefault() closeAutomationsPage() } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [closeAutomationsPage, createOpen, deleteTarget, externalDeleteTarget]) + }, [ + closeAutomationsPage, + createOpen, + deleteTarget, + externalDeleteTarget, + isDetailOpen, + selectedAutomationRunPageId, + selectedExternalRunPage + ]) return ( -
-
-
- - - - - - {translate('auto.components.automations.AutomationsPage.0329f9bef1', 'Close · Esc')} - - -
- -

- {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} -

- - - - - - {translate( - 'auto.components.automations.AutomationsPage.8d1afa8269', - 'Add automation' - )} - - -
-
- - - - - - {translate( - 'auto.components.automations.AutomationsPage.19a6e30eae', - 'Refresh automations' - )} - - -
+
+
+

+ {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} +

void confirmDeleteExternalAutomation()} /> -
- void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} - requestDeleteAutomation={requestDeleteAutomation} - requestExternalAction={requestExternalAction} - openEditExternalDialog={openEditExternalDialog} - openCreateDialog={openCreateDialog} - /> - + {/* Why: empty list flashes templates before data arrives; keep layout stable on first load. */} + {isLoading && !hasListItems ? ( + + ) : isDetailOpen && (selected || selectedExternal) ? ( void rerunAutomationRun(automation, run)} openRunWorkspace={openRunWorkspace} openAutomationRunPage={openAutomationRunPage} + onBackToList={() => { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + }} /> -
+ ) : ( + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + onOpenDetail={() => setIsDetailOpen(true)} + onRefresh={() => void refresh()} + isRefreshing={isLoading} + /> + )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx new file mode 100644 index 00000000000..58990205c4c --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' +import { translate } from '@/i18n/i18n' + +function SkeletonBar({ className }: { className?: string }): React.JSX.Element { + return
+} + +function TableRowSkeleton({ + nameWidthClass, + scheduleWidthClass, + projectWidthClass, + nextWidthClass, + statusWidthClass +}: { + nameWidthClass: string + scheduleWidthClass: string + projectWidthClass: string + nextWidthClass: string + statusWidthClass: string +}): React.JSX.Element { + return ( +
+ + + + + + + +
+ ) +} + +const TABLE_ROW_SKELETONS = [ + { + id: 'row-1', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-28', + nextWidthClass: 'w-24', + statusWidthClass: 'w-16' + }, + { + id: 'row-2', + nameWidthClass: 'w-36', + scheduleWidthClass: 'w-28', + projectWidthClass: 'w-32', + nextWidthClass: 'w-20', + statusWidthClass: 'w-14' + }, + { + id: 'row-3', + nameWidthClass: 'w-24', + scheduleWidthClass: 'w-40', + projectWidthClass: 'w-24', + nextWidthClass: 'w-28', + statusWidthClass: 'w-16' + }, + { + id: 'row-4', + nameWidthClass: 'w-32', + scheduleWidthClass: 'w-32', + projectWidthClass: 'w-36', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + }, + { + id: 'row-5', + nameWidthClass: 'w-40', + scheduleWidthClass: 'w-24', + projectWidthClass: 'w-28', + nextWidthClass: 'w-20', + statusWidthClass: 'w-16' + }, + { + id: 'row-6', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-20', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + } +] as const + +export function AutomationsPageSkeleton(): React.JSX.Element { + return ( +
+
+
+ + +
+ +
+
+
+ + + + + + + +
+
+ {TABLE_ROW_SKELETONS.map(({ id, ...row }) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/automation-detail-selection.test.ts b/src/renderer/src/components/automations/automation-detail-selection.test.ts new file mode 100644 index 00000000000..8dea844ea15 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' + +const base = { + isDetailOpen: true, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: false +} + +describe('shouldCloseDetailForLostSelection', () => { + it('closes detail when the refreshed list no longer contains the selection', () => { + expect(shouldCloseDetailForLostSelection(base)).toBe(true) + }) + + it('keeps detail open when the selected automation survived the refresh', () => { + expect( + shouldCloseDetailForLostSelection({ ...base, isSelectedAutomationInNextList: true }) + ).toBe(false) + }) + + it('keeps detail open when the selected external automation survived the refresh', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isSelectedExternalInNextList: true })).toBe( + false + ) + }) + + it('keeps detail open when a pending run navigation will re-open it', () => { + expect(shouldCloseDetailForLostSelection({ ...base, hasPendingNavigation: true })).toBe(false) + }) + + it('does nothing when detail is already closed', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isDetailOpen: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-detail-selection.ts b/src/renderer/src/components/automations/automation-detail-selection.ts new file mode 100644 index 00000000000..ce3487ee058 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.ts @@ -0,0 +1,20 @@ +/** + * True when a refresh dropped every selected automation while full-page detail is open — + * without closing, detail silently re-renders whichever automation the list now sorts first. + */ +export function shouldCloseDetailForLostSelection({ + isDetailOpen, + hasPendingNavigation, + isSelectedAutomationInNextList, + isSelectedExternalInNextList +}: { + isDetailOpen: boolean + hasPendingNavigation: boolean + isSelectedAutomationInNextList: boolean + isSelectedExternalInNextList: boolean +}): boolean { + if (!isDetailOpen || hasPendingNavigation) { + return false + } + return !isSelectedAutomationInNextList && !isSelectedExternalInNextList +} diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.test.ts b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts new file mode 100644 index 00000000000..5e0703c174f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' + +describe('isPortaledRowMenuClick', () => { + it('detects clicks whose target is outside the row DOM', () => { + const row = document.createElement('div') + const portaledMenuItem = document.createElement('div') + document.body.append(row, portaledMenuItem) + + expect( + isPortaledRowMenuClick({ + target: portaledMenuItem, + currentTarget: row + }) + ).toBe(true) + + row.remove() + portaledMenuItem.remove() + }) + + it('allows in-row clicks', () => { + const row = document.createElement('div') + const child = document.createElement('span') + row.append(child) + + expect( + isPortaledRowMenuClick({ + target: child, + currentTarget: row + }) + ).toBe(false) + }) +}) + +describe('isRowActivationKey', () => { + it('accepts Enter and Space on the row itself', () => { + const row = document.createElement('div') + + expect(isRowActivationKey({ key: 'Enter', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: ' ', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: 'a', target: row, currentTarget: row })).toBe(false) + }) + + it('ignores keys pressed on a nested control', () => { + const row = document.createElement('div') + const trigger = document.createElement('button') + row.append(trigger) + + expect(isRowActivationKey({ key: 'Enter', target: trigger, currentTarget: row })).toBe(false) + expect(isRowActivationKey({ key: ' ', target: trigger, currentTarget: row })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.ts b/src/renderer/src/components/automations/automation-list-row-interaction.ts new file mode 100644 index 00000000000..c09504bc543 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.ts @@ -0,0 +1,26 @@ +/** True when a portaled Radix menu click re-bubbles through a row's React tree. */ +export function isPortaledRowMenuClick(event: { + target: EventTarget + currentTarget: EventTarget +}): boolean { + const target = event.target + return target instanceof Node && event.currentTarget instanceof Node + ? !event.currentTarget.contains(target) + : false +} + +/** + * True when Enter/Space landed on the row itself. Keys pressed on a nested + * control (the actions trigger) must stay with that control — preventDefault + * here would swallow its native activation. + */ +export function isRowActivationKey(event: { + key: string + target: EventTarget + currentTarget: EventTarget +}): boolean { + if (event.target !== event.currentTarget) { + return false + } + return event.key === 'Enter' || event.key === ' ' +} diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts index 6397a3fa90c..b988ad96581 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts @@ -103,4 +103,23 @@ describe('automation run output snapshot queue equivalence', () => { expect(candidate.snapshot()).toEqual(reference.snapshot()) }) + + it('matches the shift reference across oversized and subsequent appends', () => { + const retained = `\ude00${'A'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS - 2)}\ud83d` + const chunks = [ + 'older output', + `discarded\u001b[31m prefix${retained}`, + 'TAIL', + `${'B'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS)}extra`, + '\u001b[0mDone\r\n' + ] + const reference = createShiftReferenceBuffer() + const candidate = createAutomationRunOutputSnapshotBuffer() + + for (const chunk of chunks) { + reference.append(chunk) + candidate.append(chunk) + expect(candidate.snapshot()).toEqual(reference.snapshot()) + } + }) }) diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts index 6dcc87cfc53..4bf868bf94e 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts @@ -132,6 +132,22 @@ describe('automation run output snapshot buffer', () => { }) }) + it('owns the exact UTF-16 tail of one oversized chunk', () => { + const buffer = createAutomationRunOutputSnapshotBuffer() + const retained = `\ude00${'A'.repeat(256 * 1024 - 2)}\ud83d` + + buffer.append('older output') + buffer.append(`discarded prefix${retained}`) + + expect(buffer.snapshot()).toMatchObject({ content: retained, truncated: true }) + + buffer.append('TAIL') + expect(buffer.snapshot()).toMatchObject({ + content: `${retained.slice(4)}TAIL`, + truncated: true + }) + }) + it('creates a saved snapshot from agent transcript text', () => { expect(createAutomationRunOutputSnapshotFromText('\nFinal summary.\n')).toEqual({ format: 'plain_text', diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index fb493e35cdf..1700b2b6ede 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -3,6 +3,7 @@ import { stripAnsiEscapeSequences, TERMINAL_CONTROL_CHARACTER_PATTERN } from '../../../../shared/ansi-escape-sequences' +import { copyUtf16SuffixToOwnedString } from '../../../../shared/owned-utf16-suffix' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 const OUTPUT_SNAPSHOT_COMPACTION_HEAD_THRESHOLD = 64 @@ -68,7 +69,10 @@ export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSn truncated = true continue } - chunks[firstChunkIndex] = firstChunk.slice(overflowChars) + chunks[firstChunkIndex] = + firstChunk.length > MAX_OUTPUT_SNAPSHOT_CHARS + ? copyUtf16SuffixToOwnedString(firstChunk, firstChunk.length - overflowChars) + : firstChunk.slice(overflowChars) totalChars -= overflowChars truncated = true overflowChars = 0 diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts new file mode 100644 index 00000000000..f6947f8c7f4 --- /dev/null +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -0,0 +1,15 @@ +/** Shared layout classes for the automations list table. + * Matches AutomationRunHistory / ExternalAutomationRunTable / Tasks list tables. */ +// Name | Schedule | Project | Next run | Status | Agent | Actions +export const AUTOMATIONS_TABLE_GRID_CLASS = + 'grid grid-cols-[minmax(0,1.5fr)_minmax(7rem,10rem)_minmax(5rem,9rem)_minmax(10rem,1.2fr)_minmax(4.5rem,6rem)_2.5rem_2.5rem]' + +export const AUTOMATIONS_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20' + +export const AUTOMATIONS_TABLE_HEADER_CLASS = + 'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground' + +export const AUTOMATIONS_TABLE_ROW_CLASS = + 'w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' + +export const AUTOMATIONS_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground' diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index a100ecaabef..699a698f008 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -1,137 +1,16 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - DashboardCard, - DashboardSleepWorkspaceArgs, - DashboardSpawnAgentArgs -} from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { AgentMap } from './AgentMap' import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' import type { AgentMapState } from './agent-map-filter' import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' - -const NOW = 2_000_000_000 - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'pane-1', - ptyId: 'pty-1', - agentType: 'codex', - bucket: 'working', - dotState: 'working', - task: 'Build map', - repoId: 'repo-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - repoName: 'Orca', - worktreeName: 'Agent map', - conversationName: 'Agent alpha', - startedAt: NOW - 10 * 60_000, - finishedAt: null, - stateChangedAt: NOW - 1_000, - unseen: false, - hostKind: 'local', - workspaceKind: 'worktree', - ...overrides - } -} - -function renderMap( - cards: DashboardCard[], - { - onOpenTerminal = vi.fn(), - selectedPaneKey = null, - compact = false, - workspaceContextMenusEnabled = false, - enabledStates, - showOrchestrationLinks, - launchableAgentsByWorktreeId, - onSpawnAgent, - onSleepWorkspace - }: { - onOpenTerminal?: (card: DashboardCard) => void - selectedPaneKey?: string | null - compact?: boolean - workspaceContextMenusEnabled?: boolean - enabledStates?: ReadonlySet - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void - } = {} -): ReturnType { - return render( - - ) -} +import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness' describe('AgentMap', () => { - const originalUserAgent = navigator.userAgent - let boundsSpy: ReturnType - - beforeEach(() => { - Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) - ) - boundsSpy = vi - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(function getBounds(this: Element) { - if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 400, - bottom: 300, - width: 400, - height: 300, - toJSON: () => ({}) - } - } - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - toJSON: () => ({}) - } - }) - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.unstubAllGlobals() - boundsSpy.mockRestore() - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: originalUserAgent - }) - }) + const environment = installAgentMapEnvironment() it('renders the amber marker only for unread agents', () => { const finished = card({ @@ -164,23 +43,6 @@ describe('AgentMap', () => { expect(doneNode).toHaveAccessibleName(/unread/) }) - it.each([ - { bucket: 'working', dotState: 'working', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, - { bucket: 'done', dotState: 'done', unseen: true, glows: false }, - { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } - ] as const)('applies the expected halo for $dotState agents', (state) => { - const { container } = renderMap([card(state)]) - const glow = container.querySelector('[data-agent-map-agent-status-glow]') - - if (state.glows) { - expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) - return - } - expect(glow).not.toBeInTheDocument() - }) - it('lets attention override working on the worktree glow', () => { const done = card({ paneKey: 'done', @@ -631,13 +493,13 @@ describe('AgentMap', () => { labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - const readsBeforeZoom = boundsSpy.mock.calls.length + const readsBeforeZoom = environment.boundsSpy.mock.calls.length fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) const zoomedScale = Number( labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) expect(zoomedScale).toBeGreaterThan(initialScale) }) @@ -656,18 +518,18 @@ describe('AgentMap', () => { releasePointerCapture: vi.fn() }) - const readsBeforeHover = boundsSpy.mock.calls.length + const readsBeforeHover = environment.boundsSpy.mock.calls.length fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) expect(svg.setPointerCapture).toHaveBeenCalledWith(1) - const readsAfterPointerDown = boundsSpy.mock.calls.length + const readsAfterPointerDown = environment.boundsSpy.mock.calls.length const initialViewBox = svg.getAttribute('viewBox') fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) expect(requestFrame).toHaveBeenCalledOnce() expect(svg).toHaveAttribute('viewBox', initialViewBox) @@ -675,7 +537,7 @@ describe('AgentMap', () => { expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) requestFrame.mockClear() - const readsBeforeWheel = boundsSpy.mock.calls.length + const readsBeforeWheel = environment.boundsSpy.mock.calls.length const wheel = (): void => { const event = new Event('wheel', { bubbles: true, cancelable: true }) Object.defineProperties(event, { @@ -688,7 +550,7 @@ describe('AgentMap', () => { } wheel() wheel() - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) expect(requestFrame).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index b3203ebf701..3b71f0f2006 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -28,6 +28,7 @@ import { import type { AgentMapViewport } from './agent-map-viewport-transition' import { useAgentMapContextMenus } from './useAgentMapContextMenus' import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapPointerHold } from './useAgentMapPointerHold' import { useAgentMapMotionLayout } from './useAgentMapMotionLayout' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' @@ -86,6 +87,7 @@ export const AgentMapCanvas = forwardRef(null) const interactionBoundsRef = useRef(null) const hasShownProjectsRef = useRef(layout.projects.length > 0) + const { held, hold, release: releaseHold, clearDrag } = useAgentMapPointerHold(dragRef) const clearInteractionBounds = useCallback(() => { interactionBoundsRef.current = null }, []) @@ -315,7 +317,7 @@ export const AgentMapCanvas = forwardRef { - if (event.button !== 0) { + if (event.button !== 0 || dragRef.current) { return } if ( @@ -337,11 +339,16 @@ export const AgentMapCanvas = forwardRef { const drag = dragRef.current - if (!drag || drag.pointerId !== event.pointerId) { + if (!drag) { + releaseHold() + return + } + if (drag.pointerId !== event.pointerId) { return } scheduleViewport({ @@ -353,14 +360,19 @@ export const AgentMapCanvas = forwardRef { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + if (clearDrag(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } }} onPointerCancel={(event) => { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + clearDrag(event.pointerId) + }} + onLostPointerCapture={(event) => { + clearDrag(event.pointerId) + }} + onPointerLeave={() => { + if (!dragRef.current) { + releaseHold() } }} > @@ -370,6 +382,8 @@ export const AgentMapCanvas = forwardRef { zoom={1} labelScale={1} mapScale={0.5} + heldProjectId={null} + heldWorktreeId={null} selectedPaneKey={null} allowAggregation showOrchestrationLinks diff --git a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx new file mode 100644 index 00000000000..ccc3b29e89f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +/** A ring has to stay open for as long as the pointer is working inside it — + * across its own contents, and across a pan drag that takes pointer capture. */ +describe('AgentMap ring hover', () => { + installAgentMapEnvironment() + + it('reveals the hovered workspace name and agent count above every other label', () => { + const { container } = renderMap([ + card(), + card({ paneKey: 'pane-2', conversationName: 'Agent beta' }) + ]) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + + fireEvent.pointerOver(container.querySelector('.agent-map-worktree-group')!) + + const hovered = container.querySelector('[data-agent-map-hover-label]')! + expect(hovered.querySelector('.agent-map-worktree-label')).toHaveTextContent('Agent map') + expect(hovered.querySelector('.agent-map-worktree-count')).toHaveTextContent('2 agents') + expect(hovered.querySelector('.agent-map-worktree-label-group')).toHaveClass( + 'is-active', + 'is-count-visible' + ) + // The hovered name is hoisted, not duplicated, and draws after every ring. + const labels = container.querySelectorAll('.agent-map-worktree-label-group') + expect(labels).toHaveLength(1) + const lastRing = [...container.querySelectorAll('[data-agent-map-worktree]')].at(-1)! + expect(lastRing.compareDocumentPosition(labels[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + }) + + it('hides the hovered workspace label again when the pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + + fireEvent.pointerOver(group) + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + + fireEvent.pointerOut(group) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + }) + + it('keeps the pressed rings lit for the whole pan drag', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + const worktreeGroup = container.querySelector('.agent-map-worktree-group')! + // Pointer capture retargets :hover to the mid-gesture, so CSS alone + // cannot hold the ring open — the class has to survive the drag. + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerUp(svg, { pointerId: 1 }) + expect(projectRing).not.toHaveClass('is-held') + expect(worktreeGroup).not.toHaveClass('is-held') + }) + + it('holds the workspace name up while panning from inside that workspace', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const worktreeGroup = container.querySelector('[data-agent-map-worktree-id]')! + // The aggregate bubble sits inside the group but is not the ring, so a press + // there pans rather than opening the workspace popover. + fireEvent.pointerDown(worktreeGroup, { button: 0, pointerId: 1 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + + expect(worktreeGroup).toHaveClass('is-held') + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('drops the held rings when the pan is cancelled', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.pointerCancel(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('drops the held rings and drag when pointer capture is lost', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.lostPointerCapture(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('keeps a focused workspace label visible after its pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + ring.focus() + fireEvent.pointerOver(group) + fireEvent.pointerOut(group) + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('keeps a hovered workspace label visible after its focus leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + fireEvent.pointerOver(group) + ring.focus() + ring.blur() + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index fdd80ff982e..2181e4c0f2c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -22,6 +22,9 @@ type AgentMapSceneProps = { zoom: number labelScale: number mapScale: number + /** Rings the pointer was pressed in; they stay lit for the whole pan drag. */ + heldProjectId: string | null + heldWorktreeId: string | null selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -63,6 +66,8 @@ export const AgentMapScene = memo(function AgentMapScene({ zoom, labelScale, mapScale, + heldProjectId, + heldWorktreeId, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -74,9 +79,16 @@ export const AgentMapScene = memo(function AgentMapScene({ onOpenWorkspaceContextMenu, onAgentKeyDown }: AgentMapSceneProps): React.JSX.Element { - const [activeWorktreeId, setActiveWorktreeId] = useState(null) - const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { - setActiveWorktreeId((current) => + const [hoveredWorktreeId, setHoveredWorktreeId] = useState(null) + const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) + const activeWorktreeId = heldWorktreeId ?? hoveredWorktreeId ?? focusedWorktreeId + const handleLabelHoverChange = useCallback((worktreeId: string, active: boolean): void => { + setHoveredWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const handleLabelFocusChange = useCallback((worktreeId: string, active: boolean): void => { + setFocusedWorktreeId((current) => active ? worktreeId : current === worktreeId ? null : current ) }, []) @@ -84,6 +96,19 @@ export const AgentMapScene = memo(function AgentMapScene({ () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), [labelScale, layout, mapScale] ) + const activeWorktree = useMemo(() => { + if (!activeWorktreeId) { + return null + } + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (worktree.id === activeWorktreeId) { + return worktree + } + } + } + return null + }, [activeWorktreeId, layout]) const visibleAgentsByPaneKey = useMemo(() => { const agents = new Map() for (const project of layout.projects) { @@ -125,7 +150,8 @@ export const AgentMapScene = memo(function AgentMapScene({ return ( ))} - {project.worktrees.map((worktree) => ( - - ))} + {project.worktrees.map((worktree) => + worktree.id === activeWorktreeId ? null : ( + + ) + )} ) })} + {/* Drawn last so a hovered name clears every other project's rings, and + * outside the declutter pass so it reads at any zoom. */} + {activeWorktree ? ( + + + + ) : null} ) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx new file mode 100644 index 00000000000..8803b3336a5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +describe('AgentMap status glow', () => { + installAgentMapEnvironment() + + it.each([ + { bucket: 'working', dotState: 'working', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, + { bucket: 'done', dotState: 'done', unseen: true, glows: false }, + { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } + ] as const)('applies the expected halo for $dotState agents', (state) => { + const { container } = renderMap([card(state)]) + const glow = container.querySelector('[data-agent-map-agent-status-glow]') + + if (state.glows) { + expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) + return + } + expect(glow).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx index 455b20df5bd..27a1c455341 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -17,7 +17,8 @@ export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ labelScale, mapScale }: AgentMapWorktreeLabelProps): React.JSX.Element { - const showCount = visible && (active || worktree.radius * mapScale >= 80) + // Hover is an explicit ask for this workspace's detail, so it outranks declutter. + const showCount = active || (visible && worktree.radius * mapScale >= 80) const agentCountText = translate( 'dashboardPopout.map.agentCount', worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 251fabbb079..ee49a574901 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -22,6 +22,8 @@ type AgentMapWorktreeRingNodeProps = { worktree: AgentMapWorktreeRing zoom: number mapScale: number + /** Pressed at the start of a pan drag; keeps the ring lit through the gesture. */ + held: boolean selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -33,7 +35,8 @@ type AgentMapWorktreeRingNodeProps = { event: React.MouseEvent, worktree: AgentMapWorktreeRing ) => void - onLabelActiveChange: (worktreeId: string, active: boolean) => void + onLabelHoverChange: (worktreeId: string, active: boolean) => void + onLabelFocusChange: (worktreeId: string, active: boolean) => void onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } @@ -175,6 +178,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ worktree, zoom, mapScale, + held, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -183,7 +187,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ onSelectAgent, onSpawnAgent, onOpenWorkspaceContextMenu, - onLabelActiveChange, + onLabelHoverChange, + onLabelFocusChange, onAgentKeyDown }: AgentMapWorktreeRingNodeProps): React.JSX.Element { const [detailsOpen, setDetailsOpen] = useState(false) @@ -196,14 +201,15 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ return ( onLabelActiveChange(worktree.id, true)} - onPointerLeave={() => onLabelActiveChange(worktree.id, false)} - onFocus={() => onLabelActiveChange(worktree.id, true)} + onPointerEnter={() => onLabelHoverChange(worktree.id, true)} + onPointerLeave={() => onLabelHoverChange(worktree.id, false)} + onFocus={() => onLabelFocusChange(worktree.id, true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - onLabelActiveChange(worktree.id, false) + onLabelFocusChange(worktree.id, false) } }} > diff --git a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts new file mode 100644 index 00000000000..ea5bbab362e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/agent-map.css'), + 'utf8' +) + +const PROJECT_SCALE = + /:where\(\s*\.agent-map-project-node:hover,\s*\.agent-map-project-node:focus-within,\s*\.agent-map-project-node\.is-held\s*\)\s*\.agent-map-project-ring\s*\{([^}]*)\}/ +const WORKTREE_SCALE = + /:where\(\s*\.agent-map-worktree-group:hover,\s*\.agent-map-worktree-group:focus-within,\s*\.agent-map-worktree-group\.is-held\s*\)\s*\.agent-map-worktree-ring\s*\{([^}]*)\}/ + +/** A ring that only reacts to :hover on itself pulses shut whenever the pointer + * crosses onto something drawn inside it, and again when a pan drag takes + * pointer capture. Both triggers have to live on the containing group. */ +describe('Agent Map hover containment', () => { + it('scales each ring from its containing group, never from the ring element', () => { + expect(css).not.toMatch(/\.agent-map-(?:project|worktree)-ring:hover/) + expect(css.match(PROJECT_SCALE)?.[1]).toContain('transform: scale') + expect(css.match(WORKTREE_SCALE)?.[1]).toContain('transform: scale') + }) + + it('keeps the group-scoped hover at ring specificity so state rules still win', () => { + // `:where()` contributes no specificity, so the workspace state rules keep + // overriding hover fill and stroke — but only while they stay below it. + const hoverAt = css.search(WORKTREE_SCALE) + + expect(hoverAt).toBeGreaterThan(css.indexOf('.agent-map-worktree-ring {')) + for (const state of ['.is-open', '.is-selected', '.is-working', '.is-blocked']) { + expect(css.indexOf(`.agent-map-worktree-ring${state}`)).toBeGreaterThan(hoverAt) + } + }) + + it('expands containing rings for keyboard focus as well as pointer hover', () => { + expect(css.match(PROJECT_SCALE)?.[0]).toContain('.agent-map-project-node:focus-within') + expect(css.match(WORKTREE_SCALE)?.[0]).toContain('.agent-map-worktree-group:focus-within') + }) + + it('drops both hover triggers under reduced motion', () => { + const reducedMotion = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) + + expect(reducedMotion).toContain('.agent-map-project-node.is-held') + expect(reducedMotion).toContain('.agent-map-worktree-group.is-held') + expect(reducedMotion).toMatch(/\.agent-map-project-node:hover/) + expect(reducedMotion).toMatch(/\.agent-map-worktree-group:hover/) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts index 28c71c881a1..ad02fb56243 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -90,19 +90,6 @@ describe('selectVisibleAgentMapLabels', () => { const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) expect(labels.worktreeIds.size).toBe(0) - expect(labels.worktreeAgentSafeIds.size).toBe(0) - }) - - it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { - const layout = layoutOf([ - worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), - worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) - ]) - - const labels = selectVisibleAgentMapLabels(layout, 1, 1) - - expect([...labels.worktreeIds]).toEqual(['busy']) - expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) }) it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 58a1f53c4d6..841197437e6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -38,8 +38,6 @@ type LabelGrid = Map> export type AgentMapVisibleLabels = { /** Worktree ring ids whose name can render without colliding. */ worktreeIds: Set - /** Worktree names that hover/focus may reveal without covering an agent. */ - worktreeAgentSafeIds: Set /** Project ids whose agent/workspace count line still has room. The count is * the first thing dropped: it repeats what the filter rail already says. */ projectCountIds: Set @@ -221,7 +219,6 @@ export function selectVisibleAgentMapLabels( }) const worktreeIds = new Set() - const worktreeAgentSafeIds = new Set() for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { const box = baselineBox( worktree.x, @@ -234,7 +231,6 @@ export function selectVisibleAgentMapLabels( if (collides(agentGrid, box)) { continue } - worktreeAgentSafeIds.add(worktree.id) if (collides(grid, box)) { continue } @@ -261,5 +257,5 @@ export function selectVisibleAgentMapLabels( projectCountIds.add(project.id) } - return { worktreeIds, worktreeAgentSafeIds, projectCountIds } + return { worktreeIds, projectCountIds } } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx new file mode 100644 index 00000000000..45c83a33c8e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -0,0 +1,133 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import type { AgentMapState } from './agent-map-filter' + +export const NOW = 2_000_000_000 + +export function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +export type RenderMapOptions = { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + showOrchestrationLinks, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: RenderMapOptions = {} +): ReturnType { + return render( + + ) +} + +export type AgentMapTestEnvironment = { + /** Exposed so tests can assert the canvas does not re-read layout when idle. */ + boundsSpy: ReturnType +} + +const CANVAS_BOUNDS = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) +} +const ZERO_BOUNDS = { ...CANVAS_BOUNDS, right: 0, bottom: 0, width: 0, height: 0 } + +/** Gives the map a measurable canvas and a non-Mac platform, the way every map + * suite needs it. Call once per describe block. */ +export function installAgentMapEnvironment(): AgentMapTestEnvironment { + const environment = {} as AgentMapTestEnvironment + const originalUserAgent = navigator.userAgent + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + environment.boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + return this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement + ? CANVAS_BOUNDS + : ZERO_BOUNDS + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + environment.boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + return environment +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 6107332ba0d..fb1f392f707 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -31,7 +31,13 @@ vector-effect: non-scaling-stroke; } -.agent-map-project-ring:hover { +/* Group hover includes nested contents; held state bridges pointer capture. */ +:where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring { fill: color-mix(in srgb, var(--card) 42%, transparent); stroke: var(--ring); transform: scale(1.018); @@ -154,7 +160,12 @@ stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); } -.agent-map-worktree-ring:hover { +:where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring { fill: color-mix(in srgb, var(--card) 72%, transparent); stroke: var(--ring); transform: scale(1.035); @@ -203,6 +214,11 @@ text-anchor: middle; } +.agent-map-worktree-label-layer, +.agent-map-worktree-hover-label-layer { + pointer-events: none; +} + .agent-map-worktree-label-group { opacity: 0; pointer-events: none; @@ -480,8 +496,18 @@ transition: none; } - .agent-map-project-ring:hover, - .agent-map-worktree-ring:hover, + :where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring, + :where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring, .agent-map-agent-node:hover .agent-map-agent-visual, .agent-map-agent-node:focus-visible .agent-map-agent-visual { transform: none; diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts new file mode 100644 index 00000000000..924c18d6586 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts @@ -0,0 +1,47 @@ +import { useCallback, useState, type RefObject } from 'react' + +export type AgentMapPointerHold = { + projectId: string | null + worktreeId: string | null +} + +type AgentMapPointerDragRef = RefObject<{ pointerId: number } | null> + +function closestId(target: Element, attribute: string): string | null { + return target.closest(`[${attribute}]`)?.getAttribute(attribute) ?? null +} + +/** + * Remembers which rings a pan drag started in. Pointer capture retargets + * `:hover` to the `` for the whole gesture, so the ring under the pointer + * would otherwise collapse until the gesture ends. + */ +export function useAgentMapPointerHold(dragRef: AgentMapPointerDragRef): { + held: AgentMapPointerHold | null + hold: (target: Element) => void + release: () => void + clearDrag: (pointerId: number) => boolean +} { + const [held, setHeld] = useState(null) + const hold = useCallback((target: Element): void => { + const projectId = closestId(target, 'data-agent-map-project-id') + const worktreeId = closestId(target, 'data-agent-map-worktree-id') + // A pan off empty canvas holds nothing, so leave the memoized scene alone. + setHeld(projectId === null && worktreeId === null ? null : { projectId, worktreeId }) + }, []) + const release = useCallback((): void => { + setHeld((current) => (current === null ? current : null)) + }, []) + const clearDrag = useCallback( + (pointerId: number): boolean => { + if (dragRef.current?.pointerId !== pointerId) { + return false + } + dragRef.current = null + release() + return true + }, + [dragRef, release] + ) + return { held, hold, release, clearDrag } +} diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.test.ts b/src/renderer/src/components/dictation/dictation-insertion-target.test.ts index d7b5e670c5f..59c207735be 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.test.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.test.ts @@ -1,12 +1,12 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' +import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, - TEXT_CONTROL_PASTE_MAX_BYTES, - getTextControlPasteByteLength + TEXT_CONTROL_PASTE_MAX_BYTES } from '@/lib/text-control-paste' import { insertText } from './dictation-insertion-target' @@ -98,8 +98,7 @@ describe('dictation insertion target', () => { expect(execCommand.mock.calls.length).toBeGreaterThan(1) expect( execCommand.mock.calls.every( - (call) => - getTextControlPasteByteLength(String(call[2] ?? '')) <= TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES + (call) => getUtf8ByteLength(String(call[2] ?? '')) <= TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES ) ).toBe(true) expect(beforeInputEvents).toHaveLength(execCommand.mock.calls.length) diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index 4f8573be15a..56a997a1803 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -1,9 +1,9 @@ import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../../shared/utf8-byte-limits' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, TEXT_CONTROL_PASTE_MAX_BYTES, - getTextControlPasteByteLength, measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield, pasteTextIntoTextControl @@ -108,11 +108,7 @@ async function insertTextIntoContentEditableTarget( if (!isContentEditableDictationTargetCurrent(element)) { return } - const nextIndex = getNextDictationChunkBoundary( - text, - textIndex, - TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES - ) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES) if ( !insertContentEditableDictationChunk(element, editorElement, text.slice(textIndex, nextIndex)) ) { @@ -162,22 +158,3 @@ function insertContentEditableDictationChunk( function isContentEditableDictationTargetCurrent(element: HTMLElement): boolean { return element.isConnected && element.contains(element.ownerDocument.activeElement) } - -function getNextDictationChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getTextControlPasteByteLength(text.slice(index, index + codeUnitLength)) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} diff --git a/src/renderer/src/components/editor/monaco-large-text-paste.ts b/src/renderer/src/components/editor/monaco-large-text-paste.ts index 7cb6ed83c76..a8e8157b78d 100644 --- a/src/renderer/src/components/editor/monaco-large-text-paste.ts +++ b/src/renderer/src/components/editor/monaco-large-text-paste.ts @@ -1,5 +1,6 @@ import type { editor } from 'monaco-editor' import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../../shared/utf8-byte-limits' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -73,39 +74,6 @@ function getPlainTextFromPasteEvent(event: ClipboardEvent): string { return event.clipboardData?.getData('text/plain') ?? '' } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function getEndPositionAfterInsert(start: Position, text: string): Position { let lineNumber = start.lineNumber let column = start.column @@ -187,7 +155,7 @@ async function insertMonacoTextInChunks( return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) const endPosition = getEndPositionAfterInsert( { lineNumber: selection.startLineNumber, column: selection.startColumn }, diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts index b6f87a0b01c..815e4a27ed0 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts @@ -1,6 +1,10 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { + getUtf8ChunkEndIndex, + isUtf8ByteLengthWithinLimit +} from '../../../../shared/utf8-byte-limits' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -33,61 +37,6 @@ export type RichMarkdownLargeTextPasteResult = chunksWritten: number } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function isTextByteLengthOverLimit(text: string, maxBytes: number): boolean { - if (text.length === 0) { - return false - } - if (text.length > maxBytes) { - return true - } - - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return true - } - if (codePoint > 0xffff) { - index += 1 - } - } - return false -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function isEditorAvailable( editor: Editor, canContinue: RichMarkdownLargeTextPasteOptions['canContinue'] @@ -126,7 +75,7 @@ function shouldHandleLargeRichMarkdownPaste({ if (plainTextExceededLimit || plainTextByteLength > maxDirect) { return true } - return isTextByteLengthOverLimit(htmlText, maxDirect) + return !isUtf8ByteLengthWithinLimit(htmlText, maxDirect) } async function insertRichMarkdownTextInChunks( @@ -144,7 +93,7 @@ async function insertRichMarkdownTextInChunks( return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) editor.view.dispatch(editor.state.tr.insertText(chunk)) textIndex = nextIndex diff --git a/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts index cbaecb4ea64..51807b15557 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts @@ -1,4 +1,5 @@ import type { Slice } from '@tiptap/pm/model' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' import { getRichMarkdownLeafVisibleText, isRichMarkdownVisibleBlockStart @@ -90,7 +91,7 @@ function addUtf8BytesWithinLimit( let byteLength = current for (let index = 0; index < value.length; index += 1) { const codePoint = value.codePointAt(index) ?? 0 - byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4 + byteLength += getUtf8ByteLengthForCodePoint(codePoint) if (byteLength > limit) { return { byteLength, exceeded: true } } diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index f04134100d3..2cff1ed560f 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -5,6 +5,7 @@ import { makeDiff, makePatches } from '@sanity/diff-match-patch' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' // Why: cap document size in UTF-16 code units (`.length`) since re-parse cost scales with length — the per-commit throwaway TipTap safety re-parse (~50-67ms here) must stay under the 300ms serialize debounce so it can't stall the main thread on slow/SSH hosts. const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000 @@ -150,7 +151,7 @@ function getUtf8OffsetsAtCodeUnitIndices( if (codePoint === undefined) { break } - byteOffset += utf8CodePointLength(codePoint) + byteOffset += getUtf8ByteLengthForCodePoint(codePoint) codeUnitIndex += codePoint > 0xffff ? 2 : 1 } offsets.set(target, byteOffset) @@ -158,19 +159,6 @@ function getUtf8OffsetsAtCodeUnitIndices( return offsets } -function utf8CodePointLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean { // Match diff-match-patch's prefix/suffix trimming so a small edit isn't mistaken for a repetitive replacement. const minimumLength = Math.min(textA.length, textB.length) diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx deleted file mode 100644 index 94295008e13..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { renderToStaticMarkup } from 'react-dom/server' -import { FEATURE_TOUR_PREVIEW_COPY, FeatureTourPreview } from './FeatureTourPreview' - -describe('FeatureTourPreview first-run copy', () => { - it('teaches that workspaces keep terminal and agent activity together', () => { - const workspaceFrame = FEATURE_TOUR_PREVIEW_COPY.find((frame) => frame.id === 1) - - expect(workspaceFrame?.caption).toContain('terminal') - expect(workspaceFrame?.caption).toContain('agent activity') - }) - - it('teaches that opening a workspace returns to its terminal', () => { - const terminalFrame = FEATURE_TOUR_PREVIEW_COPY.find((frame) => frame.id === 4) - - expect(terminalFrame?.caption).toContain('Open any workspace') - expect(terminalFrame?.caption).toContain('return to its terminal') - }) - - it('renders the cycling captions into the first-run preview', () => { - const html = renderToStaticMarkup() - - expect(html).toContain( - 'Each workspace keeps its branch, terminal, and agent activity together.' - ) - expect(html).toContain('Open any workspace to return to its terminal') - }) -}) diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx deleted file mode 100644 index c92b6c4d237..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons' -import { - CodexInlineIcon, - CursorIcon, - MailGlyph, - WorkingSpinner -} from './feature-tour-preview-glyphs' -import { - FEATURE_TOUR_ORCHESTRATION_CHILDREN, - FEATURE_TOUR_PREVIEW_COPY -} from './feature-tour-preview-copy' -import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard' -import { FeatureTourTerminalFrame } from './FeatureTourTerminalFrame' -import { translate } from '@/i18n/i18n' - -export { FEATURE_TOUR_PREVIEW_COPY } from './feature-tour-preview-copy' -export type { FeatureTourPreviewFrameCopy } from './feature-tour-preview-copy' - -function WorkspaceFrame(): JSX.Element { - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.56a0271428', - 'Isolated workspaces' - )} -
- {/* Why: 3 cards in a row tells the "ship several at once" story by - composition; the wide preview aspect (~4.9:1) makes a vertical stack - read as wasted space. The grid auto-sizes (no flex-1) so the cards - don't stretch to the container's bottom edge — keeps headroom under - the dot indicators. */} -
- - - -
-
- ) -} - -function OrchestrationFrame(): JSX.Element { - // Why: a horizontal fan (root → 3 children L→R) reads naturally as - // "fans out and ships parallel PRs" at the wide aspect; the previous - // top-down tree wasted the horizontal space. SVG paths are sized to a - // 600×130 viewBox and stretched non-uniformly, so the dashed lines flex - // with the container while the absolutely-positioned cards stay aligned - // to viewport-relative anchors (root left, children right column). - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', - 'Agent orchestration' - )} -
-
- {/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none") - so endpoints anchor to the same percentage anchors as the cards - and the bubbles — root right edge at 34%, child left edge at 64%, - child Y centers at 18%/50%/82%. Explicit width/height attrs are - required because an SVG with a 1:1 viewBox and only inset-0 - otherwise picks its intrinsic 1:1 aspect for height. */} - - {/* Why: vectorEffect is NOT inheritable in SVG, so the - non-scaling-stroke attribute must live on each path. Hoisting - it onto let preserveAspectRatio="none" stretch the dashes - into trapezoids on the diagonal connectors. */} - - - - - - {/* Why: parent matches WorkspaceCard composition — spinner + title row, - then a Claude agent row underneath — so the user reads it as "a - workspace running Claude as the orchestrator," consistent with how - workspaces look elsewhere in the app. */} -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.cebc7769cd', - 'redesign auth flow' - )} - -
-
- - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.5171768676', - 'orchestrating 3 agents' - )} - -
-
- - {/* Why: children mirror the parent's WorkspaceCard composition so the - fan reads as "coordinator workspace dispatches to 3 child - workspaces, each running its own agent." */} - {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key, position, label, agent }) => ( -
-
- - - {label} - -
-
- - {agent === 'claude' ? ( - - ) : agent === 'codex' ? ( - - ) : ( - - )} - -
-
- ))} - - {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => ( -
- -
- ))} -
-
- ) -} - -function TasksFrame(): JSX.Element { - // Why: a left→right pipeline reads as "pick from backlog → workspace - // appears" in one glance. The wide aspect lets the backlog and the - // resulting workspace card sit side-by-side instead of stacked, which - // makes the cause/effect visible in the composition itself. - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.bee6b4088d', - 'GitHub & Linear tasks' - )} -
-
-
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')} - - {/* Why: surrounding rows show only the issue number + a skeleton - so the user's eye is drawn to the row that has real text — the - one the cursor clicks on. */} - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')} - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2', - 'Worktree picker truncates' - )} - - - - {translate('auto.components.feature.wall.FeatureTourPreview.40bbd92ef4', 'Start')} - - - - - - - {/* Why: cursor + ring live inside the row so they anchor to the - pill's right-edge ml-auto, instead of using fixed pixel offsets - that drift when the preview is resized. */} - - - - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')} - - -
-
- -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.3822d8d14b', - 'fix/worktree-picker-truncates' - )} - - - - -
-
- - - -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8', - 'Linked to GH #1842' - )} -
-
-
-
- ) -} - -export function FeatureTourPreview(props: { className?: string }): JSX.Element { - return ( -
-
- -
-
- -
-
- -
-
- -
-
- {FEATURE_TOUR_PREVIEW_COPY.map((frame) => ( -
-
- {frame.title} -
-
- {frame.caption} -
-
- ))} -
-
- - - - -
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx b/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx deleted file mode 100644 index d21314126c2..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { JSX } from 'react' -import { ClaudeIcon } from '../status-bar/icons' -import { translate } from '@/i18n/i18n' - -export function FeatureTourTerminalFrame(): JSX.Element { - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', - 'Splittable terminal' - )} -
-
-
- - - - - {translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')} - -
-
-
-
- $ - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.6218a9014d', - 'pnpm playwright test' - )} - -
-
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.8279e9d95b', - 'Running 12 tests' - )} -
-
- ✓ - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.24fedd5a52', - 'login.spec.ts' - )} - -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0', - 'dashboard.spec.ts' - )} - -
-
-
-
-
- $ - - {translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')} - -
-
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a', - 'session started' - )} - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')} - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1', - 'review src/auth' - )} - -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1', - 'Thinking...' - )} - -
-
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx b/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx deleted file mode 100644 index 0004b27ef2c..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons' -import { CodexInlineIcon, WorkingSpinner } from './feature-tour-preview-glyphs' - -export type FeatureTourWorkspaceCardAgent = { - kind: 'claude' | 'codex' | 'opencode-go' - barWidth: string - state: 'working' | 'done' -} - -export function FeatureTourWorkspaceCard({ - status, - title, - agents -}: { - status: 'working' | 'done' - title: string - agents: readonly FeatureTourWorkspaceCardAgent[] -}): JSX.Element { - return ( -
-
- {status === 'working' ? ( - - ) : ( - - )} - - {title} - -
-
- {agents.map((agent, idx) => ( -
- {agent.state === 'working' ? ( - - ) : ( - - )} - {agent.kind === 'claude' ? ( - - ) : agent.kind === 'codex' ? ( - - ) : ( - - )} - -
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx index a5c02d28afc..600fb99b51e 100644 --- a/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx @@ -3,10 +3,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { JSX } from 'react' import { cn } from '@/lib/utils' -import { ClaudeIcon } from '../status-bar/icons' +import { ClaudeIcon, OpenAIIcon } from '../status-bar/icons' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FeatureWallClickRing } from './FeatureWallClickRing' -import { CodexInlineIcon } from './feature-tour-preview-glyphs' import { translate } from '@/i18n/i18n' // Why: the right-click menu needs the same icons as the real Orca menu so the @@ -815,8 +814,8 @@ function RightPaneScrollback(props: { return ( {props.isCodex ? ( - - + + ) : ( ● @@ -873,8 +872,8 @@ function RightPaneScrollback(props: { {line.withGlyph ? ( props.isCodex ? ( - - + + ) : ( ● diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts deleted file mode 100644 index 1aa0dc8abbc..00000000000 --- a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { translate } from '@/i18n/i18n' - -type FrameId = 1 | 2 | 3 | 4 - -export type FeatureTourPreviewFrameCopy = { - id: FrameId - title: string - caption: string -} - -export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ - { - id: 1, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.56a0271428', - 'Isolated workspaces' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.47f16ecf34', - 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' - ) - } - }, - { - id: 2, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', - 'Agent orchestration' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.70aa182266', - 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' - ) - } - }, - { - id: 3, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.ef737dcee1', - 'GitHub & Linear tasks' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.f10c14dd9d', - 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' - ) - } - }, - { - id: 4, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', - 'Splittable terminal' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.5d6ee181b6', - 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' - ) - } - } -] - -export type FeatureTourOrchestrationChildAgent = 'claude' | 'codex' | 'opencode-go' - -export const FEATURE_TOUR_ORCHESTRATION_CHILDREN: readonly { - key: 'top' | 'mid' | 'bot' - position: string - label: string - agent: FeatureTourOrchestrationChildAgent -}[] = [ - // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y - // endpoints the dashed SVG paths terminate at — so the connectors land on - // each card's center regardless of card height. - { - key: 'top', - position: 'top-[18%] -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.b1f17bcc74', 'PR 1/3') - }, - agent: 'claude' - }, - { - key: 'mid', - position: 'top-1/2 -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4', 'PR 2/3') - }, - agent: 'codex' - }, - { - key: 'bot', - position: 'top-[82%] -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6', 'PR 3/3') - }, - agent: 'opencode-go' - } -] diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx b/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx deleted file mode 100644 index a3fa74f8d1f..00000000000 --- a/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' - -export function CodexInlineIcon(): JSX.Element { - return ( - - - - ) -} - -export function WorkingSpinner({ - size = 'sm', - reducedMotion = false -}: { - size?: 'sm' | 'xs' - reducedMotion?: boolean -}): JSX.Element { - // Why: matches AgentStateDot's working indicator so the preview teaches - // the same state language users see in the real app. - const ring = size === 'xs' ? 'size-1.5 border' : 'size-2 border-2' - return ( - - ) -} - -export function CursorIcon(): JSX.Element { - return ( - - - - ) -} - -export function MailGlyph(): JSX.Element { - return ( - - - - - ) -} diff --git a/src/renderer/src/components/github/pr-file-content-size.test.ts b/src/renderer/src/components/github/pr-file-content-size.test.ts new file mode 100644 index 00000000000..700a9579ec4 --- /dev/null +++ b/src/renderer/src/components/github/pr-file-content-size.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRFileContents } from '../../../../shared/types' +import { getPRFileContentsCacheByteCount } from './pr-file-content-size' + +describe('PR file content size', () => { + it('counts astral characters and lone surrogates as UTF-8 bytes', () => { + const contents: GitHubPRFileContents = { + original: '😀\ud800', + modified: '\udc00é', + originalIsBinary: false, + modifiedIsBinary: false + } + + expect(getPRFileContentsCacheByteCount(contents)).toBe(12) + }) +}) diff --git a/src/renderer/src/components/github/pr-file-content-size.ts b/src/renderer/src/components/github/pr-file-content-size.ts index 89e0f26903c..0e20c92bd1d 100644 --- a/src/renderer/src/components/github/pr-file-content-size.ts +++ b/src/renderer/src/components/github/pr-file-content-size.ts @@ -1,5 +1,6 @@ import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '@/components/editor/large-diff-render-limit' import type { GitHubPRFile, GitHubPRFileContents } from '../../../../shared/types' +import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' export const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 @@ -7,29 +8,6 @@ export function isPRFileViewed(file: GitHubPRFile): boolean { return file.viewerViewedState === 'VIEWED' } -export function getUtf8ByteCount(value: string): number { - let byteCount = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x80) { - byteCount += 1 - } else if (code < 0x800) { - byteCount += 2 - } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - byteCount += 4 - index += 1 - } else { - byteCount += 3 - } - } else { - byteCount += 3 - } - } - return byteCount -} - export function isPRFileContentsTooLargeSentinel(contents: GitHubPRFileContents): boolean { return contents.originalTooLarge === true || contents.modifiedTooLarge === true } @@ -38,7 +16,7 @@ export function getPRFileContentsCacheByteCount(contents: GitHubPRFileContents): if (isPRFileContentsTooLargeSentinel(contents)) { return 0 } - return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) + return getUtf8ByteLength(contents.original) + getUtf8ByteLength(contents.modified) } export function getRetainedPRFileContentsByteCount(contents: GitHubPRFileContents): number | null { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9..e7a6269f533 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -30,10 +30,13 @@ const mocks = vi.hoisted(() => ({ } | null, createClaudeModelSwitchConfirmationObserver: vi.fn(), discoverCommitMessageModels: vi.fn(), + draft: 'hello', getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), + sendNativeChatTypedCommand: vi.fn(), sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), draftScopeKeys: [] as string[], @@ -63,8 +66,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ })) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatTypedCommand: (...args: unknown[]) => mocks.sendNativeChatTypedCommand(...args), sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) @@ -85,7 +90,7 @@ vi.mock('@/lib/native-chat-telemetry', () => ({ vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { mocks.draftScopeKeys.push(scopeKey) - return { draft: 'hello', setDraft: mocks.setDraft } + return { draft: mocks.draft, setDraft: mocks.setDraft } } })) vi.mock('./native-chat-draft-cache', () => ({ @@ -142,6 +147,7 @@ describe('NativeChatComposer', () => { clearNativeChatModelEnrichmentForTests() mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' + mocks.draft = 'hello' mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -180,7 +186,9 @@ describe('NativeChatComposer', () => { ] }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + mocks.sendNativeChatTypedCommand.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.typeNativeChatCommand.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, @@ -234,6 +242,57 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('types Codex slash composer sends instead of pasting them', () => { + mocks.draft = '/status' + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + }) + + it('keeps Codex skill sends pasted', () => { + mocks.draft = '$ref-oss' + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '$ref-oss', undefined) + expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() + }) + + it.each(['claude', 'openclaude'] as const)('keeps %s slash composer sends pasted', (agent) => { + mocks.draft = '/clear' + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/clear', undefined) + expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() + }) + it('retires the launch-draft seed once a send clears the TUI input line', () => { render( { expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) - it('applies a Codex model change without switching to the terminal', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mocks.sendHandle.settleAfterMs = 0 const onSwitchToTerminal = vi.fn() render( @@ -563,17 +622,17 @@ describe('NativeChatComposer', () => { /> ) - // Codex model changes are value-bearing commands; only effort still uses the TUI picker. await act(async () => { - await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5') + await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model') }) - expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( {}, 'pty-1', - '/model gpt-5.5', + '/model', expect.any(AbortSignal) ) - expect(onSwitchToTerminal).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled() + expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 3cad0b3ac2c..7ec609f8aa1 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -4,12 +4,14 @@ import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { sendNativeChatMessage, + sendNativeChatTypedCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, @@ -263,7 +265,10 @@ export const NativeChatComposer = forwardRef 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( target.settings, diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 0dd5e19025d..37850bccaec 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -565,7 +565,7 @@ describe('native chat PTY session options', () => { ) const result = await surface.invokeAction('effort') - expect(dispatch).toHaveBeenCalledWith('/model') + expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' }) expect(onAgentPicker).toHaveBeenCalledOnce() expect(result.snapshot).toHaveLength(1) expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 93a63765a46..9a16199d52a 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -11,7 +11,9 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { sendNativeChatMessage, + sendNativeChatTypedCommand, sendNativeChatMessageVerified, + typeNativeChatCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -252,6 +254,69 @@ describe('sendNativeChatMessageVerified', () => { }) }) +describe('typeNativeChatCommand', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInput.mockReset().mockReturnValue(true) + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() + }) + + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe(true) + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 'm', + 'o', + 'd', + 'e', + 'l', + NATIVE_CHAT_SUBMIT + ]) + }) + + it('queues composer commands as the same paced key sequence', async () => { + const handle = sendNativeChatTypedCommand(SETTINGS, PTY, '/status') + await vi.runAllTimersAsync() + await handle.settled + + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 's', + 't', + 'a', + 't', + 'u', + 's', + NATIVE_CHAT_SUBMIT + ]) + }) + + it('does not clear into the next queued send after cancellation', async () => { + const command = sendNativeChatTypedCommand(SETTINGS, PTY, '/status') + command.cancel() + const next = sendNativeChatMessage(SETTINGS, PTY, 'next') + await vi.runAllTimersAsync() + await Promise.all([command.settled, next.settled]) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('next'), + NATIVE_CHAT_SUBMIT + ]) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b..a3b34b4f716 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -19,6 +19,10 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { + AGENT_TUI_COMMAND_KEY_INTERVAL_MS, + typeAgentTuiCommand +} from '../../../../shared/agent-tui-command-typing' import { cancelNativeChatPtySends, enqueueNativeChatPtySend, @@ -213,6 +217,61 @@ export async function sendNativeChatMessageVerified( return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) } +/** Types a slash command as individual keys so Codex opens its command palette. */ +export async function typeNativeChatCommand( + settings: RuntimeSettings, + ptyId: string, + command: string, + signal?: AbortSignal +): Promise { + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + const outcome = await typeAgentTuiCommand({ + command, + signal, + write: async (key) => + (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + }) + return outcome === 'accepted' +} + +/** Queues a typed slash command with composer sends on the same PTY. */ +export function sendNativeChatTypedCommand( + settings: RuntimeSettings, + ptyId: string, + command: string +): NativeChatSendHandle { + const controller = new AbortController() + return enqueueNativeChatPtySend( + ptyId, + (command.length + 1) * AGENT_TUI_COMMAND_KEY_INTERVAL_MS, + ({ isCancelled, markSubmitted }) => { + const finish = (outcome: 'accepted' | 'rejected' | 'unknown'): void => { + if (!isCancelled() && outcome !== 'accepted') { + clearUnsubmittedAgentInput(settings, ptyId) + } + markSubmitted() + } + void typeAgentTuiCommand({ + command, + signal: controller.signal, + write: async (key) => { + if (isCancelled()) { + return 'rejected' + } + return (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + } + }).then(finish, () => finish('rejected')) + }, + { + onCancelUnsubmitted: () => { + controller.abort() + clearUnsubmittedAgentInput(settings, ptyId) + } + } + ) +} + export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts index 98481e1035c..5585512633a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts @@ -109,7 +109,9 @@ async function handleAgentPicker( ctx: SessionOptionApplyContext, midSession: Extract ): Promise { - await ctx.dispatchCommand(midSession.command) + await (midSession.delivery + ? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : ctx.dispatchCommand(midSession.command)) ctx.clearModelTruth() const snapshot = ctx.publish() ctx.onAgentPicker?.() diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts index c510990dc5e..d7b071c55e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -1,4 +1,7 @@ -import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { + CatalogAgentInteractionDetection, + CatalogCommandDelivery +} from '../../../../shared/agent-session-option-catalog' import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' export type NativeChatSessionOptionDispatchResult = { @@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = ( options?: { detectAgentInteraction?: CatalogAgentInteractionDetection expectedChoiceLabel?: string + delivery?: CatalogCommandDelivery } ) => | Promise diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx new file mode 100644 index 00000000000..90c495a2bc7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EMPTY_HISTORY } from './native-chat-composer-state' + +const sendNativeChatMessage = vi.fn() +const sendNativeChatTypedCommand = vi.fn() + +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: (...args: unknown[]) => sendNativeChatMessage(...args), + sendNativeChatTypedCommand: (...args: unknown[]) => sendNativeChatTypedCommand(...args) +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) + +import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' + +const COMMAND = { + kind: 'command' as const, + id: 'command:status', + name: 'status', + description: 'Show status', + skillCollision: false +} + +function renderDispatch(agent: 'codex' | 'claude' | 'openclaude') { + return renderHook(() => + useNativeChatPickerCommandDispatch({ + agent, + disabled: false, + isDispatchingSessionOption: false, + resolveTarget: () => ({ settings: {}, ptyId: 'pty-1' }), + sessionOptionsSurface: null, + trackPendingSend: vi.fn(), + setHistory: vi.fn((update) => update(EMPTY_HISTORY)), + setDraft: vi.fn(), + setCaret: vi.fn(), + setActiveSuggestion: vi.fn(), + clearSkillOrigin: vi.fn(), + clearImageAttachments: vi.fn(), + setNotice: vi.fn() + }) + ) +} + +describe('useNativeChatPickerCommandDispatch', () => { + beforeEach(() => { + vi.clearAllMocks() + const handle = { cancel: vi.fn(), settleAfterMs: 0 } + sendNativeChatMessage.mockReturnValue(handle) + sendNativeChatTypedCommand.mockReturnValue(handle) + }) + + it('types Codex autocomplete commands', () => { + const hook = renderDispatch('codex') + act(() => hook.result.current(COMMAND)) + + expect(sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(sendNativeChatMessage).not.toHaveBeenCalled() + }) + + it.each(['claude', 'openclaude'] as const)('keeps %s autocomplete commands pasted', (agent) => { + const hook = renderDispatch(agent) + act(() => hook.result.current(COMMAND)) + + expect(sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/status') + expect(sendNativeChatTypedCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts index 66f34362fce..0022514fd97 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -5,7 +5,7 @@ import { emitNativeChatPickerItemAccepted, emitNativeChatSendClassified } from '@/lib/native-chat-telemetry' -import { sendNativeChatMessage } from './native-chat-runtime-send' +import { sendNativeChatMessage, sendNativeChatTypedCommand } from './native-chat-runtime-send' import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget @@ -57,7 +57,11 @@ export function useNativeChatPickerCommandDispatch(args: { if (!target || disabled || isDispatchingSessionOption) { return } - trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text)) + trackPendingSend( + agent === 'codex' + ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) + : sendNativeChatMessage(target.settings, target.ptyId, text) + ) emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) // Why: picker dispatch is a catalog-verified command send; it must leave // the same telemetry and composer state as the typed path — including diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx new file mode 100644 index 00000000000..5a43cd9b4be --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const sendNativeChatMessageVerified = vi.fn() +const typeNativeChatCommand = vi.fn() + +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessageVerified: (...args: unknown[]) => sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => typeNativeChatCommand(...args) +})) +vi.mock('./native-chat-pty-send-queue', () => ({ + cancelNativeChatPtySends: vi.fn(), + waitForNativeChatPtyIdle: vi.fn() +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) + +import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' + +function renderDispatch(agent: 'codex' | 'claude' | 'openclaude') { + return renderHook(() => + useNativeChatSessionOptionCommand({ + agent, + disabled: false, + resolveTarget: () => ({ settings: {}, ptyId: 'pty-1' }), + setHistory: vi.fn() + }) + ) +} + +describe('useNativeChatSessionOptionCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + sendNativeChatMessageVerified.mockResolvedValue(true) + typeNativeChatCommand.mockResolvedValue(true) + }) + + it('types Codex option commands even without caller delivery metadata', async () => { + const hook = renderDispatch('codex') + await act(() => hook.result.current.dispatch('/model')) + + expect(typeNativeChatCommand).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model', + expect.any(AbortSignal) + ) + expect(sendNativeChatMessageVerified).not.toHaveBeenCalled() + }) + + it.each(['claude', 'openclaude'] as const)('keeps %s option commands pasted', async (agent) => { + const hook = renderDispatch(agent) + await act(() => hook.result.current.dispatch('/model sonnet', { delivery: 'type' })) + + expect(sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model sonnet', + expect.any(AbortSignal) + ) + expect(typeNativeChatCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 96d3cbdf479..b5c6e5da9fc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -6,7 +6,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send' import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, @@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: { // submit immediately so historical output cannot satisfy the match. observer.arm() } - const accepted = await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + const accepted = + agent === 'codex' + ? await typeNativeChatCommand( + target.settings, + target.ptyId, + command, + sendController.signal + ) + : await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx deleted file mode 100644 index 06b1267f80b..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption -} from '@/lib/project-host-setup-options' -import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' - -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/command', () => ({ - Command: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandItem: ({ - children, - disabled, - onSelect, - value - }: { - children: React.ReactNode - disabled?: boolean - onSelect?: (value: string) => void - value: string - }) => ( - - ) -})) - -let container: HTMLDivElement -let root: Root - -const readyOption: ProjectHostSetupOption = { - id: 'local-setup', - kind: 'ready', - projectId: 'project-1', - hostId: 'local', - repoId: 'local-repo', - label: 'Local Mac', - detail: 'Orca', - path: '/Users/alice/orca' -} - -const needsSetupOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:ssh:builder', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'ssh:builder', - label: 'Builder', - detail: 'Project location not set', - isAvailable: true, - attention: false -} - -const unavailableOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:runtime:old', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'runtime:old', - label: 'Old server', - detail: 'Update Orca on this host to set up projects', - isAvailable: false, - attention: false -} - -beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() -}) - -function renderCombobox({ - onValueChange = vi.fn() -}: { - onValueChange?: (setupId: string) => void -} = {}): void { - act(() => { - root.render( - - ) - }) -} - -describe('ProjectHostSetupCombobox', () => { - it('routes ready setup rows through onValueChange', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - act(() => { - container - .querySelector('[data-command-value="local-setup"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(onValueChange).toHaveBeenCalledWith('local-setup') - }) - - it('hides hosts that need setup from the run target list', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - expect( - container.querySelector('[data-command-value="needs-setup:ssh:builder"]') - ).toBeNull() - expect(container.textContent).not.toContain('Project location not set') - expect(onValueChange).not.toHaveBeenCalled() - }) - - it('hides unavailable setup rows from the run target list', () => { - const onValueChange = vi.fn() - - act(() => { - root.render( - - ) - }) - - const unavailableButton = container.querySelector( - '[data-command-value="needs-setup:runtime:old"]' - ) - expect(unavailableButton).toBeNull() - expect(container.textContent).not.toContain('Update Orca on this host') - - expect(onValueChange).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx deleted file mode 100644 index 22df2d5f192..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' -import { translate } from '@/i18n/i18n' - -type ProjectHostSetupComboboxProps = { - options: readonly ProjectHostSetupOption[] - value: string | null - onValueChange: (setupId: string) => void -} - -export default function ProjectHostSetupCombobox({ - options, - value, - onValueChange -}: ProjectHostSetupComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const readyOptions = options.filter((option) => option.kind === 'ready') - const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null - - const handleSelect = React.useCallback( - (setupId: string): void => { - const option = options.find((candidate) => candidate.id === setupId) - if (!option) { - return - } - if (!readyOptions.some((candidate) => candidate.id === setupId)) { - return - } - onValueChange(setupId) - setOpen(false) - }, - [onValueChange, options, readyOptions] - ) - - return ( - - - - - - - - - {translate( - 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', - 'No hosts are ready for this project.' - )} - - {readyOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} -
-
-
- ))} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx deleted file mode 100644 index 832af22219c..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { AgentFeatureSetupStep } from './AgentFeatureSetupStep' - -describe('AgentFeatureSetupStep', () => { - it('renders the agent feature setup checklist', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Agent Browser Use') - expect(html).toContain('Computer Use') - expect(html).toContain('Agent Orchestration') - expect(html).toContain('Linear agent skill') - expect(html).toContain('Enable capabilities') - expect(html).toContain('role="checkbox"') - }) -}) diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx deleted file mode 100644 index 647600e02bb..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Loader2, Terminal } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { FeatureSetupChecklist } from './FeatureSetupChecklist' -import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal' -import { - hasSelectedOnboardingFeatureSetup, - type OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type AgentFeatureSetupStepProps = { - featureSetup: OnboardingFeatureSetupSelection - onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void - featureSetupCommand: string | null - featureSetupCommandSelection: OnboardingFeatureSetupSelection | null - setupBusyLabel: string | null - onStartFeatureSetup: () => void -} - -export function AgentFeatureSetupStep({ - featureSetup, - onFeatureSetupChange, - featureSetupCommand, - featureSetupCommandSelection, - setupBusyLabel, - onStartFeatureSetup -}: AgentFeatureSetupStepProps): React.JSX.Element { - const hasSelectedFeatures = hasSelectedOnboardingFeatureSetup(featureSetup) - const showSetupAction = !featureSetupCommand - - return ( - <> - - {showSetupAction ? ( -
- -
- ) : null} - {featureSetupCommand ? ( - - ) : null} - - ) -} diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx deleted file mode 100644 index 36fb6443ce6..00000000000 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { ReactNode } from 'react' -import { Check, Globe2, MonitorCog, TicketCheck, Workflow } from 'lucide-react' -import { cn } from '@/lib/utils' -import type { - OnboardingFeatureSetupId, - OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type FeatureSetupChecklistProps = { - value: OnboardingFeatureSetupSelection - onChange: (value: OnboardingFeatureSetupSelection) => void -} - -type FeatureSetupRow = { - id: OnboardingFeatureSetupId - title: string - description: string - setupSummary: string - icon: ReactNode -} - -const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [ - { - id: 'browserUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628', - 'Agent Browser Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.01426f3a23', - 'Agents can navigate sites, inspect pages, and work through browser tasks.' - ) - }, - setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.', - icon: - }, - { - id: 'computerUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', - 'Computer Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.c5292c409d', - 'Agents can inspect app windows and operate local apps when you ask.' - ) - }, - setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.', - icon: - }, - { - id: 'orchestration', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.399cf885c0', - 'Agent Orchestration' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.77f74946f5', - 'Agents can message each other, take tasks, and coordinate handoffs.' - ) - }, - setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.', - icon: - }, - { - id: 'linearTickets', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsTitle', - 'Linear agent skill' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsDescription', - 'Agents can use linked Linear tasks for richer ticket-aware handoffs.' - ) - }, - get setupSummary() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsSetupSummary', - 'Recommended for Linear workspaces; does not affect Linear connection setup.' - ) - }, - icon: - } -] - -export function FeatureSetupChecklist({ - value, - onChange -}: FeatureSetupChecklistProps): React.JSX.Element { - return ( -
-
- {FEATURE_SETUP_ROWS.map((row) => { - const selected = value[row.id] - return ( - - ) - })} -
-
- ) -} diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index c5546aa95e6..f32b401fff2 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -100,15 +100,13 @@ const stepTooltipLabels = { type OnboardingFlowProps = { onboarding: OnboardingState onOnboardingChange: (state: OnboardingState) => void - onSettingsDetourStart?: () => void } export default function OnboardingFlow({ onboarding, - onOnboardingChange, - onSettingsDetourStart + onOnboardingChange }: OnboardingFlowProps): React.JSX.Element { - const flow = useOnboardingFlow(onboarding, onOnboardingChange, { onSettingsDetourStart }) + const flow = useOnboardingFlow(onboarding, onOnboardingChange) const continueShortcutModifierLabel = getScreenSubmitModifierLabel() const { currentStep, stepIndex, busyLabel } = flow const copy = stepCopy[currentStep.id] @@ -321,7 +319,7 @@ export default function OnboardingFlow({ busyLabel={busyLabel} onSkipToRepo={() => void flow.skipToRepo()} stepIndex={stepIndex} - onBack={flow.nestedScan ? flow.cancelNested : flow.back} + onBack={flow.back} showPrimary primaryBusy={shouldShowFooterBusy} primaryLabel={footerPrimaryLabel} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx deleted file mode 100644 index de01325e1d5..00000000000 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ComponentProps } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { RepoStep } from './RepoStep' -import { TooltipProvider } from '../ui/tooltip' - -function renderRepoStep(overrides: Partial> = {}): string { - return renderToStaticMarkup( - - - - ) -} - -describe('RepoStep', () => { - it('renders the add project options without existing-project chrome', () => { - const html = renderRepoStep() - - expect(html).not.toContain('Project already added') - expect(html).toContain('Browse for a folder') - expect(html).toContain('Clone a repo') - }) - - it('presents the local-folder card itself as the browse action', () => { - const html = renderRepoStep() - - expect(html).toContain('border-border bg-muted/30') - expect(html).toContain('focus:border-foreground/70') - expect(html).toContain('focus:ring-2 focus:ring-inset focus:ring-foreground/25') - expect(html).toContain('autofocus=""') - expect(html).toContain('Browse for a folder') - expect(html).toContain('group-hover:translate-x-0.5') - expect(html).toContain('w-fit max-w-[calc(100%-3.75rem)]') - expect(html).toContain('Want to import many repos at once? Select the parent folder.') - }) - - it('disables the nested import action when no repositories are selected', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - } - }) - - expect(html).toContain('Import repositories') - expect(html).toContain('Scanned folder: platform - /workspace/platform') - expect(html).not.toContain('Group name') - expect(html).not.toContain('Import separately') - expect(html).not.toContain('Import as project group') - expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(1) - }) - - it('shows a stop action and disables import while nested scan is still running', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - }, - nestedScanInProgress: true, - nestedSelectedPaths: new Set(['/workspace/platform/apps/web']) - }) - - expect(html).toContain('Scanning... Found 1 repository in this folder.') - expect(html).toContain('aria-label="Stop scan"') - expect(html).toContain('Showing partial scan results.') - expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(3) - }) - - it('shows a flat nested repository checklist with collision labels', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [ - { path: '/workspace/platform/web', displayName: 'web', depth: 1 }, - { path: '/workspace/platform/payments/api', displayName: 'api', depth: 2 }, - { path: '/workspace/platform/billing/api', displayName: 'api', depth: 2 } - ], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - }, - nestedSelectedPaths: new Set(['/workspace/platform/web']) - }) - - expect(html).toContain('Found 3 repositories in this folder.') - expect(html).toContain('web') - expect(html).toContain('payments/api') - expect(html).toContain('billing/api') - expect(html).not.toContain('Project group') - }) -}) diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx deleted file mode 100644 index 0202ef6a6a6..00000000000 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { - ArrowRight, - CircleStop, - FolderOpen, - GitBranch, - Lightbulb, - Loader2, - Server -} from 'lucide-react' -import type { Dispatch, SetStateAction } from 'react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { NestedRepoScanResult } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' -import { RepoStepNestedImportPanel } from './RepoStepNestedImportPanel' - -type RepoStepProps = { - cloneUrl: string - onCloneUrlChange: (value: string) => void - nestedScan: NestedRepoScanResult | null - nestedScanInProgress: boolean - nestedSelectedPaths: Set - onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void - onCancelNested: () => void - onStopNestedScan: () => void - onOpenFolder: () => void - onOpenServerFolder: (kind: 'git' | 'folder') => void - onClone: () => void - onOpenSshSettings: () => void - serverPath: string - onServerPathChange: (value: string) => void - cloneDestination: string - onCloneDestinationChange: (value: string) => void - workspaceDir: string - runtimeActive: boolean - busyLabel: string | null - error: string | null -} - -export function RepoStep({ - cloneUrl, - onCloneUrlChange, - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - onNestedSelectedPathsChange, - onImportNested, - onCancelNested, - onStopNestedScan, - onOpenFolder, - onOpenServerFolder, - onClone, - onOpenSshSettings, - serverPath, - onServerPathChange, - cloneDestination, - onCloneDestinationChange, - workspaceDir, - runtimeActive, - busyLabel, - error -}: RepoStepProps) { - const disabled = Boolean(busyLabel) - if (nestedScan) { - return ( - - ) - } - return ( -
- {runtimeActive ? ( -
{ - event.preventDefault() - onOpenServerFolder('git') - }} - > -
-
- -
-
-
- {translate( - 'auto.components.onboarding.RepoStep.8cab104e3c', - 'Open a project on this host' - )} -
-
- {translate( - 'auto.components.onboarding.RepoStep.466108ab89', - 'Enter a path that exists on the selected host.' - )} -
-
-
-
- onServerPathChange(event.target.value)} - /> - - -
-
- ) : ( - - )} - -
{ - e.preventDefault() - onClone() - }} - > -
-
- -
-
-
- {translate('auto.components.onboarding.RepoStep.132425a3e3', 'Clone a repo')} -
-
- {translate( - 'auto.components.onboarding.RepoStep.288d8444b7', - 'Paste an HTTPS or SSH URL.' - )} -
-
-
-
- onCloneUrlChange(event.target.value)} - /> - -
- {runtimeActive && ( -
- - onCloneDestinationChange(event.target.value)} - /> -
- )} -
- -
-
- {translate('auto.components.onboarding.RepoStep.7b679207e4', 'Workspace')} - - {runtimeActive - ? translate('auto.components.onboarding.RepoStep.cf23006ba7', 'Selected host') - : workspaceDir} - -
- {runtimeActive ? ( -
- - - {translate('auto.components.onboarding.RepoStep.c33b190ca3', 'Host paths only')} - -
- ) : ( - - )} -
- - {busyLabel && ( -
- {busyLabel} - {nestedScanInProgress ? ( - - - - - - {translate( - 'auto.components.onboarding.RepoStep.e8fdb36338', - 'Scanning repositories. Click to stop.' - )} - - - ) : null} -
- )} - {error && ( -
- {error} -
- )} -
- ) -} diff --git a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx deleted file mode 100644 index e7b24e2b71e..00000000000 --- a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { ArrowLeft, CircleStop, FolderOpen, Loader2 } from 'lucide-react' -import type { Dispatch, SetStateAction } from 'react' -import { Button } from '@/components/ui/button' -import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import type { NestedRepoScanResult } from '../../../../shared/types' -import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' -import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' - -type RepoStepNestedImportPanelProps = { - nestedScan: NestedRepoScanResult - nestedScanInProgress: boolean - nestedSelectedPaths: Set - onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void - onCancelNested: () => void - onStopNestedScan: () => void - busyLabel: string | null - error: string | null - disabled: boolean -} - -export function RepoStepNestedImportPanel({ - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - onNestedSelectedPathsChange, - onImportNested, - onCancelNested, - onStopNestedScan, - busyLabel, - error, - disabled -}: RepoStepNestedImportPanelProps) { - const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath - const nestedImportDisabled = disabled || nestedScanInProgress - return ( -
-
-
-
- -
-
-
- {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} -
-
- {nestedScanInProgress ? ( - - - - - - {translate( - 'auto.components.onboarding.RepoStep.e8fdb36338', - 'Scanning repositories. Click to stop.' - )} - - - ) : null} - - {translate( - 'auto.components.onboarding.RepoStep.2e6438dd34', - '{{value0}}Found {{value1}} {{value2}} in this folder.', - { - value0: nestedScanInProgress ? 'Scanning... ' : '', - value1: nestedScan.repos.length, - value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' - } - )} - -
-
- {translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '} - {folderName} - {nestedScan.selectedPath} -
-
-
- - {nestedScanInProgress || - nestedScan.truncated || - nestedScan.timedOut || - nestedScan.stopped ? ( -
- -
- ) : null} -
- - -
-
- {busyLabel ? ( -
- {busyLabel} -
- ) : null} - {error ? ( -
- {error} -
- ) : null} -
- ) -} diff --git a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts b/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts deleted file mode 100644 index fefefe03968..00000000000 --- a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createOnboardingTourOutcomeTracker, - markOnboardingTourIntroReached, - markOnboardingTourStarted, - recordOnboardingTourDepthSummary, - resolveOnboardingTourOutcome, - resolvePendingOnboardingTourOutcome -} from './onboarding-tour-outcome-tracker' - -const depthSummary = { - furthest_step: 'review_ship', - last_group_id: 'review', - visited_workflow_count: 5, - visited_substep_count: 9, - completed_workflow_count: 4, - completed_substep_count: 7 -} as const - -describe('onboarding tour outcome tracker', () => { - it('emits one skipped outcome for one intro skip', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 250, 'button')).toEqual({ - outcome: 'skipped_intro', - intro_duration_ms: 150, - advanced_via: 'button' - }) - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 260, 'button')).toBeNull() - }) - - it('emits one completed outcome with inline tour depth', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 175) - recordOnboardingTourDepthSummary(tracker, depthSummary) - - expect(resolveOnboardingTourOutcome(tracker, 'completed_inline', 575, 'button')).toEqual({ - outcome: 'completed_inline', - intro_duration_ms: 75, - tour_dwell_ms: 400, - furthest_step: 'review_ship', - visited_workflow_count: 5, - visited_substep_count: 9, - completed_workflow_count: 4, - completed_substep_count: 7, - advanced_via: 'button' - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 600)).toBeNull() - }) - - it('does not resolve a back or step jump partial before session resolution', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 125) - - expect(tracker.startedInline).toBe(true) - recordOnboardingTourDepthSummary(tracker, { - visited_workflow_count: 1, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0 - }) - expect(resolveOnboardingTourOutcome(tracker, 'completed_inline', 225, 'button')).toMatchObject({ - outcome: 'completed_inline' - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 250)).toBeNull() - }) - - it('classifies a started tour as partial even if the final action is skip', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 150) - recordOnboardingTourDepthSummary(tracker, { - furthest_step: 'tasks', - last_group_id: 'tasks', - visited_workflow_count: 2, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0 - }) - - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 400, 'button')).toEqual({ - outcome: 'started_partial', - intro_duration_ms: 50, - tour_dwell_ms: 250, - furthest_step: 'tasks', - visited_workflow_count: 2, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0, - advanced_via: 'button' - }) - }) - - it('emits at most one pending partial on session close or unmount', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 150) - recordOnboardingTourDepthSummary(tracker, { - furthest_step: 'workbench_editor', - last_group_id: 'workbench', - visited_workflow_count: 1, - visited_substep_count: 2, - completed_workflow_count: 0, - completed_substep_count: 2 - }) - - expect(resolvePendingOnboardingTourOutcome(tracker, 450)).toEqual({ - outcome: 'started_partial', - intro_duration_ms: 50, - tour_dwell_ms: 300, - furthest_step: 'workbench_editor', - visited_workflow_count: 1, - visited_substep_count: 2, - completed_workflow_count: 0, - completed_substep_count: 2 - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 475)).toBeNull() - }) -}) diff --git a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts b/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts deleted file mode 100644 index 069f5679465..00000000000 --- a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { FEATURE_WALL_MAX_DWELL_MS } from '../../../../shared/feature-wall-telemetry' -import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' -import type { EventProps } from '../../../../shared/telemetry-events' - -type OnboardingTourOutcome = EventProps<'onboarding_tour_outcome'>['outcome'] -type OnboardingTourOutcomePayload = EventProps<'onboarding_tour_outcome'> - -export type OnboardingTourOutcomeTracker = { - reachedIntro: boolean - startedInline: boolean - completedInline: boolean - emitted: boolean - introStartedAtMs: number | null - introDurationMs: number | null - tourStartedAtMs: number | null - latestDepthSummary: FeatureWallTourDepthSummary | null -} - -export function createOnboardingTourOutcomeTracker(): OnboardingTourOutcomeTracker { - return { - reachedIntro: false, - startedInline: false, - completedInline: false, - emitted: false, - introStartedAtMs: null, - introDurationMs: null, - tourStartedAtMs: null, - latestDepthSummary: null - } -} - -function boundedDurationMs(startedAtMs: number | null, nowMs: number): number | undefined { - if (startedAtMs === null) { - return undefined - } - return Math.min(FEATURE_WALL_MAX_DWELL_MS, Math.max(0, Math.round(nowMs - startedAtMs))) -} - -export function markOnboardingTourIntroReached( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): void { - if (tracker.reachedIntro) { - return - } - tracker.reachedIntro = true - tracker.introStartedAtMs = nowMs -} - -export function markOnboardingTourStarted( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): void { - if (!tracker.reachedIntro) { - markOnboardingTourIntroReached(tracker, nowMs) - } - tracker.startedInline = true - tracker.introDurationMs = boundedDurationMs(tracker.introStartedAtMs, nowMs) ?? 0 - tracker.tourStartedAtMs = nowMs -} - -export function recordOnboardingTourDepthSummary( - tracker: OnboardingTourOutcomeTracker, - summary: FeatureWallTourDepthSummary -): void { - tracker.latestDepthSummary = summary -} - -export function resolveOnboardingTourOutcome( - tracker: OnboardingTourOutcomeTracker, - outcome: OnboardingTourOutcome, - nowMs: number, - advancedVia?: NonNullable -): OnboardingTourOutcomePayload | null { - if (!tracker.reachedIntro || tracker.emitted) { - return null - } - const resolvedOutcome = - tracker.completedInline || outcome === 'completed_inline' - ? 'completed_inline' - : tracker.startedInline - ? 'started_partial' - : outcome - const introDurationMs = - tracker.introDurationMs ?? boundedDurationMs(tracker.introStartedAtMs, nowMs) - const depthSummary = resolvedOutcome === 'skipped_intro' ? null : tracker.latestDepthSummary - const tourDwellMs = - resolvedOutcome === 'skipped_intro' - ? undefined - : boundedDurationMs(tracker.tourStartedAtMs, nowMs) - - tracker.emitted = true - if (resolvedOutcome === 'completed_inline') { - tracker.completedInline = true - } - - return { - outcome: resolvedOutcome, - ...(introDurationMs !== undefined ? { intro_duration_ms: introDurationMs } : {}), - ...(tourDwellMs !== undefined ? { tour_dwell_ms: tourDwellMs } : {}), - ...(depthSummary - ? { - ...(depthSummary.furthest_step ? { furthest_step: depthSummary.furthest_step } : {}), - visited_workflow_count: depthSummary.visited_workflow_count, - visited_substep_count: depthSummary.visited_substep_count, - completed_workflow_count: depthSummary.completed_workflow_count, - completed_substep_count: depthSummary.completed_substep_count - } - : {}), - ...(advancedVia ? { advanced_via: advancedVia } : {}) - } -} - -export function resolvePendingOnboardingTourOutcome( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): OnboardingTourOutcomePayload | null { - if (!tracker.startedInline || tracker.completedInline) { - return null - } - return resolveOnboardingTourOutcome(tracker, 'started_partial', nowMs) -} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index 893e1c38fac..1dfbc0c708a 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -23,9 +23,8 @@ import type { StepNumber } from './use-onboarding-flow-types' type CloseWithCallback = ( outcome: 'completed' | 'dismissed', - checklist: Partial, lastStepReached: StepNumber, - completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal', + completedPath?: 'add_project_modal', dismissedExtras?: DismissedExtras ) => Promise @@ -48,7 +47,6 @@ function setApi(api: { function CloseWithProbe(props: { onReady: (closeWith: CloseWithCallback) => void }): null { const closeWith = useCloseWith({ onOnboardingChange: vi.fn(), - onboardingChecklist: makeOnboardingState().checklist, startTimeRef: { current: Date.now() }, setError: vi.fn() }) @@ -145,7 +143,7 @@ describe('onboarding flow persistence', () => { })) await act(async () => { - await closeWith?.('completed', {}, 5) + await closeWith?.('completed', 5) }) const api = ( diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index c0354249fc5..12a8c6e22a2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -35,7 +35,6 @@ export function buildCompletedOnboardingNotificationSettings( type CloseWithDeps = { onOnboardingChange: (state: OnboardingState) => void - onboardingChecklist: OnboardingState['checklist'] startTimeRef: { current: number } setError: (msg: string | null) => void } @@ -67,12 +66,7 @@ export function trackOnboardingDismissed( track('onboarding_dismissed', buildOnboardingDismissedPayload(lastStepReached, dismissedExtras)) } -export function useCloseWith({ - onOnboardingChange, - onboardingChecklist, - startTimeRef, - setError -}: CloseWithDeps) { +export function useCloseWith({ onOnboardingChange, startTimeRef, setError }: CloseWithDeps) { // Why: onboarding closes exactly once. On the final notifications step both // the "Add your first project" handoff (completed) and a click-off/Escape // dismissal (dismissed) can reach closeWith, and next()'s persist window @@ -83,9 +77,8 @@ export function useCloseWith({ return useCallback( async ( outcome: 'completed' | 'dismissed', - checklist: Partial, lastStepReached: StepNumber, - completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal', + completedPath?: 'add_project_modal', dismissedExtras?: DismissedExtras ): Promise => { if (closedRef.current) { @@ -94,18 +87,12 @@ export function useCloseWith({ closedRef.current = true let nextState: OnboardingState try { - // Why: main-process updateOnboarding already merges with current state, - // so spreading the local (potentially stale) onboarding.checklist would - // overwrite concurrent updates. nextState = await window.api.onboarding.update({ flowVersion: ONBOARDING_FLOW_VERSION, closedAt: Date.now(), outcome, lastCompletedStep: outcome === 'completed' ? ONBOARDING_FINAL_STEP : -1, - checklist: { - ...checklist, - dismissed: outcome === 'dismissed' - } + checklist: { dismissed: outcome === 'dismissed' } }) } catch (err) { // Why: the persist failed, so onboarding did not actually close — clear @@ -124,22 +111,6 @@ export function useCloseWith({ path: completedPath, total_duration_ms: total }) - // Why: checklist items completed by the wizard itself must fire - // `activation_checklist_item_completed` so the post-wizard panel and - // analytics agree. Other items (ranFirstAgent, triedCmdJ, …) emit - // from their own product surfaces. - if (checklist.addedRepo && !onboardingChecklist.addedRepo) { - track('activation_checklist_item_completed', { - item: 'addedRepo', - time_since_completed_ms: 0 - }) - } - if (checklist.addedFolder && !onboardingChecklist.addedFolder) { - track('activation_checklist_item_completed', { - item: 'addedFolder', - time_since_completed_ms: 0 - }) - } } if (outcome === 'completed') { // Why: closeWith updates parent state synchronously from this hook's @@ -152,7 +123,7 @@ export function useCloseWith({ } return true }, - [onOnboardingChange, onboardingChecklist, startTimeRef, setError] + [onOnboardingChange, startTimeRef, setError] ) } diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts deleted file mode 100644 index 62f2df5b254..00000000000 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const ONBOARDING_FLOW_PATH = join(import.meta.dirname, 'use-onboarding-flow.ts') - -describe('useOnboardingFlow project-added handoff', () => { - it('routes Git repo completion through the shared default-checkout opener', () => { - const source = readFileSync(ONBOARDING_FLOW_PATH, 'utf8') - - expect(source).toContain('openProjectDefaultCheckout({') - expect(source).toContain('repoId: projectId') - expect(source).toContain( - "source: path === 'clone_url' ? 'onboarding_clone_url' : 'onboarding_open_folder'" - ) - expect(source).toContain('setHideDefaultBranchWorkspace') - expect(source).not.toContain("openModal('project-added'") - }) -}) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts index 39270546c94..0133a720059 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts @@ -43,7 +43,7 @@ describe('prepareSkippedOnboardingPreferences', () => { }) }) - it('keeps the selected agent preference before jumping to repo setup', async () => { + it('keeps the selected agent preference before opening project setup', async () => { const setTheme = vi.fn() const applyTheme = vi.fn() const updateSettings = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index b21b4c742e8..b5376b363f1 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -3,35 +3,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { applyDocumentTheme } from '@/lib/document-theme' import { track } from '@/lib/telemetry' -import { getSelectedNestedRepoPathsInScanOrder } from '@/lib/nested-repo-selected-paths' import { buildAgentPickedPayload } from './agent-picked-payload' import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../../../shared/constants' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import { - buildNestedRepoImportActionTelemetry, - buildNestedRepoImportResultTelemetry, - buildNestedRepoScanTelemetry, - createNestedRepoTelemetryAttemptId, - shouldEmitNestedRepoImportSubmitTelemetry, - type NestedRepoTelemetryRuntimeKind -} from '../../../../shared/nested-repo-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' -import type { - GlobalSettings, - NestedRepoScanResult, - OnboardingState, - Repo, - TuiAgent -} from '../../../../shared/types' +import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { buildOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' -import { openProjectDefaultCheckout } from '../sidebar/project-added-default-checkout' import { translate } from '@/i18n/i18n' import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' @@ -85,10 +65,6 @@ function resolveStepIndex( return nextIndex } -function createNestedRepoScanId(): string { - return `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}` -} - function getGitHubTaskSourceStatus( status: ReturnType['preflightStatus'], loading: boolean @@ -187,7 +163,7 @@ export async function prepareSkippedOnboardingPreferences({ await updateSettings({ theme: themeToRestore }) } } - // Why: the repo step seeds folder terminals from saved settings, so preserve the visible agent choice on skip. + // Why: skipping bypasses step persistence, so save the visible agent choice before closing. if (currentStepId === 'agent' && selectedAgent) { await updateSettings({ defaultTuiAgent: selectedAgent }) } @@ -208,10 +184,8 @@ export async function prepareSkippedOnboardingPreferences({ export function useOnboardingFlow( onboarding: OnboardingState, - onOnboardingChange: (state: OnboardingState) => void, - options: { onSettingsDetourStart?: () => void } = {} + onOnboardingChange: (state: OnboardingState) => void ) { - const { onSettingsDetourStart } = options const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const refreshDetectedAgents = useAppStore((s) => s.refreshDetectedAgents) @@ -219,24 +193,13 @@ export function useOnboardingFlow( const isDetectingAgents = useAppStore((s) => s.isDetectingAgents || s.isRefreshingAgents) const pathSource = useAppStore((s) => s.pathSource) const pathFailureReason = useAppStore((s) => s.pathFailureReason) - const fetchRepos = useAppStore((s) => s.fetchRepos) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) - const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) - const addRepoPath = useAppStore((s) => s.addRepoPath) - const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) - const cancelNestedRepoScan = useAppStore((s) => s.cancelNestedRepoScan) - const importNestedRepos = useAppStore((s) => s.importNestedRepos) const openModal = useAppStore((s) => s.openModal) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) - // Why: repos are hydrated before onboarding mounts; the sync read lets the final step render added state without a flash. - const repos = useAppStore((s) => s.repos) // Why: renderToStaticMarkup uses Zustand's initial snapshot; the sync read keeps tests and the first client render aligned. const effectivePreflightStatus = preflightStatus ?? useAppStore.getState().preflightStatus @@ -266,20 +229,8 @@ export function useOnboardingFlow( ) // Why: hydrate theme from saved settings so users who already chose one see it preselected. const [theme, setTheme] = useState(settings?.theme ?? 'dark') - const [cloneUrl, setCloneUrl] = useState('') - const [serverPath, setServerPath] = useState('') - const [cloneDestination, setCloneDestination] = useState('') - const [nestedScan, setNestedScan] = useState(null) - const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) - const [nestedAttemptId, setNestedAttemptId] = useState(null) - const [nestedRuntimeKind, setNestedRuntimeKind] = useState( - null - ) - const [nestedScanInProgress, setNestedScanInProgress] = useState(false) - const [nestedImportScanId, setNestedImportScanId] = useState(null) - const nestedScanIdRef = useRef(null) const [busyLabel, setBusyLabel] = useState(null) - const [error, setError] = useState(null) + const [, setError] = useState(null) // Why: settings hydrate async after the lazy initializers run; re-sync once before commit unless the user edited the field. const themeInteractedRef = useRef(false) @@ -377,8 +328,6 @@ export function useOnboardingFlow( 0, progressSteps.findIndex(({ index }) => index === displayedStepIndex) ) - const hasExistingProject = repos.length > 0 - // Why: pin start time once so onboarding_completed reports a real funnel duration. const startTimeRef = useRef(Date.now()) @@ -528,58 +477,10 @@ export function useOnboardingFlow( const closeWith = useCloseWith({ onOnboardingChange, - onboardingChecklist: onboarding.checklist, startTimeRef, setError }) - const completeRepo = useCallback( - async (projectId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => { - await fetchRepos() - // Why: a non-authoritative Git refresh should still complete onboarding onto the project row as a fallback. - await fetchWorktrees(projectId, isGit ? { requireAuthoritative: true } : undefined) - const worktrees = useAppStore.getState().worktreesByRepo[projectId] ?? [] - if (isGit) { - await openProjectDefaultCheckout({ - repoId: projectId, - source: path === 'clone_url' ? 'onboarding_clone_url' : 'onboarding_open_folder', - setHideDefaultBranchWorkspace - }) - } else { - const worktree = worktrees[0] ?? null - if (worktree) { - // Why: non-git folders skip the composer, so seed their first terminal with the chosen default agent here. - const startup = buildOnboardingFolderAgentStartup(settings) - activateAndRevealWorktree(worktree.id, { startup }) - } - } - // Why: next() short-circuits the repo step; emit step_completed here, gated on closeWith success so a persistence failure can't double-count. - const closed = await closeWith( - 'completed', - isGit ? { addedRepo: true } : { addedFolder: true }, - ONBOARDING_FINAL_STEP, - path - ) - if (!closed) { - return - } - // Why: the final repo step has no keyboard-vs-button distinction, so emit duration_ms without advanced_via. See docs/onboarding-telemetry-extensions.md §3. - track('onboarding_step_completed', { - step: ONBOARDING_FINAL_STEP, - value_kind: 'repo', - duration_ms: consumeStepDurationMs() - }) - }, - [ - closeWith, - consumeStepDurationMs, - fetchRepos, - fetchWorktrees, - setHideDefaultBranchWorkspace, - settings - ] - ) - const persistCurrentStep = usePersistCurrentStep({ currentStepId: currentStep.id, selectedAgent, @@ -639,12 +540,7 @@ export function useOnboardingFlow( trackCurrentStepCompleted(advancedVia) if (currentStep.id === 'notifications') { setBusyLabel('Opening Add Project...') - const closed = await closeWith( - 'completed', - {}, - ONBOARDING_FINAL_STEP, - 'add_project_modal' - ) + const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal') if (closed) { openModal('add-repo') } @@ -689,388 +585,6 @@ export function useOnboardingFlow( ] ) - const showNestedRepoReview = useCallback( - ( - scan: NestedRepoScanResult, - attemptId: string, - runtimeKind: NestedRepoTelemetryRuntimeKind, - inProgress = false, - scanId: string | null = null - ) => { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedAttemptId(attemptId) - setNestedRuntimeKind(runtimeKind) - setNestedScanInProgress(inProgress) - setNestedImportScanId(scanId) - }, - [] - ) - - const onboardingNestedRepoRuntimeKind: NestedRepoTelemetryRuntimeKind = - settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' - - const openFolder = useCallback( - async (kind: 'git' | 'folder' = 'git') => { - // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. - if (busyLabel !== null) { - return - } - setError(null) - if (settings?.activeRuntimeEnvironmentId?.trim()) { - const path = serverPath.trim() - if (!path) { - const message = 'Enter a path on the selected host.' - setError(message) - return - } - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…') - try { - if (kind === 'git') { - const attemptId = createNestedRepoTelemetryAttemptId() - const scan = await scanNestedRepos(path) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind: 'runtime', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, attemptId, 'runtime') - return - } - } - setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…') - const repo = await addRepoPath(path, kind) - if (!repo) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - return - } - await completeRepo(repo.id, isGitRepoKind(repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - nestedScanIdRef.current = null - setNestedScanInProgress(false) - setBusyLabel(null) - } - return - } - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - const path = await window.api.repos.pickFolder() - if (!path) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) - return - } - setBusyLabel('Opening project…') - try { - let result = await window.api.repos.add({ path }) - if ('error' in result && result.error.includes('Not a valid git repository')) { - setBusyLabel('Scanning for repositories...') - const attemptId = createNestedRepoTelemetryAttemptId() - const scanId = createNestedRepoScanId() - nestedScanIdRef.current = scanId - setNestedScanInProgress(true) - const scan = await scanNestedRepos(path, undefined, { - scanId, - onProgress: (progressScan) => { - if ( - nestedScanIdRef.current !== scanId || - progressScan.selectedPathKind !== 'non_git_folder' || - progressScan.repos.length === 0 - ) { - return - } - showNestedRepoReview(progressScan, attemptId, 'local', true, scanId) - } - }) - if (nestedScanIdRef.current !== scanId) { - return - } - nestedScanIdRef.current = null - setNestedScanInProgress(false) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind: 'local', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, attemptId, 'local', false, scanId) - return - } - result = await window.api.repos.add({ path, kind: 'folder' }) - } - if ('error' in result) { - throw new Error(result.error) - } - await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - nestedScanIdRef.current = null - setNestedScanInProgress(false) - setBusyLabel(null) - } - }, - [ - addRepoPath, - busyLabel, - completeRepo, - scanNestedRepos, - serverPath, - showNestedRepoReview, - settings?.activeRuntimeEnvironmentId - ] - ) - - const importNested = useCallback(async () => { - const mode = 'separate' - const attemptId = nestedAttemptId - if ( - !nestedScan || - !attemptId || - !shouldEmitNestedRepoImportSubmitTelemetry({ - attemptId, - selectedCount: nestedSelectedPaths.size, - isBusy: busyLabel !== null - }) - ) { - return - } - const foundCount = nestedScan.repos.length - const selectedCount = nestedSelectedPaths.size - const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind - setError(null) - setBusyLabel('Importing repositories…') - track( - 'add_repo_nested_import_action', - buildNestedRepoImportActionTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - action: 'import_separate', - foundCount, - selectedCount - }) - ) - let resultTracked = false - try { - const selectedProjectPaths = getSelectedNestedRepoPathsInScanOrder( - nestedScan, - nestedSelectedPaths - ) - const result = await importNestedRepos({ - parentPath: nestedScan.selectedPath, - groupName: '', - // Why: Set insertion order can drift after deselect/reselect; match the visible scan order users reviewed. - projectPaths: selectedProjectPaths, - ...(nestedImportScanId ? { scanId: nestedImportScanId } : {}), - mode - }) - track( - 'add_repo_nested_import_result', - buildNestedRepoImportResultTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - mode, - foundCount, - selectedCount, - result - }) - ) - resultTracked = true - const importedRepoIds = - result?.projects - .map((entry) => entry.projectId) - .filter((projectId): projectId is string => typeof projectId === 'string') ?? [] - const projectId = importedRepoIds[0] - if (!projectId) { - const firstFailure = result?.projects.find((entry) => entry.status === 'failed')?.error - throw new Error( - firstFailure ? `No repositories imported: ${firstFailure}` : 'No repositories imported' - ) - } - for (const importedRepoId of importedRepoIds) { - // Why: imported repos are already persisted, so a non-authoritative SSH refresh shouldn't block revealing the first project. - await fetchWorktrees(importedRepoId, { requireAuthoritative: true }) - } - await completeRepo(projectId, true, 'open_folder') - } catch (err) { - if (!resultTracked) { - track( - 'add_repo_nested_import_result', - buildNestedRepoImportResultTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - mode, - foundCount, - selectedCount, - result: null - }) - ) - } - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - setBusyLabel(null) - } - }, [ - busyLabel, - completeRepo, - fetchWorktrees, - importNestedRepos, - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedImportScanId, - nestedRuntimeKind, - onboardingNestedRepoRuntimeKind - ]) - - const trackNestedBackAndClear = useCallback(() => { - if (nestedScan && nestedAttemptId) { - track( - 'add_repo_nested_import_action', - buildNestedRepoImportActionTelemetry({ - attemptId: nestedAttemptId, - surface: 'onboarding', - runtimeKind: nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind, - action: 'back', - foundCount: nestedScan.repos.length, - selectedCount: nestedSelectedPaths.size - }) - ) - } - setNestedScan(null) - setNestedSelectedPaths(new Set()) - setNestedAttemptId(null) - setNestedRuntimeKind(null) - setNestedScanInProgress(false) - setNestedImportScanId(null) - nestedScanIdRef.current = null - setBusyLabel(null) - setError(null) - }, [ - nestedAttemptId, - nestedRuntimeKind, - nestedScan, - nestedSelectedPaths.size, - onboardingNestedRepoRuntimeKind - ]) - - // Why: lets the user back out of the nested-repo step to re-pick a folder/clone target. - const cancelNested = useCallback(() => { - if (busyLabel !== null && !nestedScanInProgress) { - return - } - if (nestedScanInProgress && nestedScanIdRef.current) { - void cancelNestedRepoScan(nestedScanIdRef.current) - } - trackNestedBackAndClear() - }, [busyLabel, cancelNestedRepoScan, nestedScanInProgress, trackNestedBackAndClear]) - - const stopNestedScan = useCallback(() => { - const scanId = nestedScanIdRef.current - if (!scanId) { - return - } - void cancelNestedRepoScan(scanId) - }, [cancelNestedRepoScan]) - - const canImportNestedForTelemetry = useCallback((): boolean => { - return Boolean(nestedScan && nestedAttemptId && nestedSelectedPaths.size > 0) - }, [nestedAttemptId, nestedScan, nestedSelectedPaths.size]) - - const clone = useCallback(async () => { - // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. - if (busyLabel !== null) { - return - } - const trimmed = cloneUrl.trim() - if (!trimmed || !settings) { - return - } - setError(null) - track('onboarding_step4_path_clicked', { path: 'clone_url' }) - const target = getActiveRuntimeTarget(settings) - const destination = - target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir - if (!destination) { - const message = 'Enter a host path for the clone destination.' - setError(message) - return - } - setBusyLabel('Cloning repo…') - try { - const repo = - target.kind === 'environment' - ? ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.clone', - { url: trimmed, destination }, - { timeoutMs: 10 * 60_000 } - ) - ).repo - : await window.api.repos.clone({ - url: trimmed, - destination - }) - await completeRepo(repo.id, true, 'clone_url') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'clone_url', reason: 'clone_failed' }) - toast.error( - translate('auto.components.onboarding.use.onboarding.flow.fd74e7558e', 'Clone failed'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } finally { - setBusyLabel(null) - } - }, [busyLabel, cloneDestination, cloneUrl, completeRepo, settings]) - - const continueWithExistingProject = useCallback( - async (advancedVia: 'button' | 'keyboard' = 'button') => { - if (busyLabel !== null || repos.length === 0) { - return - } - setError(null) - setBusyLabel('Finishing...') - try { - const checklist = repos.some((repo) => isGitRepoKind(repo)) - ? { addedRepo: true } - : { addedFolder: true } - const closed = await closeWith('completed', checklist, ONBOARDING_FINAL_STEP) - if (!closed) { - return - } - track('onboarding_step_completed', { - step: ONBOARDING_FINAL_STEP, - value_kind: 'repo', - duration_ms: consumeStepDurationMs(), - advanced_via: advancedVia - }) - } finally { - setBusyLabel(null) - } - }, - [busyLabel, closeWith, consumeStepDurationMs, repos] - ) - const skipToRepo = useCallback(async () => { if (busyLabel) { return @@ -1098,7 +612,7 @@ export function useOnboardingFlow( const valueKind = currentStep.valueKind setBusyLabel('Opening Add Project...') try { - const closed = await closeWith('completed', {}, ONBOARDING_FINAL_STEP, 'add_project_modal') + const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal') if (!closed) { return } @@ -1147,76 +661,23 @@ export function useOnboardingFlow( return false } setError(null) - const closed = await closeWith('dismissed', {}, currentStep.stepNumber, undefined, { + return closeWith('dismissed', currentStep.stepNumber, undefined, { durationMs: consumeStepDurationMs(), advancedVia }) - if (closed) { - if (nestedScan) { - trackNestedBackAndClear() - } - } - return closed }, - [ - busyLabel, - closeWith, - consumeStepDurationMs, - currentStep.stepNumber, - nestedScan, - trackNestedBackAndClear - ] + [busyLabel, closeWith, consumeStepDurationMs, currentStep.stepNumber] ) - const openSshSettings = useCallback(async () => { - if (busyLabel) { - return - } - setError(null) - try { - onOnboardingChange(await persistStep(currentStep.stepNumber - 1)) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - toast.error( - translate( - 'auto.components.onboarding.use.onboarding.flow.dce4bdce5b', - 'Could not open SSH settings' - ), - { description: message } - ) - return - } - // Why: SSH users need a temporary Settings detour without marking required repo setup dismissed. - onSettingsDetourStart?.() - // Why: set the target before the lazy Settings view mounts; a timer could fire before it subscribes and strand users on General. - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) - openSettingsPage() - }, [ - busyLabel, - currentStep.stepNumber, - onOnboardingChange, - onSettingsDetourStart, - openSettingsPage, - openSettingsTarget - ]) - const back = useCallback(() => { - if (nestedScan) { - trackNestedBackAndClear() - return - } setStepIndex(getPreviousStepIndex) - }, [getPreviousStepIndex, nestedScan, trackNestedBackAndClear]) + }, [getPreviousStepIndex]) const jumpToStep = useCallback( (idx: number) => { - if (nestedScan && idx !== stepIndex) { - trackNestedBackAndClear() - } setStepIndex(resolveStepIndex(idx, skipOptions, idx < stepIndex ? 'backward' : 'forward')) }, - [nestedScan, skipOptions, stepIndex, trackNestedBackAndClear] + [skipOptions, stepIndex] ) return { @@ -1232,23 +693,7 @@ export function useOnboardingFlow( setYoloPermissions: setYoloPermissionsInteractive, theme, setTheme: setThemeInteractive, - cloneUrl, - setCloneUrl, - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - setNestedSelectedPaths, - importNested, - cancelNested, - stopNestedScan, - canImportNestedForTelemetry, - hasExistingProject, - serverPath, - setServerPath, - cloneDestination, - setCloneDestination, busyLabel, - error, detectedSet, isDetectingAgents, next, @@ -1256,10 +701,6 @@ export function useOnboardingFlow( dismissOnboarding, back, jumpToStep, - setLifecycleRootRef, - openFolder, - continueWithExistingProject, - openSshSettings, - clone + setLifecycleRootRef } } diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx deleted file mode 100644 index 07980102743..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { cn } from '@/lib/utils' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' - -export function OrcaProfileAvatar({ - profile, - className -}: { - profile: OrcaProfileSummary - className?: string -}): React.JSX.Element { - return ( - - {profile.avatar.initials.slice(0, 2).toUpperCase()} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx deleted file mode 100644 index a21681a48b8..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Check, Cloud, Loader2, LogIn, LogOut, Plus } from 'lucide-react' -import { - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger -} from '@/components/ui/dropdown-menu' -import { translate } from '@/i18n/i18n' -import type { - OrcaCloudOrgSummary, - OrcaProfileAuthStatus, - OrcaProfileSummary -} from '../../../../shared/orca-profiles' - -function getConnectLabel(authStatus: OrcaProfileAuthStatus | null, connecting: boolean): string { - if (connecting) { - return translate('auto.components.orca.profiles.switcher.signInWaiting', 'Waiting for sign-in…') - } - if (authStatus?.configured !== true) { - return translate( - 'auto.components.orca.profiles.switcher.cloud.unavailable', - 'Orca sign-in unavailable' - ) - } - if (authStatus.state === 'reconnect-required') { - return translate('auto.components.orca.profiles.switcher.signInAgain', 'Sign in again') - } - return translate('auto.components.orca.profiles.switcher.signIn', 'Sign in to Orca') -} - -export function OrcaProfileCloudMenuItems({ - activeProfile, - authStatus, - connecting, - profileActionDisabled, - allowProfileCreation, - separateAuthActions, - onConnect, - onCreateProfileForOrg, - onSelectOrg, - onRequestSignOut -}: { - activeProfile: OrcaProfileSummary - authStatus: OrcaProfileAuthStatus | null - connecting: boolean - profileActionDisabled: boolean - allowProfileCreation: boolean - separateAuthActions: boolean - onConnect: () => void - onCreateProfileForOrg: (organization: OrcaCloudOrgSummary) => void - onSelectOrg: (orgId: string) => void - onRequestSignOut: () => void -}): React.JSX.Element { - const cloudConfigured = authStatus?.configured === true - const organizations = authStatus?.organizations ?? [] - const showOrganizationChoices = activeProfile.kind === 'cloud-linked' && organizations.length > 1 - // Why: profile creation is hidden in the downscoped account menu, so the - // "Create profile for org" submenu only appears when multi-profile UI is on. - const showCloudProfileCreation = - allowProfileCreation && activeProfile.kind === 'cloud-linked' && organizations.length > 0 - const orgActionDisabled = profileActionDisabled || authStatus?.state !== 'connected' - const activeOrgId = activeProfile.cloud?.activeOrgId - const showSignIn = authStatus?.state !== 'connected' - - return ( - <> - {showOrganizationChoices || showCloudProfileCreation ? ( - <> - - - {translate('auto.components.orca.profiles.switcher.organization', 'Organization')} - - {showOrganizationChoices - ? organizations.map((organization) => ( - { - if (organization.orgId !== activeOrgId) { - onSelectOrg(organization.orgId) - } - }} - className="min-w-0" - > - - {organization.name} - {organization.orgId === activeOrgId ? : null} - - )) - : null} - {showCloudProfileCreation ? ( - - - - {translate( - 'auto.components.orca.profiles.switcher.create.profile.for.org', - 'Create profile for org' - )} - - - {organizations.map((organization) => ( - onCreateProfileForOrg(organization)} - className="min-w-0" - > - - {organization.name} - - ))} - - - ) : null} - - ) : null} - - {separateAuthActions || showOrganizationChoices || showCloudProfileCreation ? ( - - ) : null} - {showSignIn ? ( - - {connecting ? : } - {getConnectLabel(authStatus, connecting)} - - ) : null} - {activeProfile.kind === 'cloud-linked' ? ( - - - {translate('auto.components.orca.profiles.switcher.signout', 'Sign out')} - - ) : null} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx deleted file mode 100644 index 40ac5022e23..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { translate } from '@/i18n/i18n' - -export function OrcaProfileCreateDialog({ - open, - onOpenChange, - name, - onNameChange, - creating, - switching, - onSubmit -}: { - open: boolean - onOpenChange: (open: boolean) => void - name: string - onNameChange: (name: string) => void - creating: boolean - switching: boolean - onSubmit: (event: React.FormEvent) => void -}): React.JSX.Element { - return ( - - -
- - - {translate('auto.components.orca.profiles.switcher.16e3681072', 'New local profile')} - - - {translate( - 'auto.components.orca.profiles.switcher.e3b91a3d90', - 'Create an empty profile for separate projects and worktrees.' - )} - - - onNameChange(event.target.value)} - placeholder={translate( - 'auto.components.orca.profiles.switcher.f322e1f4d6', - 'Profile name' - )} - maxLength={80} - /> - - - -
-
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx deleted file mode 100644 index 8f1577c7998..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useMemo, useState } from 'react' -import { ArrowRightLeft, Copy, FolderGit2, Loader2, MoveRight } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { ScrollArea } from '@/components/ui/scroll-area' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { - OrcaProfileSummary, - TransferOrcaProfileProjectMode -} from '../../../../shared/orca-profiles' -import type { Repo } from '../../../../shared/types' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' -import { - OrcaProfileProjectTransferConfirmDialog, - type PendingProjectTransfer -} from './OrcaProfileProjectTransferConfirmDialog' -import { getOrcaProfileProjectLiveWorkSummary } from './orca-profile-switch-liveness' - -type PendingTransfer = { - repoId: string - targetProfileId: string - mode: TransferOrcaProfileProjectMode -} - -function pendingKey(value: PendingTransfer): string { - return `${value.mode}:${value.repoId}:${value.targetProfileId}` -} - -function getRepoPath(repo: Repo): string { - return repo.path || repo.displayName -} - -function ProjectTransferMenu({ - repo, - sourceProfileId, - targetProfiles, - pending, - onTransfer -}: { - repo: Repo - sourceProfileId: string - targetProfiles: OrcaProfileSummary[] - pending: PendingTransfer | null - onTransfer: ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ) => void -}): React.JSX.Element { - const disabled = targetProfiles.length === 0 || Boolean(pending) - const repoPending = pending?.repoId === repo.id - const renderTargetItems = (mode: TransferOrcaProfileProjectMode): React.JSX.Element[] => - targetProfiles.map((profile) => { - const targetPending = - pending && - pendingKey(pending) === pendingKey({ repoId: repo.id, targetProfileId: profile.id, mode }) - return ( - onTransfer(repo, profile, mode)} - > - {mode === 'move' ? : } - - {profile.name} - {targetPending ? : null} - - ) - }) - - return ( - - - - - - - {translate('auto.components.orca.profiles.management.128c7dfe64', 'Copy to')} - - {renderTargetItems('copy')} - - - {translate('auto.components.orca.profiles.management.df8b7d876b', 'Move to')} - - {renderTargetItems('move')} - - - ) -} - -export function OrcaProfileManagementDialog({ - open, - onOpenChange, - activeProfile, - profiles -}: { - open: boolean - onOpenChange: (open: boolean) => void - activeProfile: OrcaProfileSummary - profiles: OrcaProfileSummary[] -}): React.JSX.Element { - const repos = useAppStore((s) => s.repos) - const transferProject = useAppStore((s) => s.transferOrcaProfileProject) - const [pending, setPending] = useState(null) - const [pendingConfirmation, setPendingConfirmation] = useState( - null - ) - const targetProfiles = useMemo( - () => profiles.filter((profile) => profile.id !== activeProfile.id), - [activeProfile.id, profiles] - ) - - const runTransfer = async ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ): Promise => { - if (pending) { - return - } - const nextPending = { repoId: repo.id, targetProfileId: targetProfile.id, mode } - setPending(nextPending) - const result = await transferProject({ - sourceProfileId: activeProfile.id, - targetProfileId: targetProfile.id, - repoId: repo.id, - mode - }) - setPending(null) - if (result?.status === 'transferred') { - toast.success( - mode === 'move' - ? translate('auto.components.orca.profiles.management.9aa26347b3', 'Project moved') - : translate('auto.components.orca.profiles.management.816ce624b6', 'Project copied'), - { - description: targetProfile.name - } - ) - } - } - - const handleTransfer = ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ): void => { - if (pending) { - return - } - const liveWorkSummary = getOrcaProfileProjectLiveWorkSummary(useAppStore.getState(), repo.id) - if (mode === 'move' || liveWorkSummary.hasLiveWork) { - setPendingConfirmation({ repo, targetProfile, mode, liveWorkSummary }) - return - } - void runTransfer(repo, targetProfile, mode) - } - - const confirmTransfer = async (): Promise => { - if (!pendingConfirmation) { - return - } - const next = pendingConfirmation - await runTransfer(next.repo, next.targetProfile, next.mode) - setPendingConfirmation(null) - } - - return ( - - - - - {translate('auto.components.orca.profiles.management.2c45bda8d3', 'Manage profiles')} - - - {translate( - 'auto.components.orca.profiles.management.2db945e4a0', - 'Copy or move projects from the active profile to another local profile.' - )} - - - -
-
- -
-
{activeProfile.name}
-
- {translate( - repos.length === 1 - ? 'auto.components.orca.profiles.management.projectCountSingular' - : 'auto.components.orca.profiles.management.projectCountPlural', - repos.length === 1 ? '{{count}} project' : '{{count}} projects', - { count: repos.length } - )} -
-
-
- - {repos.length === 0 ? ( -
- {translate( - 'auto.components.orca.profiles.management.8668cb2946', - 'No projects in this profile.' - )} -
- ) : ( -
- {repos.map((repo) => ( -
- -
-
{repo.displayName}
-
- {getRepoPath(repo)} -
-
- { - handleTransfer(selectedRepo, targetProfile, mode) - }} - /> -
- ))} -
- )} -
-
- - {targetProfiles.length === 0 ? ( -
- {translate( - 'auto.components.orca.profiles.management.93034915ab', - 'Create another profile before copying projects.' - )} -
- ) : null} - setPendingConfirmation(null)} - onConfirm={() => { - void confirmTransfer() - }} - /> -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx deleted file mode 100644 index 692f11163d2..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CircleUserRound } from 'lucide-react' -import { DropdownMenuLabel } from '@/components/ui/dropdown-menu' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' - -export function OrcaProfileMenuHeader({ - profile, - title, - subtitle, - showProfileAvatar -}: { - profile: OrcaProfileSummary - title: string - subtitle: string - showProfileAvatar: boolean -}): React.JSX.Element { - return ( - -
- {showProfileAvatar ? ( - - ) : ( - - )} -
-
{title}
-
{subtitle}
-
-
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx deleted file mode 100644 index 894e21c8481..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useState } from 'react' -import { Loader2, UserMinus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { OrcaOrgMember, OrcaOrgRole } from '../../../../shared/orca-profiles' -import { - ORG_ROLE_OPTIONS, - orgMemberInitials, - orgRoleLabel -} from './orca-profile-org-members-labels' - -export function OrcaProfileOrgMemberRow({ - member, - canManage, - isSelf, - busy, - onChangeRole, - onRemove -}: { - member: OrcaOrgMember - canManage: boolean - isSelf: boolean - busy: boolean - onChangeRole: (role: OrcaOrgRole) => void - onRemove: () => void -}): React.JSX.Element { - const [confirmingRemove, setConfirmingRemove] = useState(false) - // Why: teammates provisioned server-side who never signed into Orca have no - // userId, so the API cannot target them for role/remove mutations. - const neverSignedIn = member.userId === null - const actionsDisabled = !canManage || isSelf || neverSignedIn || busy - const displayName = member.displayName?.trim() || member.email - - const roleControl = canManage ? ( - - ) : ( - {orgRoleLabel(member.role)} - ) - - return ( -
- - {orgMemberInitials(member.displayName, member.email)} - -
-
{displayName}
-
{member.email}
-
- - {neverSignedIn && canManage ? ( - - - {roleControl} - - - {translate( - 'auto.components.orca.profiles.org.members.not.signed.in', - "They haven't signed in to Orca yet." - )} - - - ) : ( - roleControl - )} - - {canManage ? ( - confirmingRemove && !actionsDisabled ? ( -
- - -
- ) : ( - - ) - ) : null} -
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx deleted file mode 100644 index 35e3a07ae90..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -// @vitest-environment happy-dom - -import type { ReactNode } from 'react' -import { render, screen, cleanup } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { OrcaOrgMembersRoster } from '../../../../shared/orca-profiles' -import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' - -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ children }: { children: ReactNode }) =>
{children}
, - DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, - DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, - DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/scroll-area', () => ({ - ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => {children} -})) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - disabled, - 'aria-label': ariaLabel, - type - }: { - children?: ReactNode - disabled?: boolean - 'aria-label'?: string - type?: 'button' | 'submit' - }) => ( - - ) -})) - -vi.mock('@/components/ui/input', () => ({ - Input: ({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) => ( - - ) -})) - -vi.mock('@/components/ui/select', () => ({ - Select: ({ children }: { children: ReactNode }) =>
{children}
, - SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, - SelectValue: () => null, - SelectTrigger: ({ - disabled, - 'aria-label': ariaLabel, - children - }: { - disabled?: boolean - 'aria-label'?: string - children?: ReactNode - }) => ( - - ) -})) - -const managerRoster: OrcaOrgMembersRoster = { - members: [ - { userId: 'user-viewer', email: 'me@example.com', displayName: 'Me', role: 'admin' }, - { userId: 'user-2', email: 'other@example.com', displayName: 'Other', role: 'member' }, - { userId: null, email: 'never@example.com', role: 'member' } - ], - pendingInvites: [{ email: 'pending@example.com', role: 'member', createdAt: 1 }], - viewerRole: 'owner', - canManageMembers: true -} - -const memberRoster: OrcaOrgMembersRoster = { - members: [ - { userId: 'user-1', email: 'boss@example.com', displayName: 'Boss', role: 'owner' }, - { userId: 'user-2', email: 'peer@example.com', displayName: 'Peer', role: 'member' } - ], - pendingInvites: [], - viewerRole: 'member', - canManageMembers: false -} - -function stubOrgMembersApi(roster: OrcaOrgMembersRoster): void { - ;(window as unknown as { api: unknown }).api = { - orcaProfiles: { - orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster }) - } - } -} - -describe('OrcaProfileOrgMembersDialog', () => { - afterEach(() => { - cleanup() - vi.restoreAllMocks() - }) - - it('shows the invite form and a role select per member for managers', async () => { - stubOrgMembersApi(managerRoster) - render( - {}} - orgId="org-1" - orgName="Acme" - viewerUserId="user-viewer" - /> - ) - - expect(await screen.findByText('other@example.com')).toBeTruthy() - // Invite form present. - expect(screen.getByPlaceholderText('teammate@example.com')).toBeTruthy() - // Pending invite listed with a revoke action. - expect(screen.getByText('pending@example.com')).toBeTruthy() - // One role select per member. - expect(screen.getAllByLabelText('Role')).toHaveLength(3) - // Self and the never-signed-in row disable their remove action; the peer's stays enabled. - const removeButtons = screen.getAllByLabelText('Remove teammate') as HTMLButtonElement[] - expect(removeButtons).toHaveLength(3) - expect(removeButtons.filter((button) => button.disabled)).toHaveLength(2) - // Never-signed-in explanation is present. - expect(screen.getByText("They haven't signed in to Orca yet.")).toBeTruthy() - }) - - it('renders a read-only roster for non-managers', async () => { - stubOrgMembersApi(memberRoster) - render( - {}} - orgId="org-1" - viewerUserId="user-2" - /> - ) - - expect(await screen.findByText('boss@example.com')).toBeTruthy() - expect(screen.getByText('peer@example.com')).toBeTruthy() - // No management affordances. - expect(screen.queryByPlaceholderText('teammate@example.com')).toBeNull() - expect(screen.queryAllByLabelText('Role')).toHaveLength(0) - expect(screen.queryAllByLabelText('Remove teammate')).toHaveLength(0) - }) -}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx deleted file mode 100644 index 95c5344ebcb..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { Loader2, Mail, RefreshCw, Users } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { translate } from '@/i18n/i18n' -import type { - OrcaOrgMember, - OrcaOrgMembersRoster, - OrcaOrgRole, - OrcaProfileOrgMemberMutationResult -} from '../../../../shared/orca-profiles' -import { OrcaProfileOrgMemberRow } from './OrcaProfileOrgMemberRow' -import { - describeOrgMutationError, - ORG_ROLE_OPTIONS, - orgRoleLabel -} from './orca-profile-org-members-labels' - -type DialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - orgId: string - orgName?: string - viewerUserId?: string -} - -function isOk(result: OrcaProfileOrgMemberMutationResult): result is { status: 'ok' } { - return result.status === 'ok' -} - -export function OrcaProfileOrgMembersDialog({ - open, - onOpenChange, - orgId, - orgName, - viewerUserId -}: DialogProps): React.JSX.Element { - const [roster, setRoster] = useState(null) - const [loading, setLoading] = useState(false) - const [loadError, setLoadError] = useState(false) - const [actionError, setActionError] = useState(null) - const [pendingMemberId, setPendingMemberId] = useState(null) - const [pendingInviteEmail, setPendingInviteEmail] = useState(null) - const [inviteEmail, setInviteEmail] = useState('') - const [inviteRole, setInviteRole] = useState('member') - const [inviting, setInviting] = useState(false) - - const refresh = useCallback(async (): Promise => { - setLoading(true) - setLoadError(false) - setActionError(null) - const result = await window.api.orcaProfiles.orgMembersList({ orgId }) - if (result.status === 'ok') { - setRoster(result.roster) - } else { - setRoster(null) - setLoadError(true) - } - setLoading(false) - }, [orgId]) - - // Why: reload every time the dialog opens so a manager always acts on the - // current roster, and clear transient dialog state when it closes. - useEffect(() => { - if (open) { - void refresh() - } else { - setActionError(null) - setInviteEmail('') - setInviteRole('member') - } - }, [open, refresh]) - - const canManage = roster?.canManageMembers === true - - const runMutation = async ( - mutation: Promise - ): Promise => { - setActionError(null) - const result = await mutation - if (isOk(result)) { - await refresh() - return true - } - setActionError(describeOrgMutationError(result)) - return false - } - - const handleChangeRole = async (member: OrcaOrgMember, role: OrcaOrgRole): Promise => { - if (member.userId === null || role === member.role) { - return - } - setPendingMemberId(member.userId) - await runMutation( - window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId: member.userId, role }) - ) - setPendingMemberId(null) - } - - const handleRemove = async (member: OrcaOrgMember): Promise => { - if (member.userId === null) { - return - } - setPendingMemberId(member.userId) - await runMutation(window.api.orcaProfiles.orgMemberRemove({ orgId, userId: member.userId })) - setPendingMemberId(null) - } - - const handleRevoke = async (email: string): Promise => { - setPendingInviteEmail(email) - await runMutation(window.api.orcaProfiles.orgInviteRevoke({ orgId, email })) - setPendingInviteEmail(null) - } - - const handleInvite = async (event: React.FormEvent): Promise => { - event.preventDefault() - const email = inviteEmail.trim() - if (!email || inviting) { - return - } - setInviting(true) - const ok = await runMutation( - window.api.orcaProfiles.orgMemberInvite({ orgId, email, role: inviteRole }) - ) - setInviting(false) - if (ok) { - setInviteEmail('') - setInviteRole('member') - } - } - - return ( - - - - - - {translate('auto.components.orca.profiles.org.members.title', 'Organization members')} - - - {orgName - ? translate( - 'auto.components.orca.profiles.org.members.subtitle.named', - 'People in {{orgName}} who can collaborate on Orca.', - { orgName } - ) - : translate( - 'auto.components.orca.profiles.org.members.subtitle.default', - 'People in your organization who can collaborate on Orca.' - )} - - - - {loading ? ( -
- -
- ) : loadError ? ( -
-

- {translate( - 'auto.components.orca.profiles.org.members.load.error', - "Couldn't load organization members." - )} -

- -
- ) : roster ? ( -
- {actionError ? ( -

- {actionError} -

- ) : null} - - -
- {roster.members.map((member) => ( - void handleChangeRole(member, role)} - onRemove={() => void handleRemove(member)} - /> - ))} -
-
- - {canManage && roster.pendingInvites.length > 0 ? ( -
-

- {translate( - 'auto.components.orca.profiles.org.members.pending.title', - 'Pending invites' - )} -

- {roster.pendingInvites.map((invite) => ( -
- - - {invite.email} - - - {orgRoleLabel(invite.role)} - - -
- ))} -
- ) : null} - - {canManage ? ( -
void handleInvite(event)} className="flex items-end gap-2"> -
- - setInviteEmail(event.target.value)} - disabled={inviting} - placeholder={translate( - 'auto.components.orca.profiles.org.members.invite.placeholder', - 'teammate@example.com' - )} - /> -
- - -
- ) : null} -
- ) : null} -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx deleted file mode 100644 index 4b5678e52b1..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { ArrowRightLeft, Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { - OrcaProfileSummary, - TransferOrcaProfileProjectMode -} from '../../../../shared/orca-profiles' -import type { Repo } from '../../../../shared/types' -import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -type PendingProjectTransfer = { - repo: Repo - targetProfile: OrcaProfileSummary - mode: TransferOrcaProfileProjectMode - liveWorkSummary: OrcaProfileSwitchLiveWorkSummary -} - -export type { PendingProjectTransfer } - -function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { - const lines: string[] = [] - if (summary.liveTerminalTabCount > 0) { - lines.push( - translate( - summary.liveTerminalTabCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural', - summary.liveTerminalTabCount === 1 - ? '{{count}} live terminal tab' - : '{{count}} live terminal tabs', - { count: summary.liveTerminalTabCount } - ) - ) - } - if (summary.liveAgentCount > 0) { - lines.push( - translate( - summary.liveAgentCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.agentPlural', - summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', - { count: summary.liveAgentCount } - ) - ) - } - if (summary.browserWorkspaceCount > 0) { - lines.push( - translate( - summary.browserWorkspaceCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.browserPlural', - summary.browserWorkspaceCount === 1 - ? '{{count}} browser workspace' - : '{{count}} browser workspaces', - { count: summary.browserWorkspaceCount } - ) - ) - } - return lines -} - -export function OrcaProfileProjectTransferConfirmDialog({ - activeProfileName, - pendingTransfer, - pending, - onCancel, - onConfirm -}: { - activeProfileName: string - pendingTransfer: PendingProjectTransfer | null - pending: boolean - onCancel: () => void - onConfirm: () => void -}): React.JSX.Element { - const mode = pendingTransfer?.mode ?? 'copy' - const repoName = pendingTransfer?.repo.displayName ?? '' - const targetName = pendingTransfer?.targetProfile.name ?? '' - const lines = pendingTransfer ? liveWorkLines(pendingTransfer.liveWorkSummary) : [] - - return ( - { - if (!open && !pending) { - onCancel() - } - }} - > - - - - - {mode === 'move' - ? translate( - 'auto.components.orca.profiles.project.transfer.confirm.move.title', - 'Move project?' - ) - : translate( - 'auto.components.orca.profiles.project.transfer.confirm.copy.title', - 'Copy project?' - )} - - - {mode === 'move' - ? translate( - 'auto.components.orca.profiles.project.transfer.confirm.move.description', - 'Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.', - { activeProfileName, repoName, targetName } - ) - : translate( - 'auto.components.orca.profiles.project.transfer.confirm.copy.description', - 'Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.', - { repoName, targetName } - )} - - - - {lines.length > 0 ? ( -
-
- {translate( - 'auto.components.orca.profiles.project.transfer.confirm.live.work', - 'Live work in this project' - )} -
-
    - {lines.map((line) => ( -
  • {line}
  • - ))} -
-
- ) : null} - - - - - -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx deleted file mode 100644 index 89c80e99aa6..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { AlertTriangle, Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' -import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { - const lines: string[] = [] - if (summary.liveTerminalTabCount > 0) { - lines.push( - translate( - summary.liveTerminalTabCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.terminalSingular' - : 'auto.components.orca.profiles.switch.confirm.terminalPlural', - summary.liveTerminalTabCount === 1 - ? '{{count}} live terminal tab' - : '{{count}} live terminal tabs', - { count: summary.liveTerminalTabCount } - ) - ) - } - if (summary.liveAgentCount > 0) { - lines.push( - translate( - summary.liveAgentCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.agentSingular' - : 'auto.components.orca.profiles.switch.confirm.agentPlural', - summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', - { count: summary.liveAgentCount } - ) - ) - } - if (summary.browserWorkspaceCount > 0) { - lines.push( - translate( - summary.browserWorkspaceCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.browserSingular' - : 'auto.components.orca.profiles.switch.confirm.browserPlural', - summary.browserWorkspaceCount === 1 - ? '{{count}} browser workspace' - : '{{count}} browser workspaces', - { count: summary.browserWorkspaceCount } - ) - ) - } - return lines -} - -export function OrcaProfileSwitchConfirmDialog({ - open, - onOpenChange, - onConfirm, - activeProfileName, - targetProfile, - liveWorkSummary, - switching -}: { - open: boolean - onOpenChange: (open: boolean) => void - onConfirm: () => void - activeProfileName: string - targetProfile: OrcaProfileSummary | null - liveWorkSummary: OrcaProfileSwitchLiveWorkSummary - switching: boolean -}): React.JSX.Element { - const targetName = - targetProfile?.name ?? - translate('auto.components.orca.profiles.switch.confirm.target', 'the selected profile') - const lines = liveWorkLines(liveWorkSummary) - - return ( - - - - - - {translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')} - - - {translate( - 'auto.components.orca.profiles.switch.confirm.description', - 'Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.', - { activeProfileName, targetName } - )} - - - - {lines.length > 0 ? ( -
-
- {translate( - 'auto.components.orca.profiles.switch.confirm.live.work', - 'Live work in this profile' - )} -
-
    - {lines.map((line) => ( -
  • {line}
  • - ))} -
-
- ) : null} - - - - - -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx deleted file mode 100644 index 3759d07a49f..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -// @vitest-environment happy-dom - -import type { ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store' -import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileSwitcher } from './OrcaProfileSwitcher' - -const mocks = vi.hoisted(() => ({ - state: {} as Partial -})) - -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: Partial) => unknown) => selector(mocks.state) -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuSeparator: () =>
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => {children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - 'aria-label': ariaLabel - }: { - children: ReactNode - 'aria-label'?: string - }) => -})) - -vi.mock('./OrcaProfileAvatar', () => ({ - OrcaProfileAvatar: () => -})) - -vi.mock('./OrcaProfileCreateDialog', () => ({ - OrcaProfileCreateDialog: () =>
-})) - -vi.mock('./OrcaProfileManagementDialog', () => ({ - OrcaProfileManagementDialog: () =>
-})) - -vi.mock('./OrcaProfileSwitchConfirmDialog', () => ({ - OrcaProfileSwitchConfirmDialog: () =>
-})) - -vi.mock('./OrcaProfileSignOutConfirmDialog', () => ({ - OrcaProfileSignOutConfirmDialog: () =>
-})) - -vi.mock('./orca-profile-switch-liveness', () => ({ - getOrcaProfileSwitchLiveWorkSummary: () => ({ hasLiveWork: false }) -})) - -const cloudProfile: OrcaProfileSummary = { - id: 'local-default', - name: 'Personal', - avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, - kind: 'cloud-linked', - createdAt: 1, - updatedAt: 1, - lastOpenedAt: 1, - cloud: { - cloudProfileId: 'cloud-1', - userId: 'user-1', - email: 'nina@example.com', - activeOrgId: 'org-1', - activeOrgName: 'Acme', - linkedAt: 2 - } -} - -const localProfile: OrcaProfileSummary = { - id: 'local-default', - name: 'Personal', - avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, - kind: 'local', - createdAt: 1, - updatedAt: 1, - lastOpenedAt: 1 -} - -const connectedAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: true, - state: 'connected', - persistence: 'encrypted', - cloud: cloudProfile.cloud, - organizations: [ - { orgId: 'org-1', name: 'Acme' }, - { orgId: 'org-2', name: 'Globex' } - ] -} - -const unconfiguredAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: false, - state: 'unconfigured', - persistence: 'none' -} - -const signedOutAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: true, - state: 'local', - persistence: 'none' -} - -function baseState(overrides: Partial): Partial { - return { - orcaProfiles: [cloudProfile], - activeOrcaProfileId: 'local-default', - orcaProfilesLoading: false, - orcaProfileSwitching: false, - orcaProfileConnecting: false, - orcaProfileAuthStatus: connectedAuthStatus, - orcaProfilesMultiProfileUi: false, - fetchOrcaProfiles: vi.fn(), - createLocalOrcaProfile: vi.fn(), - createCloudLinkedOrcaProfile: vi.fn(), - connectCurrentOrcaProfile: vi.fn(), - signOutCurrentOrcaProfile: vi.fn(), - selectOrcaProfileOrg: vi.fn(), - switchOrcaProfile: vi.fn(), - ...overrides - } -} - -describe('OrcaProfileSwitcher', () => { - beforeEach(() => { - mocks.state = baseState({}) - }) - - it('renders an account menu without profile management when the flag is off and cloud is configured', () => { - mocks.state = baseState({ orcaProfilesMultiProfileUi: false }) - const html = renderToStaticMarkup() - - expect(html).toContain('aria-label="Account"') - expect(html).toContain('nina@example.com') - expect(html).toContain('Acme') - // Cloud actions stay reachable in the downscoped account menu. - expect(html).toContain('Sign out') - expect(html).not.toContain('Reconnect profile') - // Profile management surfaces are gone. - expect(html).not.toContain('Manage profiles') - expect(html).not.toContain('New local profile') - expect(html).not.toContain('Create profile for org') - expect(html).not.toContain('data-testid="create-dialog"') - expect(html).not.toContain('data-testid="management-dialog"') - expect(html).not.toContain('data-testid="switch-confirm-dialog"') - // Sign-out remains mounted. - expect(html).toContain('data-testid="signout-confirm-dialog"') - }) - - it('presents only the sign-in action before an account identity exists', () => { - mocks.state = baseState({ - orcaProfiles: [localProfile], - orcaProfileAuthStatus: signedOutAuthStatus, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('Sign in to Orca') - expect(html).not.toContain('Orca account') - expect(html).not.toContain('Signed out') - expect(html).not.toContain('Personal') - expect(html).not.toContain('>Local<') - }) - - it('gives a reconnect-required account an explicit recovery action', () => { - mocks.state = baseState({ - orcaProfileAuthStatus: { - ...connectedAuthStatus, - state: 'reconnect-required' - }, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('nina@example.com') - expect(html).toContain('Sign-in required') - expect(html).toContain('Sign in again') - }) - - it('names the pending browser authentication step', () => { - mocks.state = baseState({ - orcaProfiles: [localProfile], - orcaProfileAuthStatus: signedOutAuthStatus, - orcaProfileConnecting: true, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('Waiting for sign-in…') - }) - - it('renders nothing when the flag is off and cloud is unconfigured', () => { - mocks.state = baseState({ - orcaProfilesMultiProfileUi: false, - orcaProfileAuthStatus: unconfiguredAuthStatus - }) - const html = renderToStaticMarkup() - - expect(html).toBe('') - }) - - it('renders the full multi-profile menu when the flag is on', () => { - mocks.state = baseState({ orcaProfilesMultiProfileUi: true }) - const html = renderToStaticMarkup() - - expect(html).toContain('aria-label="Switch profile"') - expect(html).toContain('Manage profiles') - expect(html).toContain('New local profile') - expect(html).toContain('Create profile for org') - expect(html).toContain('data-testid="create-dialog"') - expect(html).toContain('data-testid="management-dialog"') - expect(html).toContain('data-testid="switch-confirm-dialog"') - }) -}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx deleted file mode 100644 index f5858d4aa7d..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - Check, - ChevronDown, - CircleUserRound, - Cloud, - Laptop, - Loader2, - Plus, - Settings2, - Users -} from 'lucide-react' -import { useShallow } from 'zustand/react/shallow' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { useAppStore } from '@/store' -import type { OrcaCloudOrgSummary, OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' -import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems' -import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog' -import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' -import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog' -import { OrcaProfileMenuHeader } from './OrcaProfileMenuHeader' -import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' -import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog' -import { getOrcaAccountIdentity } from './orca-account-identity' -import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -function isWebClient(): boolean { - return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) -} - -function getProfileSubtitle(profile: OrcaProfileSummary): string { - if (profile.cloud?.activeOrgName) { - return profile.cloud.activeOrgName - } - if (profile.cloud?.email) { - return profile.cloud.email - } - return translate('auto.components.orca.profiles.switcher.b4f9d1125d', 'Local') -} - -export function OrcaProfileSwitcher({ - placement = 'titlebar' -}: { - placement?: 'titlebar' | 'sidebar' -}): React.JSX.Element | null { - const profiles = useAppStore((s) => s.orcaProfiles) - const activeProfileId = useAppStore((s) => s.activeOrcaProfileId) - const loading = useAppStore((s) => s.orcaProfilesLoading) - const switching = useAppStore((s) => s.orcaProfileSwitching) - const connecting = useAppStore((s) => s.orcaProfileConnecting) - const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) - const multiProfileUi = useAppStore((s) => s.orcaProfilesMultiProfileUi) - const fetchProfiles = useAppStore((s) => s.fetchOrcaProfiles) - const createLocalProfile = useAppStore((s) => s.createLocalOrcaProfile) - const createCloudLinkedProfile = useAppStore((s) => s.createCloudLinkedOrcaProfile) - const connectCurrentProfile = useAppStore((s) => s.connectCurrentOrcaProfile) - const signOutCurrentProfile = useAppStore((s) => s.signOutCurrentOrcaProfile) - const selectOrg = useAppStore((s) => s.selectOrcaProfileOrg) - const switchProfile = useAppStore((s) => s.switchOrcaProfile) - const liveWorkSummary = useAppStore(useShallow((s) => getOrcaProfileSwitchLiveWorkSummary(s))) - const [dialogOpen, setDialogOpen] = useState(false) - const [managementOpen, setManagementOpen] = useState(false) - const [newProfileName, setNewProfileName] = useState('') - const [creating, setCreating] = useState(false) - const [creatingCloudProfile, setCreatingCloudProfile] = useState(false) - const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false) - const [signingOut, setSigningOut] = useState(false) - const [orgMembersOpen, setOrgMembersOpen] = useState(false) - const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState(null) - const activeProfile = useMemo( - () => profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null, - [activeProfileId, profiles] - ) - const pendingSwitchProfile = useMemo( - () => profiles.find((profile) => profile.id === pendingSwitchProfileId) ?? null, - [pendingSwitchProfileId, profiles] - ) - - // Why: one attempt per mount — retrying on every loading toggle would spin - // an unbounded IPC loop when the list call persistently fails. - const fetchAttemptedRef = useRef(false) - useEffect(() => { - if (profiles.length === 0 && !loading && !fetchAttemptedRef.current) { - fetchAttemptedRef.current = true - void fetchProfiles() - } - }, [fetchProfiles, loading, profiles.length]) - - // Why: the Orca Cloud account UX isn't ready for production users yet, so the - // trigger stays hidden in packaged builds. Dev builds still show it when cloud - // auth is configured. electron-vite build forces NODE_ENV=production, so PROD - // is baked true even for the e2e bundle; exempt MODE==='e2e' so the switcher - // specs can exercise the render path while real packaged prod builds stay hidden. - if (import.meta.env.PROD && import.meta.env.MODE !== 'e2e') { - return null - } - - // Why: paired web/mobile clients only see the desktop stub's fabricated - // profile list; showing a switcher there would misreport the active profile - // and none of its actions can work remotely. - if (isWebClient() || !activeProfile) { - return null - } - - // Why: with multi-profile UI downscoped, local-only builds (no cloud - // configured) have nothing to offer in an account menu — show no trigger. - if (!multiProfileUi && authStatus?.configured !== true) { - return null - } - - const handleCreateProfile = async (event: React.FormEvent): Promise => { - event.preventDefault() - if (creating || switching) { - return - } - setCreating(true) - const profile = await createLocalProfile(newProfileName) - setCreating(false) - if (!profile) { - return - } - setNewProfileName('') - setDialogOpen(false) - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(profile.id) - return - } - await switchProfile(profile.id) - } - - const handleSwitchProfile = (profileId: string): void => { - if (profileId === activeProfileId || switching) { - return - } - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(profileId) - return - } - void switchProfile(profileId) - } - - const handleConfirmSwitchProfile = (): void => { - if (!pendingSwitchProfileId || switching) { - return - } - void switchProfile(pendingSwitchProfileId) - } - - const handleCreateCloudProfileForOrg = async ( - organization: OrcaCloudOrgSummary - ): Promise => { - if (creatingCloudProfile || switching) { - return - } - setCreatingCloudProfile(true) - const result = await createCloudLinkedProfile({ - orgId: organization.orgId, - name: organization.name - }) - setCreatingCloudProfile(false) - if (result?.status !== 'created') { - return - } - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(result.profile.id) - return - } - await switchProfile(result.profile.id) - } - - const handleConfirmSignOut = async (): Promise => { - if (signingOut) { - return - } - setSigningOut(true) - const result = await signOutCurrentProfile() - setSigningOut(false) - if (result) { - setSignOutConfirmOpen(false) - } - } - - const profileActionDisabled = - switching || creating || creatingCloudProfile || connecting || signingOut - // Why: teammate management needs a connected cloud profile scoped to an org; - // the server enforces role permissions, and the dialog adapts via - // canManageMembers, so cloud-linked + org + connected is enough to reveal it. - const activeOrgId = activeProfile.cloud?.activeOrgId - const showOrgMembers = - activeProfile.kind === 'cloud-linked' && - Boolean(activeOrgId) && - authStatus?.state === 'connected' - const sidebarPlacement = placement === 'sidebar' - const triggerLabel = multiProfileUi - ? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile') - : translate('auto.components.orca.profiles.switcher.account', 'Account') - const accountIdentity = getOrcaAccountIdentity(activeProfile, authStatus) - const showAccountIdentity = - multiProfileUi || - authStatus?.state === 'connected' || - authStatus?.state === 'reconnect-required' - - return ( - <> - - - - - - - - - {triggerLabel} - - - - {showAccountIdentity ? ( - <> - - - - ) : null} - {multiProfileUi - ? profiles.map((profile) => { - const active = profile.id === activeProfileId - return ( - handleSwitchProfile(profile.id)} - className="min-w-0" - > - - - {profile.name} - - {getProfileSubtitle(profile)} - - - {profile.kind === 'cloud-linked' ? : } - {active && } - - ) - }) - : null} - {showOrgMembers ? ( - <> - - setOrgMembersOpen(true)} - > - - {translate( - 'auto.components.orca.profiles.switcher.org.members', - 'Organization members' - )} - - - ) : null} - { - void connectCurrentProfile() - }} - onCreateProfileForOrg={(organization) => { - void handleCreateCloudProfileForOrg(organization) - }} - onSelectOrg={(orgId) => { - void selectOrg(orgId) - }} - onRequestSignOut={() => setSignOutConfirmOpen(true)} - /> - {multiProfileUi ? ( - <> - - { - setManagementOpen(true) - }} - > - - {translate('auto.components.orca.profiles.switcher.d00d853e2a', 'Manage profiles')} - - { - setDialogOpen(true) - }} - > - - {translate( - 'auto.components.orca.profiles.switcher.c106c674fe', - 'New local profile' - )} - - - ) : null} - - - - {multiProfileUi ? ( - <> - - - - ) : null} - {showOrgMembers && activeOrgId ? ( - - ) : null} - { - if (!signingOut) { - setSignOutConfirmOpen(open) - } - }} - onConfirm={() => { - void handleConfirmSignOut() - }} - signingOut={signingOut} - /> - {multiProfileUi ? ( - { - if (!open && !switching) { - setPendingSwitchProfileId(null) - } - }} - onConfirm={handleConfirmSwitchProfile} - activeProfileName={activeProfile.name} - targetProfile={pendingSwitchProfile} - liveWorkSummary={liveWorkSummary} - switching={switching} - /> - ) : null} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/orca-account-identity.ts b/src/renderer/src/components/orca-profiles/orca-account-identity.ts deleted file mode 100644 index 65ab7231058..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-account-identity.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' - -export function getOrcaAccountIdentity( - profile: OrcaProfileSummary, - authStatus: OrcaProfileAuthStatus | null -): { title: string; subtitle: string } { - // Why: the account-only menu must not present a local execution profile as - // an authenticated Orca identity. - const cloud = authStatus?.cloud ?? profile.cloud - if (authStatus?.state === 'connected') { - return { - title: - cloud?.displayName?.trim() || - cloud?.email || - translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: - cloud?.activeOrgName || - (cloud?.displayName && cloud.email - ? cloud.email - : translate('auto.components.orca.profiles.switcher.accountSignedIn', 'Signed in')) - } - } - if (authStatus?.state === 'reconnect-required') { - return { - title: - cloud?.displayName?.trim() || - cloud?.email || - translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: translate( - 'auto.components.orca.profiles.switcher.accountSignInRequired', - 'Sign-in required' - ) - } - } - return { - title: translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: translate('auto.components.orca.profiles.switcher.accountSignedOut', 'Signed out') - } -} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts deleted file mode 100644 index 9378d77cec1..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { - OrcaOrgRole, - OrcaProfileOrgMemberMutationResult -} from '../../../../shared/orca-profiles' - -export const ORG_ROLE_OPTIONS: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] - -export function orgRoleLabel(role: OrcaOrgRole): string { - switch (role) { - case 'owner': - return translate('auto.components.orca.profiles.org.members.role.owner', 'Owner') - case 'admin': - return translate('auto.components.orca.profiles.org.members.role.admin', 'Admin') - case 'member': - return translate('auto.components.orca.profiles.org.members.role.member', 'Member') - } -} - -export function orgMemberInitials(displayName: string | undefined, email: string): string { - const source = displayName?.trim() || email.trim() - if (!source) { - return '?' - } - const words = source.split(/\s+/).filter(Boolean) - if (words.length >= 2) { - return `${words[0][0]}${words[1][0]}`.toUpperCase() - } - return source.slice(0, 2).toUpperCase() -} - -// Why: the dialog maps every non-ok mutation status to a precise, translated -// message so managers see why an action was rejected instead of a raw code. -export function describeOrgMutationError( - result: Exclude -): string { - switch (result.status) { - case 'forbidden': - return translate( - 'auto.components.orca.profiles.org.members.error.forbidden', - "Your role can't do that." - ) - case 'conflict': - return translate( - 'auto.components.orca.profiles.org.members.error.conflict', - 'Already a member or invited.' - ) - case 'invalid': - return result.reason === 'cannot_remove_self' - ? translate( - 'auto.components.orca.profiles.org.members.error.remove.self', - "You can't remove yourself." - ) - : translate( - 'auto.components.orca.profiles.org.members.error.own.role', - "You can't change your own role." - ) - case 'not-found': - return translate( - 'auto.components.orca.profiles.org.members.error.not.found', - 'That teammate is no longer in this organization.' - ) - case 'reconnect-required': - return translate( - 'auto.components.orca.profiles.org.members.error.reconnect', - 'Reconnect your profile to manage members.' - ) - case 'unconfigured': - return translate( - 'auto.components.orca.profiles.org.members.error.unconfigured', - 'Organization management is unavailable in this build.' - ) - case 'failed': - return translate( - 'auto.components.orca.profiles.org.members.error.failed', - 'Something went wrong. Please try again.' - ) - } -} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts deleted file mode 100644 index 1dfc0873727..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' -import { - getOrcaProfileProjectLiveWorkSummary, - getOrcaProfileSwitchLiveWorkSummary, - type OrcaProfileSwitchLiveWorkState -} from './orca-profile-switch-liveness' - -const NOW = 1_000_000 - -function makeState( - overrides: Partial = {} -): OrcaProfileSwitchLiveWorkState { - return { - agentStatusByPaneKey: {}, - browserTabsByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - tabsByWorktree: {}, - ...overrides - } -} - -function makeTab( - overrides: Partial> & { - id: string - worktreeId?: string - } -): TerminalTab { - const { id, worktreeId = 'worktree-1', ...rest } = overrides - return { - id, - color: null, - createdAt: NOW, - customTitle: null, - ptyId: null, - sortOrder: 0, - title: 'zsh', - worktreeId, - ...rest - } -} - -function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { - return { - paneKey: 'tab-1:0', - prompt: '', - state: 'working', - stateHistory: [], - stateStartedAt: NOW, - updatedAt: NOW, - ...overrides - } -} - -function makeBrowserWorkspace(id: string): BrowserWorkspace { - return { - id, - activePageId: null, - canGoBack: false, - canGoForward: false, - createdAt: NOW, - faviconUrl: null, - label: 'Browser', - loadError: null, - loading: false, - pageIds: [], - title: 'Browser', - url: 'about:blank', - worktreeId: 'worktree-1' - } -} - -describe('getOrcaProfileSwitchLiveWorkSummary', () => { - it('reports quiet profiles as safe to switch without confirmation', () => { - expect(getOrcaProfileSwitchLiveWorkSummary(makeState(), NOW)).toEqual({ - browserWorkspaceCount: 0, - hasLiveWork: false, - liveAgentCount: 0, - livePtyCount: 0, - liveTerminalTabCount: 0 - }) - }) - - it('counts live PTYs as live terminal work', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - ptyIdsByTabId: { - 'tab-1': ['pty-1', 'pty-2'], - 'tab-2': [] - } - }), - NOW - ) - - expect(summary).toMatchObject({ - hasLiveWork: true, - livePtyCount: 2, - liveTerminalTabCount: 1 - }) - }) - - it('counts fresh working explicit agent status', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - agentStatusByPaneKey: { - 'tab-1:0': makeAgentEntry({ paneKey: 'tab-1:0', state: 'working' }), - 'tab-2:0': makeAgentEntry({ paneKey: 'tab-2:0', state: 'done' }), - 'tab-3:0': makeAgentEntry({ - paneKey: 'tab-3:0', - state: 'waiting', - updatedAt: -999_999_999 - }) - } - }), - NOW - ) - - expect(summary.liveAgentCount).toBe(1) - expect(summary.hasLiveWork).toBe(true) - }) - - it('counts title-detected agents only when the tab has a live PTY', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - ptyIdsByTabId: { - 'tab-live': ['pty-1'] - }, - tabsByWorktree: { - 'worktree-1': [ - makeTab({ id: 'tab-live', title: 'Codex working' }), - makeTab({ id: 'tab-slept', title: 'Codex working' }) - ] - } - }), - NOW - ) - - expect(summary.liveAgentCount).toBe(1) - expect(summary.hasLiveWork).toBe(true) - }) - - it('counts browser workspaces as live browser work', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - browserTabsByWorktree: { - 'worktree-1': [makeBrowserWorkspace('browser-1'), makeBrowserWorkspace('browser-2')] - } - }), - NOW - ) - - expect(summary).toMatchObject({ - browserWorkspaceCount: 2, - hasLiveWork: true - }) - }) - - it('filters live work to the selected project', () => { - const summary = getOrcaProfileProjectLiveWorkSummary( - makeState({ - agentStatusByPaneKey: { - 'other-tab:0': makeAgentEntry({ - paneKey: 'other-tab:0', - worktreeId: 'repo-other::/workspace/other' - }) - }, - browserTabsByWorktree: { - 'repo-1::/workspace/orca': [makeBrowserWorkspace('browser-1')], - 'repo-other::/workspace/other': [makeBrowserWorkspace('browser-2')] - }, - ptyIdsByTabId: { - 'tab-live': ['pty-1'], - 'other-tab': ['pty-2'] - }, - tabsByWorktree: { - 'repo-1::/workspace/orca': [makeTab({ id: 'tab-live', title: 'Codex working' })], - 'repo-other::/workspace/other': [makeTab({ id: 'other-tab', title: 'Codex working' })] - } - }), - 'repo-1', - NOW - ) - - expect(summary).toMatchObject({ - browserWorkspaceCount: 1, - hasLiveWork: true, - liveAgentCount: 1, - livePtyCount: 1, - liveTerminalTabCount: 1 - }) - }) -}) diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts deleted file mode 100644 index c96a1812157..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { AppState } from '@/store/types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' - -export type OrcaProfileSwitchLiveWorkState = Pick< - AppState, - | 'agentStatusByPaneKey' - | 'browserTabsByWorktree' - | 'ptyIdsByTabId' - | 'runtimePaneTitlesByTabId' - | 'tabsByWorktree' -> - -export type OrcaProfileSwitchLiveWorkSummary = { - hasLiveWork: boolean - liveAgentCount: number - livePtyCount: number - liveTerminalTabCount: number - browserWorkspaceCount: number -} - -const LIVE_AGENT_STATES = new Set(['working', 'blocked', 'waiting']) - -export function getOrcaProfileSwitchLiveWorkSummary( - state: OrcaProfileSwitchLiveWorkState, - now = Date.now() -): OrcaProfileSwitchLiveWorkSummary { - const terminalSummary = getLiveTerminalSummary(state) - const liveAgentCount = getLiveAgentKeys(state, now).size - const browserWorkspaceCount = Object.values(state.browserTabsByWorktree).reduce( - (count, workspaces) => count + workspaces.length, - 0 - ) - - return { - hasLiveWork: - terminalSummary.livePtyCount > 0 || liveAgentCount > 0 || browserWorkspaceCount > 0, - liveAgentCount, - livePtyCount: terminalSummary.livePtyCount, - liveTerminalTabCount: terminalSummary.liveTerminalTabCount, - browserWorkspaceCount - } -} - -export function getOrcaProfileProjectLiveWorkSummary( - state: OrcaProfileSwitchLiveWorkState, - repoId: string, - now = Date.now() -): OrcaProfileSwitchLiveWorkSummary { - const tabsByWorktree = Object.fromEntries( - Object.entries(state.tabsByWorktree).filter(([worktreeId]) => - worktreeBelongsToRepo(worktreeId, repoId) - ) - ) - const tabIds = new Set(Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))) - const browserTabsByWorktree = Object.fromEntries( - Object.entries(state.browserTabsByWorktree).filter(([worktreeId]) => - worktreeBelongsToRepo(worktreeId, repoId) - ) - ) - const ptyIdsByTabId = Object.fromEntries( - Object.entries(state.ptyIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) - ) - const runtimePaneTitlesByTabId = Object.fromEntries( - Object.entries(state.runtimePaneTitlesByTabId).filter(([tabId]) => tabIds.has(tabId)) - ) - const agentStatusByPaneKey = Object.fromEntries( - Object.entries(state.agentStatusByPaneKey).filter(([paneKey, entry]) => - agentEntryBelongsToProject(paneKey, entry, repoId, tabIds) - ) - ) - - return getOrcaProfileSwitchLiveWorkSummary( - { - agentStatusByPaneKey, - browserTabsByWorktree, - ptyIdsByTabId, - runtimePaneTitlesByTabId, - tabsByWorktree - }, - now - ) -} - -function getLiveTerminalSummary(state: OrcaProfileSwitchLiveWorkState): { - livePtyCount: number - liveTerminalTabCount: number -} { - const liveTabIds = new Set() - let livePtyCount = 0 - - for (const [tabId, ptyIds] of Object.entries(state.ptyIdsByTabId)) { - if (ptyIds.length === 0) { - continue - } - livePtyCount += ptyIds.length - liveTabIds.add(tabId) - } - - return { - livePtyCount, - liveTerminalTabCount: liveTabIds.size - } -} - -function getLiveAgentKeys(state: OrcaProfileSwitchLiveWorkState, now: number): Set { - const keys = new Set() - for (const entry of Object.values(state.agentStatusByPaneKey)) { - if ( - LIVE_AGENT_STATES.has(entry.state) && - isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) - ) { - keys.add(entry.paneKey) - } - } - - for (const tabs of Object.values(state.tabsByWorktree)) { - for (const tab of tabs) { - addTitleDetectedLiveAgentKeys(keys, state, tab) - } - } - - return keys -} - -function addTitleDetectedLiveAgentKeys( - keys: Set, - state: OrcaProfileSwitchLiveWorkState, - tab: TerminalTab -): void { - if (!tabHasLivePty(state.ptyIdsByTabId, tab.id)) { - return - } - - const paneTitles = state.runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - for (const [paneId, title] of Object.entries(paneTitles)) { - if (isLiveAgentTitle(title)) { - keys.add(`${tab.id}:${paneId}`) - } - } - return - } - - if (isLiveAgentTitle(tab.title)) { - keys.add(`${tab.id}:title`) - } -} - -function isLiveAgentTitle(title: string): boolean { - const status = detectAgentStatusFromTitle(title) - return status === 'working' || status === 'permission' -} - -function agentEntryBelongsToProject( - paneKey: string, - entry: AgentStatusEntry, - repoId: string, - tabIds: Set -): boolean { - if (entry.worktreeId && worktreeBelongsToRepo(entry.worktreeId, repoId)) { - return true - } - return tabIds.has(getPaneKeyTabId(paneKey)) -} - -function getPaneKeyTabId(paneKey: string): string { - const separatorIndex = paneKey.lastIndexOf(':') - return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) -} - -function worktreeBelongsToRepo(worktreeId: string, repoId: string): boolean { - return getRepoIdFromWorktreeId(worktreeId) === repoId -} diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts index 875794753af..902481e9ba3 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { PRComment } from '../../../shared/types' -import { groupPRComments } from '@/lib/pr-comment-groups' +import { groupPRComments } from '../../../shared/pr-comment-groups' import { buildPRCommentsResolutionPrompt, isResolvablePRCommentGroup diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.ts b/src/renderer/src/components/pr-comments-resolution-prompt.ts index 8aecb0ad3e2..a31d4340844 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.ts @@ -1,5 +1,5 @@ import type { PRComment } from '../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../shared/pr-comment-groups' export type PRCommentsResolutionReviewKind = 'PR' | 'MR' diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index 8dc6c550344..fc00fac9891 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -192,6 +192,43 @@ describe('useRuntimeFileListForWorktree', () => { expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) }) + it('cancels the in-flight scan as soon as listing is disabled', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + listRuntimeFilesMock.mockReturnValue(new Promise(() => {})) + + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })], + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + + const root = await renderProbe({ + enabled: true, + onState: () => {}, + worktreeId: workspaceKey + }) + await waitForListRuntimeFilesCall() + + const [listContext, listRequest] = listRuntimeFilesMock.mock.calls[0] + expect(listContext.connectionId).toBe('ssh-1') + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: false, + onState: () => {}, + worktreeId: workspaceKey + }) + ) + }) + await flushEffects() + + expect(cancelRuntimeFileListMock).toHaveBeenCalledTimes(1) + expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) + }) + it('does not restart the scan when unrelated ownership metadata changes', async () => { const workspaceKey = folderWorkspaceKey('folder-workspace-1') useAppStore.setState({ diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 9fc66a78819..3a6bceee5f1 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -42,6 +42,7 @@ import { import { isFolderRepo } from '../../../../shared/repo-kind' import { githubProjectHost } from '../../../../shared/github-project-identity' import HostedReviewActions from './HostedReviewActions' +import { GitHubPRStackMap, type GitHubPRStackMapNavigationModifiers } from './GitHubPRStackMap' import { PullRequestIcon, prStateColor, @@ -191,7 +192,7 @@ import { formatCreateError } from './create-pull-request-review-copy' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { translate } from '@/i18n/i18n' -import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { groupPRComments, type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { openChecksPanelHostedReviewUrl, resolveChecksPanelHostedReviewModifierDestination, @@ -3734,6 +3735,18 @@ export default function ChecksPanel(): React.JSX.Element { [activeReview, activeWorktreeId] ) + const handleOpenStackPR = useCallback( + (url: string, modifiers: GitHubPRStackMapNavigationModifiers) => { + openChecksPanelHostedReviewUrl({ + url, + event: modifiers, + isMac: isMacPlatform(), + worktreeId: activeWorktreeId + }) + }, + [activeWorktreeId] + ) + const handleUnlinkPullRequest = useCallback(() => { if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) { return @@ -4437,6 +4450,14 @@ export default function ChecksPanel(): React.JSX.Element { {detachedHeadDisplay && } + {activeReview.provider === 'github' && pr?.stack ? ( + + ) : null} + {/* Review title */} {editingTitle ? (
diff --git a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx new file mode 100644 index 00000000000..e29d0b74361 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../../../shared/types' +import { GitHubPRStackMap } from './GitHubPRStackMap' + +const stack: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 201, + title: 'Models', + url: 'https://github.com/acme/repo/pull/201', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED' + }, + { + position: 2, + number: 202, + title: 'API', + url: 'https://github.com/acme/repo/pull/202', + state: 'open', + checksStatus: 'failure', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 203, + title: 'UI', + url: 'https://github.com/acme/repo/pull/203', + state: 'draft', + checksStatus: 'neutral', + mergeable: 'UNKNOWN' + } + ] +} + +let container: HTMLDivElement +let root: Root + +describe('GitHubPRStackMap', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('shows compact identity then expands top-to-bottom with the current PR highlighted', () => { + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('Stack #51') + expect(container.textContent).toContain('2 of 3 · main') + expect(container.querySelectorAll('button[data-stack-pr-number]')).toHaveLength(0) + + const trigger = container.querySelector( + 'button[aria-label="Expand stack #51"]' + ) + act(() => trigger?.click()) + + const rows = [...container.querySelectorAll('button[data-stack-pr-number]')] + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining('#203'), + expect.stringContaining('#202'), + expect.stringContaining('#201') + ]) + expect(rows[1]?.dataset.current).toBe('true') + expect(container.textContent).toContain('checks failed') + expect(container.textContent).toContain('main') + }) + + it('opens another PR without changing local branches', () => { + const onOpenPullRequest = vi.fn() + act(() => { + root.render( + + ) + }) + act(() => + container.querySelector('button[aria-label="Expand stack #51"]')?.click() + ) + act(() => + container.querySelector('button[data-stack-pr-number="203"]')?.click() + ) + + expect(onOpenPullRequest).toHaveBeenCalledTimes(1) + expect(onOpenPullRequest).toHaveBeenCalledWith('https://github.com/acme/repo/pull/203', { + metaKey: false, + ctrlKey: false, + shiftKey: false + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx new file mode 100644 index 00000000000..e0187b37709 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react' +import { ChevronDown, GitBranch, GitPullRequest } from 'lucide-react' +import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { translate } from '@/i18n/i18n' + +export type GitHubPRStackMapNavigationModifiers = Pick< + React.MouseEvent, + 'metaKey' | 'ctrlKey' | 'shiftKey' +> + +function stackEntryStatus(entry: GitHubPRStackEntry): string { + if (entry.state === 'merged') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.8a9bdc36c0', 'merged') + } + if (entry.state === 'closed') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.3511405914', 'closed') + } + if (entry.state === 'draft') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.568c647ccd', 'draft') + } + if (entry.mergeable === 'CONFLICTING') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.bea9ade223', 'conflicts') + } + if (entry.checksStatus === 'failure') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.838aadf512', 'checks failed') + } + if (entry.checksStatus === 'pending') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.316039b5db', 'checks pending') + } + if (entry.reviewDecision === 'CHANGES_REQUESTED') { + return translate( + 'auto.components.right.sidebar.GitHubPRStackMap.4b1e5ee9d3', + 'changes requested' + ) + } + if (entry.reviewDecision === 'REVIEW_REQUIRED') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.9a17b5255c', 'review needed') + } + if (entry.reviewDecision === 'APPROVED') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.d3d97cf3f2', 'approved') + } + return translate('auto.components.right.sidebar.GitHubPRStackMap.e6cb964305', 'open') +} + +export function GitHubPRStackMap({ + stack, + currentPRNumber, + onOpenPullRequest +}: { + stack: GitHubPRStack + currentPRNumber: number + onOpenPullRequest: (url: string, modifiers: GitHubPRStackMapNavigationModifiers) => void +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const entries = useMemo( + () => [...(stack.entries ?? [])].sort((a, b) => b.position - a.position), + [stack.entries] + ) + + return ( + + + + + + {entries.length > 0 ? ( +
+ {entries.map((entry) => { + const current = entry.number === currentPRNumber + return ( + + ) + })} +
+ + {stack.baseRefName} +
+
+ ) : ( +
+ {translate( + 'auto.components.right.sidebar.GitHubPRStackMap.525259fa17', + 'Stack details are temporarily unavailable.' + )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index 3c625e2003c..b8adeb40c4f 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -28,6 +28,11 @@ import { RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS } from './right-sidebar-primary-action-layout' import { translate } from '@/i18n/i18n' +import { + getGitHubPRStackMergeBlocker, + getGitHubPRStackMergeScope, + isGitHubPRStackMergeQueueRequired +} from './github-pr-stack-merge' export default function HostedReviewActions({ review, @@ -48,11 +53,33 @@ export default function HostedReviewActions({ const isGitLab = review.provider === 'gitlab' const shortLabel = isGitLab ? 'MR' : 'PR' const reviewLabel = isGitLab ? 'merge request' : 'pull request' + const stackMergeScope = useMemo( + () => (githubPR?.stack ? getGitHubPRStackMergeScope(githubPR.stack, review.number) : null), + [githubPR?.stack, review.number] + ) + const stackUsesMergeQueue = isGitHubPRStackMergeQueueRequired( + review.mergeQueueRequired, + githubPR?.mergeQueueRequired + ) + const stackMergeLabel = + stackMergeScope && stackUsesMergeQueue + ? stackMergeScope.count === 1 + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.9a41a687b7', + 'Queue through #{{pr}} · {{count}} PR', + { pr: review.number, count: stackMergeScope.count } + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.38a1bccb14', + 'Queue through #{{pr}} · {{count}} PRs', + { pr: review.number, count: stackMergeScope.count } + ) + : stackMergeScope?.label const mergePresentation = useMemo(() => { if (isGitLab) { return { ...presentGitLabMRMergeState(review), autoMergeAction: null } } - return presentGitHubPRMergeState({ + const presentation = presentGitHubPRMergeState({ ...githubPR, state: review.state, mergeable: review.mergeable, @@ -63,7 +90,30 @@ export default function HostedReviewActions({ autoMergeAllowed: review.autoMergeAllowed, mergeQueueRequired: review.mergeQueueRequired }) - }, [githubPR, isGitLab, review]) + if (!githubPR?.stack || !stackMergeScope) { + return presentation + } + const stackBlocker = getGitHubPRStackMergeBlocker(stackMergeScope) + return { + ...presentation, + label: stackMergeLabel ?? stackMergeScope.label, + tooltip: + stackBlocker ?? + (stackUsesMergeQueue + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.3de88351c5', + 'GitHub will add this pull request and every pull request below it to the merge queue.' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.a32fe6dba6', + 'GitHub will merge this pull request and every pull request below it in the stack.' + )), + directMergeAvailable: + !stackBlocker && + (stackMergeScope.complete || presentation.directMergeAvailable || stackUsesMergeQueue), + autoMergeAction: null + } + }, [githubPR, isGitLab, review, stackMergeLabel, stackMergeScope, stackUsesMergeQueue]) const mergeMethods = useMemo( () => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)), [githubPR?.mergeMethodSettings, isGitLab] @@ -140,12 +190,22 @@ export default function HostedReviewActions({ )} {merging - ? translate( - 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d', - 'Working...' - ) + ? stackMergeScope + ? stackUsesMergeQueue + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.73e0e1819d', + 'Queueing stack...' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.e555a41d32', + 'Merging stack...' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d', + 'Working...' + ) : mergePresentation.directMergeAvailable - ? mergeMethods.defaultLabel + ? (stackMergeLabel ?? mergeMethods.defaultLabel) : (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)} @@ -198,16 +258,17 @@ export default function HostedReviewActions({ )} - {mergeMethods.methods.map(({ method, label }) => ( - void handleMerge(method)} - > - - {label} - - ))} + {(!stackMergeScope || !stackUsesMergeQueue) && + mergeMethods.methods.map(({ method, label }) => ( + void handleMerge(method)} + > + + {label} + + ))} `#${entry.number}`).join(', ') + const included = + scope.complete && numbers + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.84f6f5b9eb', + 'Included: {{numbers}}. ', + { numbers } + ) + : '' + + if (usesMergeQueue) { + return { + title: translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.541984b2eb', + 'Queue through #{{pr}}?', + { pr: currentPRNumber } + ), + description: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.4809f55cdb', + '{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + { included, count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.be8f2621be', + '{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + { included, count: scope.count } + ), + confirmLabel: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.92ca033e72', + 'Queue {{count}} PR', + { count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.478a527b15', + 'Queue {{count}} PRs', + { count: scope.count } + ) + } + } + + return { + title: translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.1feef35ca4', + 'Merge through #{{pr}}?', + { pr: currentPRNumber } + ), + description: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.c3e036c99f', + '{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.', + { included, count: scope.count, method } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.369aba4b32', + '{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.', + { included, count: scope.count, method } + ), + confirmLabel: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.493c78f521', + 'Merge {{count}} PR', + { count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.eb7051d268', + 'Merge {{count}} PRs', + { count: scope.count } + ) + } +} diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts new file mode 100644 index 00000000000..be889a5eb74 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRStack } from '../../../../shared/types' +import { + getGitHubPRStackMergeBlocker, + getGitHubPRStackMergeScope, + isGitHubPRStackMergeQueueRequired +} from './github-pr-stack-merge' + +function makeStack(): GitHubPRStack { + return { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 201, + title: 'Models', + url: 'https://example.test/201', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 202, + title: 'API', + url: 'https://example.test/202', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 203, + title: 'UI', + url: 'https://example.test/203', + state: 'draft', + checksStatus: 'neutral', + mergeable: 'UNKNOWN' + } + ] + } +} + +describe('GitHub stack merge scope', () => { + it('uses stack metadata when review metadata has not observed the merge queue', () => { + expect(isGitHubPRStackMergeQueueRequired(false, true)).toBe(true) + }) + + it('includes the current PR and downstack entries, never upstack entries', () => { + const scope = getGitHubPRStackMergeScope(makeStack(), 202) + + expect(scope.entries.map((entry) => entry.number)).toEqual([201, 202]) + expect(scope.complete).toBe(true) + expect(scope.label).toBe('Merge through #202 · 2 PRs') + expect(getGitHubPRStackMergeBlocker(scope)).toBeNull() + }) + + it('keeps the GitHub-reported merge count when entry details are incomplete', () => { + const stack = makeStack() + stack.entries = stack.entries?.filter((entry) => entry.position !== 1) + + const scope = getGitHubPRStackMergeScope(stack, 202) + + expect(scope.entries.map((entry) => entry.number)).toEqual([202]) + expect(scope.count).toBe(2) + expect(scope.complete).toBe(false) + expect(scope.label).toBe('Merge through #202 · 2 PRs') + }) + + it('surfaces the first downstack blocker', () => { + const stack = makeStack() + stack.entries![0] = { ...stack.entries![0]!, mergeable: 'CONFLICTING' } + + expect(getGitHubPRStackMergeBlocker(getGitHubPRStackMergeScope(stack, 202))).toBe( + '#201 has merge conflicts.' + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts new file mode 100644 index 00000000000..8eacfb0380b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts @@ -0,0 +1,100 @@ +import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type GitHubPRStackMergeScope = { + count: number + complete: boolean + entries: GitHubPRStackEntry[] + label: string +} + +export function isGitHubPRStackMergeQueueRequired( + reviewMergeQueueRequired: boolean | null | undefined, + stackMergeQueueRequired: boolean | null | undefined +): boolean { + return reviewMergeQueueRequired === true || stackMergeQueueRequired === true +} + +export function getGitHubPRStackMergeScope( + stack: GitHubPRStack, + currentPRNumber: number +): GitHubPRStackMergeScope { + const entries = [...(stack.entries ?? [])] + .filter((entry) => entry.position <= stack.position) + .sort((a, b) => a.position - b.position) + const count = stack.position + const complete = + entries.length === count && entries.every((entry, index) => entry.position === index + 1) + return { + count, + complete, + entries, + label: + count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.merge.55ae29b907', + 'Merge through #{{pr}} · {{count}} PR', + { pr: currentPRNumber, count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.merge.b8446f6ec2', + 'Merge through #{{pr}} · {{count}} PRs', + { pr: currentPRNumber, count } + ) + } +} + +export function getGitHubPRStackMergeBlocker(scope: GitHubPRStackMergeScope): string | null { + for (const entry of scope.entries) { + if (entry.state === 'draft') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.189d0ec614', + '#{{pr}} is still a draft.', + { pr: entry.number } + ) + } + if (entry.state === 'closed') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.640fb50d9c', + '#{{pr}} is closed.', + { pr: entry.number } + ) + } + if (entry.mergeable === 'CONFLICTING' || entry.mergeStateStatus === 'DIRTY') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.46ffcbda75', + '#{{pr}} has merge conflicts.', + { pr: entry.number } + ) + } + if (entry.reviewDecision === 'CHANGES_REQUESTED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.6dabefd63e', + '#{{pr}} has requested changes.', + { pr: entry.number } + ) + } + if (entry.reviewDecision === 'REVIEW_REQUIRED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.2bb21fc326', + '#{{pr}} still needs review approval.', + { pr: entry.number } + ) + } + if (entry.mergeStateStatus === 'BEHIND') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.c23faf74df', + '#{{pr}} must be updated.', + { pr: entry.number } + ) + } + if (entry.mergeStateStatus === 'BLOCKED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.f561e80968', + '#{{pr}} is blocked.', + { pr: entry.number } + ) + } + } + return null +} diff --git a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts index a498da99071..2481e5b4293 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts @@ -30,7 +30,8 @@ export async function mergeGitHubHostedReview(args: { method: args.method, prRepo: args.prRepo ?? null }, - { timeoutMs: 30_000 } + // Why: GitHub stack merges can run asynchronously for several minutes. + { timeoutMs: 4 * 60_000 } ) } return window.api.gh.mergePR({ diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts deleted file mode 100644 index 9bcb57cf3f0..00000000000 --- a/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../shared/types' -import { filterPRCommentsByAudience, isAutomatedPRComment } from './pr-comment-filters' - -function comment(author: string): PRComment { - return { - id: author.length, - author, - authorAvatarUrl: '', - body: 'body', - createdAt: '2026-04-26T00:00:00.000Z', - url: '' - } -} - -describe('pr-comment-filters', () => { - it('classifies GitHub app and automation authors as bot comments', () => { - expect(isAutomatedPRComment(comment('github-actions[bot]'))).toBe(true) - expect(isAutomatedPRComment(comment('dependabot[bot]'))).toBe(true) - expect(isAutomatedPRComment(comment('renovate-bot'))).toBe(true) - expect(isAutomatedPRComment(comment('preview-automation'))).toBe(true) - }) - - it('keeps normal user logins as human comments', () => { - expect(isAutomatedPRComment(comment('octocat'))).toBe(false) - expect(isAutomatedPRComment(comment('robotics-dev'))).toBe(false) - }) - - it('trusts the GitHub-provided isBot flag when true', () => { - expect(isAutomatedPRComment({ ...comment('github-actions[bot]'), isBot: true })).toBe(true) - }) - - it('classifies known AI review services that register as User accounts', () => { - // These sign in as regular GitHub users, so GitHub's user.type is 'User' - // and their logins have no "bot" / "automation" tokens. Allowlist picks - // them up regardless. - expect(isAutomatedPRComment({ ...comment('qodo-ai-reviewer'), isBot: false })).toBe(true) - expect(isAutomatedPRComment({ ...comment('coderabbitai'), isBot: false })).toBe(true) - expect(isAutomatedPRComment(comment('sonarcloud'))).toBe(true) - expect(isAutomatedPRComment(comment('codium-ai-reviewer'))).toBe(true) - }) - - it('filters comments by audience', () => { - const comments = [ - comment('octocat'), - comment('github-actions[bot]'), - comment('mona'), - comment('dependabot[bot]') - ] - - expect(filterPRCommentsByAudience(comments, 'all')).toEqual(comments) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.author)).toEqual([ - 'octocat', - 'mona' - ]) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.author)).toEqual([ - 'github-actions[bot]', - 'dependabot[bot]' - ]) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.ts deleted file mode 100644 index 6da2c17c3df..00000000000 --- a/src/renderer/src/components/right-sidebar/pr-comment-filters.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - filterPRCommentsByAudience, - isBotPRComment as isAutomatedPRComment, - type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts index 2f4708f212a..dec478b8e1f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { PRComment } from '../../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { acknowledgePRCommentsAfterAiLaunch, attachPRReviewReplyParent, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index c73ab1aee1d..8eadffdcdfb 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -1,6 +1,6 @@ import type { PRComment, PRInfo } from '../../../../shared/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { getPRCommentGroupRoot, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { getPRCommentGroupRoot, type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' import { buildPRCommentBatchConversationReplyBody, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index a7b9af2153f..b8ab4111a77 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -46,7 +46,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} })) import type { PRComment } from '../../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS, clearPRCommentsListSelectionsForTests, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 6767e405c05..6245f6ff131 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { getPRCommentGroupId, groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { + getPRCommentGroupId, + groupPRComments, + type PRCommentGroup +} from '../../../../shared/pr-comment-groups' import { isPRCommentGroupQueueableForAI } from '@/lib/pr-comment-action-state' import type { PRComment } from '../../../../shared/types' diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx index 1eae2a150bc..09fd8fe4133 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx @@ -46,10 +46,14 @@ function makeRepo(overrides: Partial = {}): Repo { } as Repo } -function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise }): null { +function HookProbe(props: { + repo: Repo + onRefreshReview: () => Promise + pullRequest?: PRInfo +}): null { latest = useHostedReviewActions({ review, - githubPR, + githubPR: props.pullRequest ?? githubPR, repo: props.repo, isGitLab: false, shortLabel: 'PR', @@ -61,12 +65,16 @@ function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise }): return null } -async function renderHook(repo: Repo, onRefreshReview = vi.fn().mockResolvedValue(undefined)) { +async function renderHook( + repo: Repo, + onRefreshReview = vi.fn().mockResolvedValue(undefined), + pullRequest?: PRInfo +) { const container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render(createElement(HookProbe, { repo, onRefreshReview })) + root?.render(createElement(HookProbe, { repo, onRefreshReview, pullRequest })) }) return { onRefreshReview } } @@ -115,7 +123,7 @@ describe('useHostedReviewActions', () => { method: 'squash', prRepo }, - { timeoutMs: 30_000 } + { timeoutMs: 4 * 60_000 } ) expect(window.api.gh.mergePR).not.toHaveBeenCalled() expect(onRefreshReview).toHaveBeenCalledTimes(1) @@ -140,4 +148,103 @@ describe('useHostedReviewActions', () => { expect(runtimeRpcMocks.callRuntimeRpc).not.toHaveBeenCalled() expect(onRefreshReview).toHaveBeenCalledTimes(1) }) + + it('confirms the downstack merge scope before merging a registered stack', async () => { + const stackedPR = { + ...githubPR, + stack: { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 1014, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/1014', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 1015, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/1015', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 1016, + title: 'UI', + url: 'https://github.com/stablyai/orca/pull/1016', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + } + ] + } + } as PRInfo + await renderHook(makeRepo(), undefined, stackedPR) + + await act(async () => { + await latest?.handleMerge('squash') + }) + + expect(confirmationMocks.confirm).toHaveBeenCalledWith({ + title: 'Merge through #1015?', + description: + 'Included: #1014, #1015. GitHub will merge 2 pull requests atomically using squash. If any cannot merge, none will.', + confirmLabel: 'Merge 2 PRs' + }) + expect(window.api.gh.mergePR).toHaveBeenCalledTimes(1) + }) + + it('describes merge-queue stack behavior without promising atomicity or a method', async () => { + const stackedPR = { + ...githubPR, + mergeQueueRequired: true, + stack: { + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 1014, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/1014', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 1015, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/1015', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + } + ] + } + } as PRInfo + await renderHook(makeRepo(), undefined, stackedPR) + + await act(async () => { + await latest?.handleMerge('squash') + }) + + expect(confirmationMocks.confirm).toHaveBeenCalledWith({ + title: 'Queue through #1015?', + description: + 'Included: #1014, #1015. GitHub will add 2 pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + confirmLabel: 'Queue 2 PRs' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index eec79060650..6830fe86eee 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -10,6 +10,7 @@ import { updateGitHubHostedReviewState } from './hosted-review-github-actions' import { translate } from '@/i18n/i18n' +import { buildGitHubPRStackMergeConfirmation } from './github-pr-stack-confirmation' export type HostedReviewActionInfo = Pick< HostedReviewInfo, @@ -62,6 +63,21 @@ export function useHostedReviewActions({ const handleMerge = useCallback( async (method: GitHubPRMergeMethod = defaultMergeMethod) => { + if (!isGitLab && githubPR?.stack) { + const usesMergeQueue = + review.mergeQueueRequired === true || githubPR.mergeQueueRequired === true + const confirmed = await confirm( + buildGitHubPRStackMergeConfirmation({ + stack: githubPR.stack, + currentPRNumber: review.number, + method, + usesMergeQueue + }) + ) + if (!confirmed) { + return + } + } setMerging(true) setActionError(null) try { @@ -89,7 +105,18 @@ export function useHostedReviewActions({ setMerging(false) } }, - [githubPR?.prRepo, isGitLab, defaultMergeMethod, onRefreshReview, repo, review.number] + [ + confirm, + githubPR?.prRepo, + githubPR?.mergeQueueRequired, + githubPR?.stack, + isGitLab, + defaultMergeMethod, + onRefreshReview, + repo, + review.mergeQueueRequired, + review.number + ] ) const handleAutoMerge = useCallback(async () => { diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx new file mode 100644 index 00000000000..c1340b79327 --- /dev/null +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewDialog.tsx @@ -0,0 +1,188 @@ +import { useMemo, useState } from 'react' +import { Trash2 } from 'lucide-react' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import { translate } from '@/i18n/i18n' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import { useAppStore } from '@/store' +import { Button } from '../ui/button' +import { Checkbox } from '../ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui/dialog' +import { Label } from '../ui/label' +import { preservedBranchCleanupKey } from '@/lib/preserved-branch-cleanup' + +type ActionablePreservedBranch = PreservedBranchCleanup & { expectedHead: string } + +function selectionKey(branch: PreservedBranchCleanup): string { + return preservedBranchCleanupKey(branch) +} + +function getRepositoryLabel(branch: PreservedBranchCleanup): string { + const repoId = getRepoIdFromWorktreeId(branch.worktreeId) + return useAppStore.getState().repos?.find((repo) => repo.id === repoId)?.displayName || repoId +} + +export function PreservedBranchBatchReviewDialog({ + branches, + open, + onOpenChange, + onForceDelete +}: { + branches: readonly PreservedBranchCleanup[] + open: boolean + onOpenChange: (open: boolean) => void + onForceDelete: (branches: readonly ActionablePreservedBranch[]) => void +}): React.JSX.Element { + const actionableBranches = useMemo( + () => + branches.filter((branch): branch is ActionablePreservedBranch => + Boolean(branch.expectedHead) + ), + [branches] + ) + const actionableKeys = useMemo( + () => actionableBranches.map((branch) => selectionKey(branch)), + [actionableBranches] + ) + const [selectedKeys, setSelectedKeys] = useState>( + () => new Set(actionableKeys) + ) + const selectedBranches = actionableBranches.filter((branch) => + selectedKeys.has(selectionKey(branch)) + ) + const allSelected = selectedBranches.length === actionableBranches.length + const someSelected = selectedBranches.length > 0 && !allSelected + + const setBranchSelected = (branch: ActionablePreservedBranch, selected: boolean): void => { + setSelectedKeys((current) => { + const next = new Set(current) + if (selected) { + next.add(selectionKey(branch)) + } else { + next.delete(selectionKey(branch)) + } + return next + }) + } + + return ( + + + + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.c4bf8e7eaf', + 'Review kept branches' + )} + + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.f21976c9a8', + 'Select the local branches you want to force delete. Unselected branches stay in their repositories.' + )} + + + +
+
+ + + {translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.9602129d38', + '{{value0}} of {{value1}} selected', + { value0: selectedBranches.length, value1: actionableBranches.length } + )} + +
+ +
    + {branches.map((branch, index) => { + const actionableBranch = branch.expectedHead + ? (branch as ActionablePreservedBranch) + : null + const branchKey = selectionKey(branch) + const checkboxId = `preserved-branch-${index}` + return ( +
  • + { + if (actionableBranch) { + setBranchSelected(actionableBranch, checked === true) + } + }} + /> + + + {actionableBranch + ? translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.ee39e872d5', + 'May be unmerged' + ) + : translate( + 'auto.components.sidebar.PreservedBranchBatchReviewDialog.676db406fd', + 'Head unavailable' + )} + +
  • + ) + })} +
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx new file mode 100644 index 00000000000..34bdbc9d4e8 --- /dev/null +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import { useAppStore } from '@/store' +import { PreservedBranchBatchReviewDialog } from './PreservedBranchBatchReviewDialog' +import { + forceDeletePreservedBranchBatch, + type ActionablePreservedBranch +} from './preserved-branch-batch-toast' + +function isPreservedBranchCleanup(value: unknown): value is PreservedBranchCleanup { + if (!value || typeof value !== 'object') { + return false + } + const branch = value as Partial + return ( + typeof branch.worktreeId === 'string' && + typeof branch.branchName === 'string' && + (branch.expectedHead === undefined || typeof branch.expectedHead === 'string') && + (branch.hostId === undefined || typeof branch.hostId === 'string') && + (branch.runtimeEnvironmentId === undefined || typeof branch.runtimeEnvironmentId === 'string') + ) +} + +function getModalBranches(value: unknown): PreservedBranchCleanup[] { + return Array.isArray(value) ? value.filter(isPreservedBranchCleanup) : [] +} + +export default function PreservedBranchBatchReviewModal(): React.JSX.Element { + const activeModal = useAppStore((state) => state.activeModal) + const modalData = useAppStore((state) => state.modalData) + const closeModal = useAppStore((state) => state.closeModal) + const branches = useMemo(() => getModalBranches(modalData.branches), [modalData.branches]) + const open = activeModal === 'preserved-branch-review' && branches.length > 0 + + const handleForceDelete = (selectedBranches: readonly ActionablePreservedBranch[]): void => { + closeModal() + void forceDeletePreservedBranchBatch(selectedBranches) + } + + return ( + { + if (!nextOpen) { + closeModal() + } + }} + onForceDelete={handleForceDelete} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx new file mode 100644 index 00000000000..32f399610fa --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import type * as ContextualTour from '@/components/contextual-tours/use-contextual-tour' +import type * as VisibleWorkspaceKanban from './use-visible-workspace-kanban-worktree-ids' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' + +const { contentProbe, contextualTourMock } = vi.hoisted(() => ({ + contentProbe: { + mounts: vi.fn(), + projections: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + unmounts: vi.fn() + }, + contextualTourMock: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) + +vi.mock('@/components/ui/sheet', () => ({ + // Why: keep children mounted while closed so this measures Orca's gate, not Radix's portal. + Sheet: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
{children}
+ ), + SheetContent: ({ + children, + 'data-workspace-board-drag-preview': dragPreview + }: { + children: React.ReactNode + 'data-workspace-board-drag-preview'?: string + }) => ( +
+ {children} +
+ ) +})) + +vi.mock('./WorkspaceKanbanDrawerHeader', () => ({ + default: () =>
+})) +vi.mock('./WorkspaceKanbanLaneGrid', () => ({ default: () =>
})) +vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ default: () =>
})) +vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({ + default: React.forwardRef((_, ref) =>
) +})) + +vi.mock('./use-visible-workspace-kanban-worktree-ids', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useVisibleWorkspaceKanbanWorktreeIds: ( + params: Parameters[0] + ) => { + const visibleIds = actual.useVisibleWorkspaceKanbanWorktreeIds(params) + contentProbe.renders() + contentProbe.projections() + React.useEffect(() => { + contentProbe.mounts() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unmounts() + unsubscribe() + } + }, []) + return visibleIds + } + } +}) + +vi.mock('./use-workspace-kanban-selection', () => ({ + useWorkspaceKanbanSelection: () => ({ + selectedWorktreeIds: new Set(), + selectedWorktrees: [], + selectionAnchorId: null, + updateSelectionForGesture: vi.fn(), + updateSelectionForArea: vi.fn(), + clearSelection: vi.fn(), + selectForContextMenu: vi.fn(() => []) + }) +})) +vi.mock('./use-workspace-kanban-area-selection', () => ({ + useWorkspaceKanbanAreaSelection: () => ({ handleAreaSelectionPointerDown: vi.fn() }) +})) +vi.mock('./use-workspace-kanban-column-resize', () => ({ + useWorkspaceKanbanColumnResize: () => ({ + columnWidth: 308, + isResizingColumn: false, + onColumnResizeStart: vi.fn(), + onColumnResizeKeyDown: vi.fn() + }) +})) +vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({ + useWorkspaceKanbanCardPointerDrag: () => ({ + isPointerDragActiveRef: { current: false }, + onCardPointerDownCapture: vi.fn() + }) +})) +vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({ + useWorkspaceKanbanShiftWheelScroll: vi.fn() +})) +vi.mock('./use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: vi.fn() +})) +vi.mock('./use-workspace-status-drop', () => ({ useWorkspaceStatusDocumentDrop: vi.fn() })) +vi.mock('@/components/contextual-tours/use-contextual-tour', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useContextualTour: (...args: Parameters) => { + contextualTourMock(...args) + actual.useContextualTour(...args) + } + } +}) + +const initialAppState = useAppStore.getInitialState() +const onOpenChange = vi.fn() +const onMenuOpenChange = vi.fn() +let testContainer: HTMLDivElement +let testRoot: Root + +function activeContentSubscriptions(): number { + return contentProbe.mounts.mock.calls.length - contentProbe.unmounts.mock.calls.length +} + +function requireStoreListenerCount(): number { + const count = readStoreListenerCount() + expect(count).not.toBeNull() + return count ?? 0 +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderDrawer(open: boolean, dragPreview = false): Promise { + await act(async () => { + testRoot.render( + + ) + }) + await flushEffects() +} + +async function churnRemoteWorkspaceState(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ + worktreesByRepo: {}, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + agentStatusEpoch: index + }) + } + }) +} + +describe('WorkspaceKanbanDrawer mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + Object.values(contentProbe).forEach((probe) => probe.mockClear()) + contextualTourMock.mockClear() + onOpenChange.mockClear() + onMenuOpenChange.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts heavy content after the close animation and isolates closed churn', async () => { + const listenerBaseline = requireStoreListenerCount() + await renderDrawer(false) + const closedListenerCount = requireStoreListenerCount() + + expect(closedListenerCount).toBe(listenerBaseline) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + + await renderDrawer(true) + const openListenerCount = requireStoreListenerCount() + expect(openListenerCount).toBeGreaterThan(closedListenerCount + 15) + expect(activeContentSubscriptions()).toBe(1) + + await renderDrawer(false) + expect(testContainer.querySelector('[data-sheet-open="false"]')).not.toBeNull() + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + const lingeringNotifications = contentProbe.storeNotifications.mock.calls.length + await churnRemoteWorkspaceState(1) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(lingeringNotifications + 1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + expect(requireStoreListenerCount()).toBe(closedListenerCount) + + const hiddenProjectionCount = contentProbe.projections.mock.calls.length + const hiddenRenderCount = contentProbe.renders.mock.calls.length + const hiddenNotificationCount = contentProbe.storeNotifications.mock.calls.length + await churnRemoteWorkspaceState(1_000) + + expect(contentProbe.projections).toHaveBeenCalledTimes(hiddenProjectionCount) + expect(contentProbe.renders).toHaveBeenCalledTimes(hiddenRenderCount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(hiddenNotificationCount) + }) + + it('cancels the pending unmount when reopened at 299 ms', async () => { + await renderDrawer(true) + await renderDrawer(false) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await renderDrawer(true) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-sheet-open="true"]')).not.toBeNull() + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) + + it('preserves drag preview presentation when the preview becomes a full board', async () => { + await renderDrawer(true, true) + + expect(testContainer.querySelector('[data-workspace-board-drag-preview="true"]')).not.toBeNull() + expect(contextualTourMock).toHaveBeenLastCalledWith( + 'workspace-board', + false, + 'workspace_board_visible' + ) + + await renderDrawer(true, false) + + expect(testContainer.querySelector('[data-workspace-board-drag-preview]')).toBeNull() + expect(contextualTourMock).toHaveBeenLastCalledWith( + 'workspace-board', + true, + 'workspace_board_visible' + ) + expect(contentProbe.mounts).toHaveBeenCalledTimes(1) + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index d3998031dcb..b457c378a15 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -66,6 +66,9 @@ type WorkspaceKanbanDrawerProps = { onMenuOpenChange: (open: boolean) => void } +// Why: outlast the Sheet close animation so the board does not disappear mid-slide. +const WORKSPACE_BOARD_CLOSE_LINGER_MS = 300 + function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string { switch (message.kind) { case 'issue-read-failed': @@ -140,7 +143,26 @@ function formatTaskStatusSyncDescription(result: WorkspaceBoardTaskStatusSyncRes .join('. ') } -export default function WorkspaceKanbanDrawer({ +export default function WorkspaceKanbanDrawer( + props: WorkspaceKanbanDrawerProps +): React.JSX.Element | null { + const [lingering, setLingering] = useState(props.open) + useEffect(() => { + if (props.open) { + setLingering(true) + return + } + const timer = window.setTimeout(() => setLingering(false), WORKSPACE_BOARD_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [props.open]) + + if (!props.open && !lingering) { + return null + } + return +} + +function WorkspaceKanbanDrawerContent({ leftSidebarStyle, open, statusBarVisible, diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx deleted file mode 100644 index 633dce8a46e..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator' - -const mocks = vi.hoisted(() => ({ - status: 'inactive' as WorktreeStatus -})) - -vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: vi.fn(() => mocks.status) -})) - -function renderMarkup(status: WorktreeStatus): string { - mocks.status = status - return renderToStaticMarkup( - React.createElement(WorktreeActivityStatusIndicator, { worktreeId: 'wt-child' }) - ) -} - -describe('WorktreeActivityStatusIndicator', () => { - beforeEach(() => { - mocks.status = 'inactive' - }) - - it('renders the shared inactive status for slept worktrees', () => { - const markup = renderMarkup('inactive') - - expect(markup).toContain('Inactive') - expect(markup).toContain('bg-neutral-500/40') - expect(markup).not.toContain('bg-emerald-500') - }) - - it('renders the shared active status when the worktree is live', () => { - const markup = renderMarkup('active') - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - }) -}) diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx deleted file mode 100644 index be187336da8..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import { getWorktreeStatusLabel } from '@/lib/worktree-status' -import StatusIndicator from './StatusIndicator' -import { useWorktreeActivityStatus } from './use-worktree-activity-status' - -export function WorktreeActivityStatusIndicator({ - worktreeId, - className -}: { - worktreeId: string - className?: string -}): React.JSX.Element { - const status = useWorktreeActivityStatus(worktreeId) - - return ( - <> -
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx new file mode 100644 index 00000000000..1c4e3343af5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' + +import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { Badge } from '@/components/ui/badge' +import CacheTimer from './CacheTimer' +import { CONFLICT_OPERATION_LABELS } from './WorktreeCardHelpers' +import { TruncatedSidebarLabel } from './truncated-sidebar-label' +import { getDirectoryName } from './worktree-card-model' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardMetaRow({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + hostContextLabel, + identityDisplay, + isFolder, + newCardStyle, + branch, + detachedHeadDisplay, + conflictOperation, + cacheStartedAt, + cacheTtlMs + } = card + const { + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + hasHoverDetails, + showBranch, + showDetachedHeadInMetaRow, + showConflictOperationBadge, + showMetaRowDetails, + detailsAndPorts + } = presentation + + return ( +
+
+ {showRepoBadgeInMetaRow && repo && ( +
+ + + {repo.displayName} + +
+ )} + + {showHostContextBadge && ( + + {hostContextLabel} + + )} + + {showIdentityInNewCard ? ( + + ) : isFolder && !newCardStyle ? ( + + {getDirectoryName(worktree.path)} + + ) : showBranch ? ( + + ) : showDetachedHeadInMetaRow && detachedHeadDisplay ? ( + + ) : null} + + {showConflictOperationBadge && ( + + + {CONFLICT_OPERATION_LABELS[conflictOperation]} + + )} + + {cacheStartedAt != null && } +
+ + {showMetaRowDetails && ( +
{detailsAndPorts}
+ )} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts new file mode 100644 index 00000000000..2331b92d2cd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -0,0 +1,116 @@ +import type React from 'react' + +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' + +export type WorktreeRenameRequest = { + worktreeId: string + rowKey?: string +} + +export type ActiveSurfaceVariant = 'primary' | 'secondary' + +export type WorktreeCardProps = { + worktree: Worktree + repo: Repo | undefined + isActive: boolean + isCurrentWorktree?: boolean + isActiveSurface?: boolean + activeSurfaceVariant?: ActiveSurfaceVariant + isMultiSelected?: boolean + revealHighlight?: boolean + revealHighlightTone?: 'default' | 'ai' + selectedWorktrees?: readonly Worktree[] + hideRepoBadge?: boolean + hostContextLabel?: string + inPinnedSection?: boolean + activationRowKey?: string + renameRowKey?: string + contentIndent?: number + flushSurface?: boolean + lineageChildCount?: number + lineageCollapsed?: boolean + lineageChildren?: React.ReactNode + lineageChildrenStyle?: React.CSSProperties + onLineageToggle?: (event: React.MouseEvent) => void + isLineageDropTarget?: boolean + onActivate?: () => void + onImmediateActivate?: (worktreeId: string, rowKey: string | undefined) => void + onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect?: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onCardDragStart?: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd?: (event: React.DragEvent) => void + nativeDragEnabled?: boolean + affiliateListMode?: boolean + statusPrDisplay?: WorktreeCardPrDisplay | null +} + +type DefaultedWorktreeCardProp = + | 'isActiveSurface' + | 'activeSurfaceVariant' + | 'isMultiSelected' + | 'revealHighlight' + | 'revealHighlightTone' + | 'nativeDragEnabled' + | 'inPinnedSection' + | 'contentIndent' + | 'flushSurface' + | 'lineageChildCount' + | 'lineageCollapsed' + | 'isLineageDropTarget' + | 'affiliateListMode' + | 'statusPrDisplay' + +export type ResolvedWorktreeCardProps = Omit & { + isActiveSurface: boolean + activeSurfaceVariant: ActiveSurfaceVariant + isMultiSelected: boolean + revealHighlight: boolean + revealHighlightTone: 'default' | 'ai' + nativeDragEnabled: boolean + inPinnedSection: boolean + contentIndent: number + flushSurface: boolean + lineageChildCount: number + lineageCollapsed: boolean + isLineageDropTarget: boolean + affiliateListMode: boolean + statusPrDisplay: WorktreeCardPrDisplay | null +} + +export const EMPTY_WORKSPACE_PORTS = [] +export const HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS = 60_000 + +export function shouldBeginWorktreeRename( + request: WorktreeRenameRequest | null, + worktreeId: string, + rowKey: string | undefined +): boolean { + return ( + request?.worktreeId === worktreeId && + (request.rowKey === undefined || request.rowKey === rowKey) + ) +} + +export function formatSparseDirectoryPreview(directories: string[]): string { + const preview = directories.slice(0, 4).join(', ') + return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more` +} + +export function isWebClient(): boolean { + return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + +export function getDirectoryName(folderPath: string): string { + const normalized = folderPath.replace(/[\\/]+$/, '') + const parts = normalized.split(/[\\/]+/) + return parts.at(-1) || normalized || folderPath +} diff --git a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx new file mode 100644 index 00000000000..d1b24cca2ad --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx @@ -0,0 +1,86 @@ +import React from 'react' + +import { cn } from '@/lib/utils' +import { WorktreeCardHeader } from './worktree-card-header' +import { WorktreeCardMetaRow } from './worktree-card-meta-row' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows' +import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardParentContent({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + affiliateListMode, + newCardStyle, + lineageChildren, + showStatus, + unreadTooltip, + stopQuickActionPointerPropagation, + handleToggleUnreadQuick, + statusLaneReview, + branchIdentityDisplay, + showInlineAgentList + } = card + const { titleOnlyCard, parentContentMarginLeft, showCombinedStatusSlot, showUnreadQuickAction } = + presentation + + return ( +
+ {showCombinedStatusSlot ? ( +
+ +
+ ) : null} + + {/* Content area */} +
+ {/* Header row: Title */} + + {presentation.hasMetaRow && } + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx new file mode 100644 index 00000000000..5ce1e94501c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -0,0 +1,288 @@ +import React from 'react' + +import { + getFlushWorktreeCardPaddingLeft, + getNewCardStyleParentContentMarginLeft +} from './worktree-list-indentation' +import { + hasWorktreeCardDetails, + WorktreeCardDetailsHover, + WorktreeCardMetaBadges +} from './WorktreeCardMeta' +import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function buildWorktreeCardPresentation(card: WorktreeCardController) { + const { + worktree, + repo, + inPinnedSection, + hideRepoBadge, + hostContextLabel, + affiliateListMode, + flushSurface, + contentIndent, + newCardStyle, + compactCards, + isFolder, + detachedHeadDisplay, + branch, + identityDisplay, + folderMetaRowContent, + showIdentityInNewCard, + conflictOperation, + cardProps, + cacheStartedAt, + hasDetails, + hasPorts, + showStatus, + showInlineAgentList, + showLineageChildChip, + remoteBranchConflict, + visibleCardTitle, + workspacePorts, + metaIssue, + metaLinearIssue, + metaJiraIssue, + metaReview, + metaComment, + metaAutomationProvenance, + metaCliProvenance, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + linearIssue, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + detailsHoverControl, + showDeleteQuickAction + } = card + + // Why: pinned trees mix repos, so the repo icon shows regardless of groupBy's hideRepoBadge. + const showPinnedRepoIcon = inPinnedSection && !!repo + // Why: new card style retired the Compact/Detailed switch; repo identity uses the compact chip, not a lower pill. + const showRepoIdentityInTitle = newCardStyle || compactCards + const showInlineRepoBadge = + showRepoIdentityInTitle && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon + const showRepoBadgeInMetaRow = + !showRepoIdentityInTitle && !!repo && !hideRepoBadge && !showPinnedRepoIcon + const showHostContextBadge = !compactCards && !!hostContextLabel + const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null + const showBranch = + !isFolder && + branch.length > 0 && + !newCardStyle && + (!compactCards || branch !== worktree.displayName) + // Why: rebases already surface in source control, so dense cards skip the persistent rebase chip. + const showConflictOperationBadge = + !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' + const hasMetadataBadge = showConflictOperationBadge + const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle + // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive. + const showCombinedStatusSlot = showStatus + const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder + const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) + const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) + // Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content. + const hasDetailedMetaRowContent = Boolean( + (showRepoBadgeInMetaRow && repo) || + showHostContextBadge || + folderMetaRowContent || + showBranch || + showIdentityInNewCard || + showDetachedHeadInMetaRow || + showConflictOperationBadge || + cacheStartedAt != null || + showMetaRowDetails + ) + const hasMetaRow = compactCards + ? hasMetadataBadge || cacheStartedAt != null + : hasDetailedMetaRowContent + const showHeaderActions = showTitleRowPrimary || showDeleteQuickAction + // Why: normalize the title once so title/branch de-dupe and identity-only hover eligibility stay in sync. + const trimmedVisibleCardTitle = visibleCardTitle.trim() + const showBranchIdentityHover = newCardStyle + ? Boolean(identityDisplay) && + !cardProps.includes('branch') && + identityDisplay !== trimmedVisibleCardTitle + : compactCards && showBranch + const hoverBranchName = newCardStyle + ? identityDisplay + : showBranchIdentityHover + ? branch + : undefined + const hoverWorkspaceTitle = + trimmedVisibleCardTitle.length > 0 && trimmedVisibleCardTitle !== hoverBranchName + ? trimmedVisibleCardTitle + : undefined + const hasHoverIdentity = Boolean(hoverWorkspaceTitle || hoverBranchName) + const hasHoverDetails = + newCardStyle && + (hasWorktreeCardDetails({ + issue: hoverIssue, + linearIssue: hoverLinearIssue, + jiraIssue: hoverJiraIssue, + review: hoverReview, + comment: hoverComment, + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance + }) || + workspacePorts.length > 0 || + hasHoverIdentity) + // Why: the parent row owns metadata hover; don't stack the title's truncation tooltip on the details popover. + const titleWrapper = newCardStyle + ? hasHoverDetails + ? (title: React.ReactElement): React.ReactElement => title + : undefined + : compactCards && (showBranchIdentityHover || hasDetails || hasPorts) + ? (title: React.ReactElement): React.ReactElement => ( + : null} + openDelay={100} + // Why: compact mode also renders the plug/badge hover root; sharing one open-state made hovering the + // plug force-open the wider title card and race it closed (#9304), so let this title hover own its state. + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' + ? handleOpenReviewInOrca + : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: compact mode hides the metadata badge row, so title hover carries the explicit-link affordance. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {title} + + ) + : undefined + // Why: sidebar rows need a small surface inset while content stays aligned with the pre-inset layout. + const applyNewCardStyleStatusLaneOffset = newCardStyle && showCombinedStatusSlot + const cardPaddingLeft = flushSurface + ? getFlushWorktreeCardPaddingLeft(contentIndent, applyNewCardStyleStatusLaneOffset) + : contentIndent > 0 + ? `calc(0.125rem + ${contentIndent}px)` + : null + const parentContentMarginLeft = + flushSurface && applyNewCardStyleStatusLaneOffset + ? getNewCardStyleParentContentMarginLeft(contentIndent) + : 0 + const cardStyle = cardPaddingLeft ? { paddingLeft: cardPaddingLeft } : undefined + const detailsAndPortsContent = + hasDetails || hasPorts ? ( +
+ {hasPorts && } + {hasDetails && ( + + )} +
+ ) : null + const detailsAndPorts = + detailsAndPortsContent && !newCardStyle ? ( + : null} + hoverControl={detailsHoverControl} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenGitHubIssueInOrca : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {detailsAndPortsContent} + + ) : ( + detailsAndPortsContent + ) + const titleRowIndicators = showTitleRowIndicators ? ( +
{detailsAndPorts}
+ ) : null + const hasSecondaryCardContent = + hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip + const titleOnlyCard = !hasSecondaryCardContent + + return { + showPinnedRepoIcon, + showInlineRepoBadge, + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + showDetachedHeadInMetaRow, + showBranch, + showConflictOperationBadge, + showUnreadQuickAction, + showCombinedStatusSlot, + showTitleRowPrimary, + showMetaRowDetails, + showTitleRowIndicators, + hasMetaRow, + showHeaderActions, + showDeleteQuickAction, + hoverBranchName, + hoverWorkspaceTitle, + hasHoverDetails, + titleWrapper, + parentContentMarginLeft, + cardStyle, + detailsAndPorts, + titleRowIndicators, + titleOnlyCard + } +} + +export type WorktreeCardPresentation = ReturnType diff --git a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx new file mode 100644 index 00000000000..c71d9d6b0cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Workflow } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' +import WorktreeCardAgents from './WorktreeCardAgents' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSecondaryRows({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + settings, + isActive, + newCardStyle, + lineageChildren, + lineageCollapsed, + onLineageToggle, + remoteBranchConflict, + showInlineAgentList, + agentActivityDisplayMode, + compactInlineAgentRows, + showLineageChildChip, + lineageChildAriaLabel, + childWorkspaceShortLabel + } = card + const { hasMetaRow } = presentation + + return ( + <> + {remoteBranchConflict && ( +
+ + + {translate( + 'auto.components.sidebar.WorktreeCard.a88c92d0e3', + '{{value0}}/{{value1}} already exists.', + { + value0: remoteBranchConflict.remote, + value1: remoteBranchConflict.branchName + } + )} + +
+ )} + + {isActive && worktree.linkedLinearIssue ? ( + + ) : null} + + {/* Why: counterbalance the card stack gap (-mt-1) so agents right after the title read as one header group. */} + {showInlineAgentList && ( + + )} + + {showLineageChildChip && ( +
+ + + + + + {lineageCollapsed + ? translate( + 'auto.components.sidebar.WorktreeCard.8cb634cda6', + 'Show child workspaces' + ) + : translate( + 'auto.components.sidebar.WorktreeCard.57eaa61b55', + 'Hide child workspaces' + )} + + +
+ )} + + {!newCardStyle && lineageChildren && ( +
+ {lineageChildren} +
+ )} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx new file mode 100644 index 00000000000..7bc0abdcb89 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import { LoaderCircle } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' +import WorktreeContextMenu from './WorktreeContextMenu' +import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +import { WorktreeCardPortsDetails } from './WorktreeCardPorts' +import { WorktreeCardParentContent } from './worktree-card-parent-content' +import { buildWorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): React.JSX.Element { + const presentation = buildWorktreeCardPresentation(card) + const { + worktree, + selectedWorktrees, + onAssignWorkspaceStatus, + affiliateListMode, + isActiveSurface, + activeSurfaceVariant, + isMultiSelected, + revealHighlight, + revealHighlightTone, + flushSurface, + isLineageDropTarget, + nativeDragEnabled, + lineageChildren, + lineageChildrenStyle, + newCardStyle, + titleRenaming, + isDeleting, + isRuntimeDisconnected, + isQueuedForDeletion, + deleteLabel, + handleClick, + handleDoubleClick, + handleDragStart, + handleDragEnd, + handleContextMenuSelect, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + metaAutomationProvenance, + metaCliProvenance, + workspacePorts, + detailsHoverControl, + handleRenameTitle, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + linearIssue, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + showRenameErrorDialog, + setShowRenameErrorDialog + } = card + const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } = + presentation + + const parentCardContent = + + const parentHoverTriggerBody = ( +
+ {parentCardContent} +
+ ) + + const parentCardBodyWithHoverDetails = + hasHoverDetails && !titleRenaming ? ( + 0 ? : null + } + openDelay={100} + hoverControl={detailsHoverControl} + onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + hoverIssue && 'url' in hoverIssue && hoverIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {parentHoverTriggerBody} + + ) : ( + parentHoverTriggerBody + ) + + const cardBody = ( +
+ {isDeleting && ( +
+
+ {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel} +
+
+ )} + {parentCardBodyWithHoverDetails} + + {newCardStyle && lineageChildren ? ( +
+ {lineageChildren} +
+ ) : null} +
+ ) + + return ( + <> + {affiliateListMode ? ( + cardBody + ) : ( + + {cardBody} + + )} + + {typeof worktree.firstAgentMessageRenameError === 'string' && + worktree.firstAgentMessageRenameError.length > 0 && ( + + )} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts index df556f4e5cb..889587306d0 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-execution.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -11,6 +11,8 @@ import type { Worktree } from '../../../../shared/types' import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' +import { showPreservedBranchBatchToast } from './preserved-branch-batch-toast' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' // A failed delete usually means unresolved changes, so land on the diff panel. @@ -55,6 +57,8 @@ export async function runWorktreeDeletesInParallel( // Children must leave first or Git rejects their registered ancestor. group.sort((a, b) => b.path.length - a.path.length) } + const preservedBranches: PreservedBranchCleanup[] = [] + const aggregatePreservedBranches = uniqueTargets.length > 1 let listChanged = false const groupResults = await Promise.all( Array.from(groups.values()).map(async (group) => { @@ -74,7 +78,12 @@ export async function runWorktreeDeletesInParallel( } const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { ...options, - focusSuccessorOnDelete: false + focusSuccessorOnDelete: false, + suppressPreservedBranchToast: aggregatePreservedBranches, + onPreservedBranch: (branch) => { + preservedBranches.push(branch) + options.onPreservedBranch?.(branch) + } }) if (deleted) { deletedInGroup.push(target.id) @@ -94,6 +103,15 @@ export async function runWorktreeDeletesInParallel( if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { commitBatchFocus?.() } + if (aggregatePreservedBranches && preservedBranches.length > 0) { + const targetOrder = new Map(uniqueTargets.map((target, index) => [target.id, index])) + preservedBranches.sort( + (left, right) => + (targetOrder.get(left.worktreeId) ?? Number.MAX_SAFE_INTEGER) - + (targetOrder.get(right.worktreeId) ?? Number.MAX_SAFE_INTEGER) + ) + showPreservedBranchBatchToast(deletedSet.size, preservedBranches) + } return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) } @@ -107,9 +125,23 @@ export function runWorktreeDeleteWithToast( const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) const focusSuccessor = options.focusSuccessorOnDelete !== false - return removeWorktree(worktreeId, options.force === true) + const removal = options.suppressPreservedBranchToast + ? removeWorktree(worktreeId, options.force === true, { suppressPreservedBranchToast: true }) + : removeWorktree(worktreeId, options.force === true) + return removal .then((result) => { if (result.ok) { + if (result.preservedBranch) { + options.onPreservedBranch?.({ + worktreeId, + branchName: result.preservedBranch.branchName, + expectedHead: result.preservedBranch.head, + ...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}), + ...(result.preservedBranch.runtimeEnvironmentId + ? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId } + : {}) + }) + } if (focusSuccessor) { commitFocus() } diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts index 4092c5cde51..71ef9e87cda 100644 --- a/src/renderer/src/components/sidebar/worktree-delete-request.ts +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -1,4 +1,5 @@ import type { Worktree } from '../../../../shared/types' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorktreeBatchDeleteOptions = { forceConfirm?: boolean @@ -14,6 +15,8 @@ export type WorktreeDeleteOptions = { export type WorktreeDeleteWithToastOptions = { force?: boolean onForceDeleted?: (worktreeId: string) => void + onPreservedBranch?: (branch: PreservedBranchCleanup) => void + suppressPreservedBranchToast?: boolean // Batch deletion commits one focus handoff after all targets settle. focusSuccessorOnDelete?: boolean } diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts deleted file mode 100644 index 5de876fedb3..00000000000 --- a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types' -import { - getProjectGroupHeaderKey, - PINNED_GROUP_KEY, - type WorktreeGroupBy -} from './worktree-list-groups' -import { - buildWorktreeSectionActivitySummaries, - type WorktreeSectionActivityState -} from './worktree-section-activity' - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function makeRepo(overrides: Partial = {}): Repo { - return { - id: 'repo-1', - path: '/tmp/repo-1', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - ...overrides - } -} - -function makeProjectGroup(overrides: Partial): ProjectGroup { - return { - id: 'group-1', - name: 'Group', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...overrides - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'wt-1', - repoId: 'repo-1', - path: '/tmp/repo-1-feature', - branch: 'refs/heads/feature', - head: 'abc123', - isBare: false, - isMainWorktree: false, - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - comment: '', - displayName: 'feature', - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} - -function makeTerminalTab(overrides: Partial = {}): TerminalTab { - return { - id: 'tab-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'zsh', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0, - ...overrides - } -} - -function makeState( - overrides: Partial = {} -): WorktreeSectionActivityState { - return { - tabsByWorktree: {}, - browserTabsByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - terminalLayoutRootsByTabId: {}, - agentStatusEpoch: 0, - agentStatusByPaneKey: {}, - migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {}, - ...overrides - } -} - -function buildSummaries({ - groupBy = 'repo', - worktrees, - repos, - projectGroups = [], - state -}: { - groupBy?: WorktreeGroupBy - worktrees: readonly Worktree[] - repos: readonly Repo[] - projectGroups?: readonly ProjectGroup[] - state: WorktreeSectionActivityState -}) { - return buildWorktreeSectionActivitySummaries({ - groupBy, - worktrees, - repoMap: new Map(repos.map((repo) => [repo.id, repo])), - prCache: null, - workspaceStatuses: [], - projectGroups, - state - }) -} - -describe('buildWorktreeSectionActivitySummaries', () => { - it('counts running worktrees across repo project-group ancestors', () => { - const parent = makeProjectGroup({ id: 'parent', name: 'Parent' }) - const child = makeProjectGroup({ - id: 'child', - name: 'Child', - parentGroupId: parent.id - }) - const repo = makeRepo({ projectGroupId: child.id }) - const worktree = makeWorktree({ repoId: repo.id }) - const state = makeState({ - tabsByWorktree: { - [worktree.id]: [ - makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id, title: 'Codex working' }) - ] - }, - ptyIdsByTabId: { - 'tab-1': ['pty-1'] - } - }) - - const summaries = buildSummaries({ - worktrees: [worktree], - repos: [repo], - projectGroups: [parent, child], - state - }) - - expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({ - runningCount: 1 - }) - expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({ - runningCount: 1 - }) - expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 - }) - }) - - it('counts pinned workspace activity on pinned and natural headers', () => { - const repo = makeRepo({ projectGroupId: 'group-1' }) - const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) - const now = Date.now() - const entry: AgentStatusEntry = { - state: 'working', - prompt: '', - updatedAt: now, - stateStartedAt: now, - paneKey: makePaneKey('tab-1', LEAF_ID), - stateHistory: [] - } - const state = makeState({ - tabsByWorktree: { - [worktree.id]: [makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id })] - }, - agentStatusEpoch: 1, - agentStatusByPaneKey: { - [entry.paneKey]: entry - } - }) - - const summaries = buildSummaries({ - worktrees: [worktree], - repos: [repo], - projectGroups: [makeProjectGroup({ id: 'group-1' })], - state - }) - - expect(summaries.get(PINNED_GROUP_KEY)).toEqual({ - runningCount: 1 - }) - expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 - }) - expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toEqual({ - runningCount: 1 - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts deleted file mode 100644 index fb81da4f0cf..00000000000 --- a/src/renderer/src/components/sidebar/worktree-section-activity.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AppState } from '@/store/types' -import { resolveWorktreeStatus } from '@/lib/worktree-status' -import type { - ProjectGroup, - Repo, - TerminalPaneLayoutNode, - TerminalTab, - Worktree, - WorkspaceStatusDefinition -} from '../../../../shared/types' -import { - getGroupKeysForWorktree, - type WorktreeGroupBy, - PINNED_GROUP_KEY -} from './worktree-list-groups' -import { - selectLivePtyIdsForWorktree, - selectRuntimePaneTitlesForWorktree -} from './worktree-card-status-inputs' -import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' -import type { BrowserActivityTab } from './visible-worktree-activity-inputs' - -export type WorktreeSectionActivityState = Pick< - AppState, - | 'ptyIdsByTabId' - | 'runtimePaneTitlesByTabId' - | 'agentStatusEpoch' - | 'agentStatusByPaneKey' - | 'migrationUnsupportedByPtyId' - | 'retainedAgentsByPaneKey' -> & { - tabsByWorktree: Record[]> - browserTabsByWorktree: Record - terminalLayoutRootsByTabId: Record -} - -export type WorktreeSectionActivitySummary = { - runningCount: number -} - -export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = { - runningCount: 0 -} - -export function buildWorktreeSectionActivitySummaries({ - groupBy, - worktrees, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - state -}: { - groupBy: WorktreeGroupBy - worktrees: readonly Worktree[] - repoMap: Map - prCache: Record | null - workspaceStatuses: readonly WorkspaceStatusDefinition[] - settings?: AppState['settings'] - projectGroups: readonly ProjectGroup[] - state: WorktreeSectionActivityState -}): Map { - const summaries = new Map() - - for (const worktree of worktrees) { - const groupKeys = [ - ...(worktree.isPinned ? [PINNED_GROUP_KEY] : []), - ...getGroupKeysForWorktree( - groupBy, - worktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) - ] - if (groupKeys.length === 0) { - continue - } - - const status = getSectionWorktreeStatus(state, worktree.id) - for (const groupKey of groupKeys) { - const summary = summaries.get(groupKey) ?? { ...EMPTY_WORKTREE_SECTION_ACTIVITY } - if (status === 'working') { - summary.runningCount++ - } - summaries.set(groupKey, summary) - } - } - - return summaries -} - -function getSectionWorktreeStatus( - state: WorktreeSectionActivityState, - worktreeId: string -): ReturnType { - const agentSummary = selectWorktreeAgentActivitySummary(state, worktreeId) - - // Why: collapsed headers must mirror the card dot semantics exactly; otherwise - // a hidden section can advertise different activity than its visible cards. - return resolveWorktreeStatus({ - tabs: state.tabsByWorktree[worktreeId] ?? [], - browserTabs: state.browserTabsByWorktree[worktreeId] ?? [], - ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), - runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), - agentStatusPaneIdsByTabId: agentSummary.agentStatusPaneIdsByTabId, - terminalLayoutRootsByTabId: state.terminalLayoutRootsByTabId, - hasPermission: agentSummary.hasPermission, - hasLiveWorking: agentSummary.hasLiveWorking, - hasLiveDone: agentSummary.hasLiveDone, - hasRetainedDone: agentSummary.hasRetainedDone - }) -} diff --git a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx index fc16182b759..6e484c1bd0e 100644 --- a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx @@ -5,8 +5,8 @@ import type { ClaudeUsageSummary } from '../../../../shared/claude-usage-types' import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' -import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type ClaudeUsageDetailsProps = { @@ -57,7 +57,30 @@ export function ClaudeUsageDetails({ />
- + + {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} + {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined + ? `${Math.round(summary.cacheReuseRate * 100)}%` + : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} + + } + headings={[ + translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active'), + translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project'), + translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model'), + translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns'), + translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input'), + translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output'), + translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache') + ]} + unknownModel={translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} + rows={recentSessions} + getActivity={(row) => row.turns} + getTrailingTokens={(row) => row.cacheReadTokens + row.cacheWriteTokens} + /> ) } diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 8b24f0aca39..c47846c8c28 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -5,29 +5,16 @@ import { DatabaseZap, FolderKanban, Gauge, - RefreshCw, - SlidersHorizontal, Sparkles, Waypoints } from 'lucide-react' import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageDetails } from './ClaudeUsageDetails' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -86,34 +73,27 @@ export function ClaudeUsagePane(): React.JSX.Element { void setClaudeUsageEnabled(enabled) } + const title = translate( + 'auto.components.stats.ClaudeUsagePane.6afacbee37', + 'Claude Usage Tracking' + ) + const enableLabel = translate( + 'auto.components.stats.ClaudeUsagePane.424cd50412', + 'Enable Claude usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate( - 'auto.components.stats.ClaudeUsagePane.6afacbee37', - 'Claude Usage Tracking' - )} -

-

- {translate( - 'auto.components.stats.ClaudeUsagePane.0cb1a36d7d', - 'Reads local Claude usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } @@ -124,204 +104,145 @@ export function ClaudeUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyClaudeData ?? scanState.hasAnyClaudeData return ( -
-
-
-

- {translate('auto.components.stats.ClaudeUsagePane.6afacbee37', 'Claude Usage Tracking')} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.ClaudeUsagePane.2d41fd45c6', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- {summary && daily.length > 0 && ( - - )} - - - - - - - - - - {translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')} - - - - - - {translate('auto.components.stats.ClaudeUsagePane.f61cffb9c8', 'Scope')} - - void setClaudeUsageScope(value as ClaudeUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.ClaudeUsagePane.505be9aac4', 'Range')} - - void setClaudeUsageRange(value as ClaudeUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.ClaudeUsagePane.7dde9331fd', - 'No local Claude usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - 0 - ? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%` - : 'n/a' - } - icon={} - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.ClaudeUsagePane.51ae85fa00', - 'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.ClaudeUsagePane.2d41fd45c6', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.ClaudeUsagePane.e9bf9fce0e', + 'Claude usage options' )} -
+ filtersLabel={translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.ClaudeUsagePane.c5b9b344d0', + 'Refresh Claude usage' + )} + refreshLabel={translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')} + filterSections={[ + void setClaudeUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setClaudeUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.ClaudeUsagePane.7dde9331fd', + 'No local Claude usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshClaudeUsage()} + headerAction={ + summary && daily.length > 0 ? ( + + ) : undefined + } + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + 0 + ? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%` + : 'n/a' + } + icon={} + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.ClaudeUsagePane.51ae85fa00', + 'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx deleted file mode 100644 index 57752c46515..00000000000 --- a/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { - ClaudeUsageSessionRow, - ClaudeUsageSummary -} from '../../../../shared/claude-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function ClaudeUsageRecentSessionsTable({ - recentSessions, - summary -}: { - recentSessions: ClaudeUsageSessionRow[] - summary: ClaudeUsageSummary | null -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} - {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined - ? `${Math.round(summary.cacheReuseRate * 100)}%` - : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')} - - {translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')} - - {translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')} - - {translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')} - - {translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')} - - {translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')} - - {translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} - {row.turns}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - - {formatTokens(row.cacheReadTokens + row.cacheWriteTokens)} -
-
-
- ) -} diff --git a/src/renderer/src/components/stats/CodexUsageDetails.tsx b/src/renderer/src/components/stats/CodexUsageDetails.tsx index 7e7a46fd17e..c297d60ab4c 100644 --- a/src/renderer/src/components/stats/CodexUsageDetails.tsx +++ b/src/renderer/src/components/stats/CodexUsageDetails.tsx @@ -5,8 +5,8 @@ import type { CodexUsageSummary } from '../../../../shared/codex-usage-types' import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type CodexUsageDetailsProps = { @@ -58,7 +58,27 @@ export function CodexUsageDetails({ />
- + row.events} + getTrailingTokens={(row) => row.totalTokens} + getModelSuffix={(row) => (row.hasInferredPricing ? ' *' : '')} + /> ) } diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index ad6e02c7b56..1f36402a87c 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -1,32 +1,12 @@ import { useEffect } from 'react' -import { - Activity, - Brain, - Coins, - DatabaseZap, - FolderKanban, - RefreshCw, - SlidersHorizontal, - Sparkles -} from 'lucide-react' +import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react' import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { CodexUsageDetails } from './CodexUsageDetails' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -85,38 +65,31 @@ export function CodexUsagePane(): React.JSX.Element { void setCodexUsageEnabled(enabled) } + const title = translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking') + const enableLabel = translate( + 'auto.components.stats.CodexUsagePane.f7c1affbd5', + 'Enable Codex usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')} -

-

- {translate( - 'auto.components.stats.CodexUsagePane.13badcd8f2', - 'Reads local Codex usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { return ( @@ -126,183 +99,121 @@ export function CodexUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyCodexData ?? scanState.hasAnyCodexData return ( -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.CodexUsagePane.8a6655f7a2', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- {summary && daily.length > 0 && ( - - )} - - - - - - - - - - {translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')} - - - - - - {translate('auto.components.stats.CodexUsagePane.6d68e8399a', 'Scope')} - - void setCodexUsageScope(value as CodexUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.CodexUsagePane.89162e019b', 'Range')} - - void setCodexUsageRange(value as CodexUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.CodexUsagePane.4c865393b4', - 'No local Codex usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.CodexUsagePane.94ac1f1ee7', - 'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.CodexUsagePane.8a6655f7a2', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.CodexUsagePane.70b5b8581f', + 'Codex usage options' )} -
+ filtersLabel={translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.CodexUsagePane.ec4d270e2c', + 'Refresh Codex usage' + )} + refreshLabel={translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')} + filterSections={[ + void setCodexUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setCodexUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.CodexUsagePane.4c865393b4', + 'No local Codex usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshCodexUsage()} + headerAction={ + summary && daily.length > 0 ? ( + + ) : undefined + } + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.CodexUsagePane.94ac1f1ee7', + 'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx deleted file mode 100644 index f5ee1a7a430..00000000000 --- a/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { CodexUsageSessionRow } from '../../../../shared/codex-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function CodexUsageRecentSessionsTable({ - recentSessions -}: { - recentSessions: CodexUsageSessionRow[] -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.CodexUsagePane.0bd8655475', - 'Most recent local Codex sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')} - - {translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')} - - {translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')} - - {translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')} - - {translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')} - - {translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')} - - {translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')} - {row.hasInferredPricing ? ' *' : ''} - {row.events}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - {formatTokens(row.totalTokens)}
-
-
- ) -} diff --git a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx index b3b58733d78..541db1cb7d4 100644 --- a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx @@ -5,8 +5,8 @@ import type { OpenCodeUsageSummary } from '../../../../shared/opencode-usage-types' import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type OpenCodeUsageDetailsProps = { @@ -58,7 +58,26 @@ export function OpenCodeUsageDetails({ />
- + row.events} + getTrailingTokens={(row) => row.totalTokens} + /> ) } diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index 6c11240a866..861800c99e5 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -1,34 +1,14 @@ import { useEffect } from 'react' -import { - Activity, - Brain, - Coins, - DatabaseZap, - FolderKanban, - RefreshCw, - SlidersHorizontal, - Sparkles -} from 'lucide-react' +import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react' import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../../../shared/opencode-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { OpenCodeUsageDetails } from './OpenCodeUsageDetails' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -90,44 +70,34 @@ export function OpenCodeUsagePane(): React.JSX.Element { void setOpenCodeUsageEnabled(enabled) } + const title = translate( + 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', + 'OpenCode Usage Tracking' + ) + const enableLabel = translate( + 'auto.components.stats.OpenCodeUsagePane.f04131b3be', + 'Enable OpenCode usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', - 'OpenCode Usage Tracking' - )} -

-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.b8b3522436', - 'Reads local OpenCode usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { return ( @@ -137,192 +107,116 @@ export function OpenCodeUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData return ( -
-
-
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', - 'OpenCode Usage Tracking' - )} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.OpenCodeUsagePane.6cc7782458', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- - - - - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')} - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.40d283c837', 'Scope')} - - void setOpenCodeUsageScope(value as OpenCodeUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0', 'Range')} - - void setOpenCodeUsageRange(value as OpenCodeUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.OpenCodeUsagePane.bb6363e08c', - 'No local OpenCode usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.e5bb23d85e', - 'Cost comes from the local OpenCode database when the assistant message recorded one.' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.OpenCodeUsagePane.6cc7782458', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.OpenCodeUsagePane.230d6de108', + 'OpenCode usage options' )} -
+ filtersLabel={translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.OpenCodeUsagePane.bed558df0b', + 'Refresh OpenCode usage' + )} + refreshLabel={translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')} + filterSections={[ + void setOpenCodeUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setOpenCodeUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.OpenCodeUsagePane.bb6363e08c', + 'No local OpenCode usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshOpenCodeUsage()} + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.e5bb23d85e', + 'Cost comes from the local OpenCode database when the assistant message recorded one.' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx deleted file mode 100644 index ad8e3a38152..00000000000 --- a/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { OpenCodeUsageSessionRow } from '../../../../shared/opencode-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function OpenCodeUsageRecentSessionsTable({ - recentSessions -}: { - recentSessions: OpenCodeUsageSessionRow[] -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.81817a641a', - 'Most recent local OpenCode sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')} - - {translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')} - - {translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')} - - {translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')} - - {translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')} - - {translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')} - - {translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')} - {row.events}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - {formatTokens(row.totalTokens)}
-
-
- ) -} diff --git a/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx new file mode 100644 index 00000000000..1ffe503b858 --- /dev/null +++ b/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx @@ -0,0 +1,86 @@ +import { formatSessionTime, formatTokens } from './usage-formatters' + +type UsageRecentSession = { + sessionId: string + lastActiveAt: string + projectLabel: string + model: string | null + inputTokens: number + outputTokens: number +} + +type UsageRecentSessionHeadings = readonly [ + lastActive: string, + project: string, + model: string, + activity: string, + input: string, + output: string, + trailing: string +] + +type UsageRecentSessionsTableProps = { + title: React.ReactNode + description: React.ReactNode + headings: UsageRecentSessionHeadings + unknownModel: string + rows: readonly Row[] + getActivity: (row: Row) => number + getTrailingTokens: (row: Row) => number + getModelSuffix?: (row: Row) => React.ReactNode +} + +export function UsageRecentSessionsTable({ + title, + description, + headings, + unknownModel, + rows, + getActivity, + getTrailingTokens, + getModelSuffix +}: UsageRecentSessionsTableProps): React.JSX.Element { + return ( +
+
+

{title}

+

{description}

+
+
+ + + + {headings.map((heading) => ( + + ))} + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
+ {heading} +
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? unknownModel} + {getModelSuffix?.(row)} + {getActivity(row)}{formatTokens(row.inputTokens)} + {formatTokens(row.outputTokens)} + + {formatTokens(getTrailingTokens(row))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx new file mode 100644 index 00000000000..b4ba3dd2678 --- /dev/null +++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' + +afterEach(cleanup) + +function renderEnabled({ + hasData = false, + isRefreshing = false +}: { hasData?: boolean; isRefreshing?: boolean } = {}) { + const onEnabledChange = vi.fn() + const onRefresh = vi.fn() + const onScopeChange = vi.fn() + + render( + , +
Range filter
+ ]} + selectionSummary="Orca worktrees only • Last 7 days" + emptyMessage="No local Codex usage found yet for this scope." + onEnabledChange={onEnabledChange} + onRefresh={onRefresh} + headerAction={Share usage} + > +
Ready content
+
+ ) + + return { onEnabledChange, onRefresh, onScopeChange } +} + +describe('UsageTrackingPaneShell', () => { + it('renders the disabled state and enables tracking', async () => { + const onEnabledChange = vi.fn() + const user = userEvent.setup() + render( + + ) + + expect(screen.getByText('Reads local Codex usage logs.')).toBeInTheDocument() + await user.click(screen.getByRole('switch', { name: 'Enable Codex usage analytics' })) + expect(onEnabledChange).toHaveBeenCalledWith(true) + }) + + it('renders the empty state and wires header actions', async () => { + const user = userEvent.setup() + const { onEnabledChange, onRefresh, onScopeChange } = renderEnabled() + + expect(screen.getByText('Updated now')).toBeInTheDocument() + expect(screen.getByText('Share usage')).toBeInTheDocument() + expect(screen.getByText('Orca worktrees only • Last 7 days')).toBeInTheDocument() + expect(screen.getByText('No local Codex usage found yet for this scope.')).toBeInTheDocument() + expect(screen.queryByText('Ready content')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Codex usage options' })) + expect(screen.getByRole('separator')).toBeInTheDocument() + await user.click(screen.getByRole('menuitemradio', { name: 'All local usage' })) + await user.click(screen.getByRole('button', { name: 'Refresh Codex usage' })) + await user.click(screen.getByRole('switch', { name: 'Enable Codex usage analytics' })) + expect(onScopeChange).toHaveBeenCalledWith('all') + expect(onRefresh).toHaveBeenCalledOnce() + expect(onEnabledChange).toHaveBeenCalledWith(false) + }) + + it('renders ready content and disables refresh while scanning', () => { + renderEnabled({ hasData: true, isRefreshing: true }) + + expect(screen.getByText('Ready content')).toBeInTheDocument() + expect( + screen.queryByText('No local Codex usage found yet for this scope.') + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh Codex usage' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx new file mode 100644 index 00000000000..5ca091f4d19 --- /dev/null +++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx @@ -0,0 +1,160 @@ +import { Fragment, type ReactElement, type ReactNode } from 'react' +import { RefreshCw, SlidersHorizontal } from 'lucide-react' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { Switch } from '../ui/switch' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' + +type UsageFilterRadioGroupProps = { + label: ReactNode + value: Value + options: readonly { value: Value; label: ReactNode }[] + onValueChange: (value: Value) => void | Promise +} + +export function UsageFilterRadioGroup({ + label, + value, + options, + onValueChange +}: UsageFilterRadioGroupProps): React.JSX.Element { + return ( + <> + {label} + void onValueChange(nextValue as Value)} + > + {options.map((option) => ( + + {option.label} + + ))} + + + ) +} + +type UsageTrackingPaneShellProps = { + title: ReactNode + enableLabel: string + onEnabledChange: (enabled: boolean) => void +} & ( + | { + enabled: false + disabledDescription: ReactNode + } + | { + enabled: true + status: ReactNode + isRefreshing: boolean + hasData: boolean + optionsLabel: string + filtersLabel: ReactNode + refreshAriaLabel: string + refreshLabel: ReactNode + filterSections: readonly ReactElement[] + selectionSummary: ReactNode + emptyMessage: ReactNode + onRefresh: () => void | Promise + headerAction?: ReactNode + children: ReactNode + } +) + +export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): React.JSX.Element { + if (!props.enabled) { + return ( +
+
+
+

{props.title}

+

{props.disabledDescription}

+
+ +
+
+ ) + } + + return ( +
+
+
+

{props.title}

+

{props.status}

+
+
+ {props.headerAction} + + + + + + + + + + {props.filtersLabel} + + + + + {props.filterSections.map((section, index) => ( + + {index > 0 && } + {section} + + ))} + + + + + + + + + {props.refreshLabel} + + + + +
+
+ +
+

{props.selectionSummary}

+
+ + {!props.hasData ? ( +
+ {props.emptyMessage} +
+ ) : ( + props.children + )} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index bbbdddabc98..5e989f9e4a9 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -23,6 +23,8 @@ import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { useProjectHostSetupProjection } from '@/store/selectors' +import { terminalQuickCommandMatchesWorkspaceProject } from '@/lib/terminal-quick-command-project-scope' type TabBarQuickCommandsButtonProps = { worktreeId: string @@ -35,6 +37,7 @@ export function TabBarQuickCommandsButton({ }: TabBarQuickCommandsButtonProps): React.JSX.Element | null { const recentByGroup = useAppStore((s) => s.recentQuickCommandIdByGroup) const repos = useAppStore((s) => s.repos) + const projectHostSetupProjection = useProjectHostSetupProjection() const { executionHostId, hosts, refreshRemoteHost, remoteHostLoadFailed, remoteHostPending } = useTerminalQuickCommandHosts(worktreeId) const confirm = useConfirmationDialog() @@ -61,12 +64,20 @@ export function TabBarQuickCommandsButton({ const scope = getTerminalQuickCommandScope(command) if (scope.type === 'global') { globalList.push(entry) - } else if (scope.type === 'repo' && repoId !== null && scope.repoId === repoId) { + } else if ( + scope.type === 'repo' && + terminalQuickCommandMatchesWorkspaceProject(command, { + commandHostId: entry.hostId, + projectHostSetups: projectHostSetupProjection.setups, + targetHostId: executionHostId, + targetRepoId: repoId + }) + ) { repoList.push(entry) } } return { repoCommands: repoList, globalCommands: globalList } - }, [hosts, repoId]) + }, [executionHostId, hosts, projectHostSetupProjection.setups, repoId]) const recentId = recentByGroup[groupId] ?? null // Why: split-button label prefers the most recently used command for this diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 28d36689bcf..da6070241f9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -32,16 +32,8 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import type { PtyTransportRecoveryState } from './pty-transport-types' import { fitPanes, isWindowsUserAgent } from './pane-helpers' -import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context' -import { - getExplicitRuntimeEnvironmentIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' -import { - selectRuntimeAwareSshStatus, - selectRuntimeAwareSshTargetLabel, - selectRuntimeAwareSshTargetRemoved -} from '@/store/slices/runtime-environment-ssh' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' @@ -123,7 +115,6 @@ import { resolveNativeChatLeafRoute, type NativeChatLeafRoute } from '../native-chat/native-chat-leaf-routing' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' @@ -169,18 +160,17 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' -import { useRepoById } from '@/store/selectors' +import { useProjectHostSetupProjection, useRepoById } from '@/store/selectors' import { refitAndRefreshAllTerminalPanes } from '@/lib/pane-manager/pane-manager-registry' import { getTerminalQuickCommandScope, - isTerminalQuickCommandComplete, - terminalQuickCommandMatchesRepo + isTerminalQuickCommandComplete } from '../../../../shared/terminal-quick-commands' +import { terminalQuickCommandMatchesWorkspaceProject } from '@/lib/terminal-quick-command-project-scope' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog @@ -243,6 +233,7 @@ import { getCachedUnifiedTerminalTabForWorktree } from './terminal-unified-tab-lookup' import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' import { isXtermHelperTextarea, releaseTerminalFocusForOutsidePointerDown, @@ -349,37 +340,14 @@ function TerminalPane( const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible - const sshReconnectTargetId = useAppStore((store) => { - const connectionId = getConnectionIdFromState(store, worktreeId) - // Why: runtime-owned SSH targets are internal plumbing users can't connect to, so a reconnect prompt would mislead. - if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { - return null - } - return connectionId - }) - const nativeChatTranscriptIsLocalReadable = useAppStore((store) => - isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) - ) - // Which machine's SSH store this target belongs to: a remote server's per-environment bucket, or null for this machine's local SSH maps. - const sshReconnectEnvironmentId = useAppStore((store) => - sshReconnectTargetId ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) : null - ) - const sshReconnectStatus = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : null - ) - const sshReconnectTargetLabel = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : '' - ) - // Why: a ghost target (removed from its host) can only fail reconnect, so the overlay offers Remove instead of Connect. - const sshReconnectTargetRemoved = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : false - ) + const { + nativeChatTranscriptIsLocalReadable, + sshReconnectEnvironmentId, + sshReconnectStatus, + sshReconnectTargetId, + sshReconnectTargetLabel, + sshReconnectTargetRemoved + } = useAppStore(useShallow((store) => selectTerminalPaneHostState(store, worktreeId))) useEffect(() => { if (!sshReconnectEnvironmentId) { return @@ -819,6 +787,7 @@ function TerminalPane( const quickCommandRepoId = worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? null : getRepoIdFromWorktreeId(worktreeId) const quickCommandRepo = useRepoById(quickCommandRepoId) + const projectHostSetupProjection = useProjectHostSetupProjection() const quickCommandRepoLabel = quickCommandRepo ? quickCommandRepo.displayName || quickCommandRepo.path : quickCommandRepoId @@ -2550,6 +2519,7 @@ function TerminalPane( rightClickToPaste }) const { + executionHostId: quickCommandExecutionHostId, hosts: quickCommandHosts, refreshRemoteHost: refreshQuickCommandRemoteHost, remoteHostLoadFailed: quickCommandHostLoadFailed, @@ -2568,12 +2538,23 @@ function TerminalPane( repoCommands: commands.filter((command) => { const scope = getTerminalQuickCommandScope(command) return ( - scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) + scope.type === 'repo' && + terminalQuickCommandMatchesWorkspaceProject(command, { + commandHostId: host.hostId, + projectHostSetups: projectHostSetupProjection.setups, + targetHostId: quickCommandExecutionHostId, + targetRepoId: quickCommandRepoId + }) ) }) } }), - [quickCommandHosts, quickCommandRepoId] + [ + projectHostSetupProjection.setups, + quickCommandExecutionHostId, + quickCommandHosts, + quickCommandRepoId + ] ) useEffect(() => { if (contextMenu.open) { diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts new file mode 100644 index 00000000000..539348edf8b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS, + MAX_DEFERRED_REATTACH_LIVE_CHUNKS, + type DeferredReattachLiveDataChunk +} from './deferred-reattach-live-data-queue' + +function createChunk( + data: string, + streamGeneration = 1, + options: Omit = { + ptyId: 'pty-1' + } +): DeferredReattachLiveDataChunk { + return { data, streamGeneration, ...options } +} + +function releaseTransferred(chunks: DeferredReattachLiveDataChunk[]): void { + for (const chunk of chunks) { + chunk.ackCredit?.() + } +} + +class ReferenceDeferredQueue { + private chunks: DeferredReattachLiveDataChunk[] = [] + private retainedChars = 0 + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + this.chunks = this.chunks.filter((queued) => { + const keep = queued.streamGeneration === chunk.streamGeneration + if (!keep) { + queued.ackCredit?.() + } + return keep + }) + this.retainedChars = this.chunks.reduce((total, queued) => total + queued.data.length, 0) + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + this.chunks.push({ + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + }) + this.retainedChars += this.chunks.at(-1)?.data.length ?? 0 + + let dropped = oversized + while ( + this.chunks.length > 1 && + (this.chunks.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks.shift() + this.retainedChars -= removed?.data.length ?? 0 + removed?.ackCredit?.() + dropped = true + } + if (dropped && this.chunks[0]) { + this.chunks[0].meta = { ...this.chunks[0].meta, droppedOutput: true } + } + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const chunks = this.chunks + this.chunks = [] + this.retainedChars = 0 + return chunks + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } +} + +function normalizedChunks(chunks: DeferredReattachLiveDataChunk[]): unknown[] { + return chunks.map(({ ackCredit: _ackCredit, ...chunk }) => chunk) +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +describe('deferred reattach live data queue', () => { + it('does not rescan or shift the live queue while saturated', () => { + const originalFilter = Array.prototype.filter + const originalReduce = Array.prototype.reduce + const originalShift = Array.prototype.shift + const queue = new DeferredReattachLiveDataQueue() + + try { + for (const method of ['filter', 'reduce', 'shift'] as const) { + Object.defineProperty(Array.prototype, method, { + configurable: true, + writable: true, + value() { + throw new Error(`Array.${method} should not run on enqueue`) + } + }) + } + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS * 2; index += 1) { + queue.enqueue(createChunk('x'.repeat(512))) + } + } finally { + Object.defineProperties(Array.prototype, { + filter: { configurable: true, writable: true, value: originalFilter }, + reduce: { configurable: true, writable: true, value: originalReduce }, + shift: { configurable: true, writable: true, value: originalShift } + }) + } + + expect(queue.takeAll()).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + }) + + it('evicts whole chunks only after the count and character boundaries are exceeded', () => { + const countQueue = new DeferredReattachLiveDataQueue() + const countAcks = Array.from({ length: MAX_DEFERRED_REATTACH_LIVE_CHUNKS + 1 }, () => vi.fn()) + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS; index += 1) { + countQueue.enqueue( + createChunk('', 1, { ptyId: 'pty-1', meta: { seq: index }, ackCredit: countAcks[index] }) + ) + } + + expect(countQueue.getStorageForTest().retainedChunks).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(countAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + countQueue.enqueue( + createChunk('', 1, { + ptyId: 'pty-1', + meta: { seq: MAX_DEFERRED_REATTACH_LIVE_CHUNKS }, + ackCredit: countAcks.at(-1) + }) + ) + + const counted = countQueue.takeAll() + expect(countAcks[0]).toHaveBeenCalledOnce() + expect(counted).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(counted[0]?.meta).toEqual({ seq: 1, droppedOutput: true }) + expect(counted.at(-1)?.meta?.seq).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + releaseTransferred(counted) + expect(countAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + + const charQueue = new DeferredReattachLiveDataQueue() + const fullAck = vi.fn() + const tailAck = vi.fn() + charQueue.enqueue( + createChunk('a'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS), 1, { + ptyId: 'pty-1', + ackCredit: fullAck + }) + ) + expect(charQueue.getStorageForTest().retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + charQueue.enqueue(createChunk('tail', 1, { ptyId: 'pty-1', ackCredit: tailAck })) + + const characterBounded = charQueue.takeAll() + expect(fullAck).toHaveBeenCalledOnce() + expect(characterBounded.map((chunk) => chunk.data)).toEqual(['tail']) + expect(characterBounded[0]?.meta).toEqual({ droppedOutput: true }) + expect(tailAck).not.toHaveBeenCalled() + releaseTransferred(characterBounded) + expect(tailAck).toHaveBeenCalledOnce() + }) + + it('keeps the exact oversized tail and preserves metadata under the gap marker', () => { + const queue = new DeferredReattachLiveDataQueue() + const ackCredit = vi.fn() + const meta = { seq: 9, rawLength: MAX_DEFERRED_REATTACH_LIVE_CHARS + 1, background: true } + queue.enqueue( + createChunk(`P${'T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)}`, 3, { + ptyId: 'pty-3', + meta, + ackCredit + }) + ) + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + expect(retained[0]?.data).toBe('T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)) + expect(retained[0]?.meta).toEqual({ ...meta, droppedOutput: true }) + expect(meta).not.toHaveProperty('droppedOutput') + expect(ackCredit).not.toHaveBeenCalled() + releaseTransferred(retained) + expect(ackCredit).toHaveBeenCalledOnce() + }) + + it('releases a replaced generation exactly once without carrying its gap marker forward', () => { + const queue = new DeferredReattachLiveDataQueue() + const firstAck = vi.fn() + const secondAck = vi.fn() + const thirdAck = vi.fn() + const replacementAck = vi.fn() + queue.enqueue(createChunk('a'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: firstAck })) + queue.enqueue(createChunk('b'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: secondAck })) + queue.enqueue(createChunk('c', 4, { ptyId: 'old', ackCredit: thirdAck })) + + expect(firstAck).toHaveBeenCalledOnce() + queue.enqueue( + createChunk('replacement', 5, { + ptyId: 'new', + meta: { seq: 5, transformed: true }, + ackCredit: replacementAck + }) + ) + + const replacement = queue.takeAll() + expect(firstAck).toHaveBeenCalledOnce() + expect(secondAck).toHaveBeenCalledOnce() + expect(thirdAck).toHaveBeenCalledTimes(1) + expect(replacement).toHaveLength(1) + expect(replacement[0]?.data).toBe('replacement') + expect(replacement[0]?.meta).toEqual({ seq: 5, transformed: true }) + expect(replacementAck).not.toHaveBeenCalled() + releaseTransferred(replacement) + expect(replacementAck).toHaveBeenCalledOnce() + }) + + it('compacts a cleared prefix repeatedly while settling every credit once', () => { + const queue = new DeferredReattachLiveDataQueue() + const acks = Array.from({ length: 513 }, () => vi.fn()) + const fullChunk = 'x'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS) + for (let index = 0; index < acks.length; index += 1) { + queue.enqueue( + createChunk(fullChunk, 1, { + ptyId: 'pty-1', + ackCredit: acks[index] + }) + ) + const storage = queue.getStorageForTest() + expect(storage.retainedChunks).toBe(1) + expect(storage.retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + expect(storage.backingLength).toBeLessThanOrEqual(64) + } + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + releaseTransferred(retained) + expect(acks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + head: 0, + retainedChars: 0, + retainedChunks: 0 + }) + }) + + it('transfers credits from takeAll but settles retained credits on discard', () => { + const queue = new DeferredReattachLiveDataQueue() + const transferredAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('first', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[0] })) + queue.enqueue(createChunk('second', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[1] })) + + const transferred = queue.takeAll() + expect(transferred.map((chunk) => chunk.data)).toEqual(['first', 'second']) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + + const discardedAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('third', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[0] })) + queue.enqueue(createChunk('fourth', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[1] })) + queue.discard() + queue.discard() + expect(discardedAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + releaseTransferred(transferred) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new DeferredReattachLiveDataQueue() + const reference = new ReferenceDeferredQueue() + const random = createSeededRandom(0x5eedc0de) + const queueAcks = new Map() + const referenceAcks = new Map() + let generation = 1 + + const recordAck = + (acks: Map, index: number): (() => void) => + () => { + acks.set(index, (acks.get(index) ?? 0) + 1) + } + for (let index = 0; index < 5_000; index += 1) { + const value = random() + if (index > 0 && value % 997 === 0) { + generation += 1 + } + const bucket = value % 100 + const length = + index < 1_500 + ? value % 3 + : bucket < 70 + ? value % 1_025 + : bucket < 90 + ? 4 * 1024 + : bucket < 97 + ? 64 * 1024 + : bucket < 99 + ? 300 * 1024 + : MAX_DEFERRED_REATTACH_LIVE_CHARS + 1 + const data = String.fromCharCode(65 + (index % 26)).repeat(length) + const base = { + data, + ptyId: `pty-${generation % 3}`, + streamGeneration: generation, + meta: { + seq: index, + rawLength: length, + ...(value % 7 === 0 ? { background: true } : {}) + } + } + queue.enqueue({ ...base, ackCredit: recordAck(queueAcks, index) }) + reference.enqueue({ ...base, ackCredit: recordAck(referenceAcks, index) }) + + if ((index + 1) % 1_000 !== 0) { + continue + } + if ((index + 1) % 2_000 === 0) { + queue.discard() + reference.discard() + } else { + const actual = queue.takeAll() + const expected = reference.takeAll() + expect(normalizedChunks(actual)).toEqual(normalizedChunks(expected)) + releaseTransferred(actual) + releaseTransferred(expected) + } + expect(queueAcks).toEqual(referenceAcks) + } + + expect(queueAcks).toEqual(referenceAcks) + expect([...queueAcks.values()].every((count) => count === 1)).toBe(true) + expect(queueAcks.size).toBe(5_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts new file mode 100644 index 00000000000..3045569b1e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts @@ -0,0 +1,111 @@ +import type { PtyDataMeta } from './pty-dispatcher' + +export const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 +export const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 + +const COMPACTION_MIN_HEAD = 64 + +export type DeferredReattachLiveDataChunk = { + data: string + ptyId: string | null + streamGeneration: number + meta?: PtyDataMeta + ackCredit?: () => void +} + +export type DeferredReattachLiveDataQueueStorage = { + backingLength: number + head: number + retainedChars: number + retainedChunks: number +} + +export class DeferredReattachLiveDataQueue { + private chunks: (DeferredReattachLiveDataChunk | undefined)[] = [] + private head = 0 + private retainedChars = 0 + private streamGeneration: number | null = null + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + if (this.streamGeneration !== null && this.streamGeneration !== chunk.streamGeneration) { + this.discard() + } + this.streamGeneration = chunk.streamGeneration + + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const queuedChunk = { + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + } + this.chunks.push(queuedChunk) + this.retainedChars += queuedChunk.data.length + + let dropped = oversized + while ( + this.retainedChunkCount > 1 && + (this.retainedChunkCount > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks[this.head] + this.chunks[this.head] = undefined + this.head += 1 + if (removed) { + this.retainedChars -= removed.data.length + removed.ackCredit?.() + } + dropped = true + } + + const oldest = this.chunks[this.head] + if (dropped && oldest) { + oldest.meta = { ...oldest.meta, droppedOutput: true } + } + this.compact() + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const retained: DeferredReattachLiveDataChunk[] = [] + for (let index = this.head; index < this.chunks.length; index += 1) { + const chunk = this.chunks[index] + if (chunk) { + retained.push(chunk) + } + } + this.reset() + return retained + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } + + getStorageForTest(): DeferredReattachLiveDataQueueStorage { + return { + backingLength: this.chunks.length, + head: this.head, + retainedChars: this.retainedChars, + retainedChunks: this.retainedChunkCount + } + } + + private get retainedChunkCount(): number { + return this.chunks.length - this.head + } + + private compact(): void { + if (this.head < COMPACTION_MIN_HEAD || this.head * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.head) + this.head = 0 + } + + private reset(): void { + this.chunks = [] + this.head = 0 + this.retainedChars = 0 + this.streamGeneration = null + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5fee2bae59a..2ff73043cc3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -8694,6 +8694,211 @@ describe('connectPanePty', () => { ) }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { + // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('drops a live daemon alt frame until a hidden pane has a final grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 1, rows: 1 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'LEGACY-MERGED-SNAPSHOT', + snapshotPrefixAnsi: 'INCOMPLETE-PREFIX', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('LEGACY-MERGED-SNAPSHOT') + expect(writes.join('')).not.toContain('INCOMPLETE-PREFIX') + }) + + it('keeps a daemon alt frame when the pane is not narrower than the capture', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect( + (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]).join('') + ).toContain('ALT-FRAME-BODY') + }) + + it('drops a too-wide daemon alt frame on cold restore and keeps its history', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true, + coldRestore: { scrollback: 'x', cwd: '/tmp' } + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes).toContain(POST_REPLAY_MODE_RESET) + }) + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. const { connectPanePty } = await import('./pty-connection') @@ -11394,11 +11599,12 @@ describe('connectPanePty', () => { expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => { + it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-1') transportFactoryQueue.push(transport) const pane = createPane(1) + const providerSession = { key: 'session_id', id: 'codex-session-1' } as const connectPanePty( pane as never, @@ -11406,6 +11612,7 @@ describe('connectPanePty', () => { createDeps({ startup: { command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession, showSessionRestoredBanner: true } }) as never @@ -11421,7 +11628,11 @@ describe('connectPanePty', () => { expect.stringContaining('--- session restored ---'), expect.any(Function) ) - expect(createdTransportOptions[0]?.command).toBe("codex 'resume' 'codex-session-1'") + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(createdTransportOptions[0]).toMatchObject({ + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession + }) }) it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { @@ -15207,6 +15418,67 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('pty-id') + let onData: ConnectCallbacks['onData'] + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + onData = callbacks.onData + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + getMainBufferSnapshot.mockResolvedValue({ + data: 'TOO-WIDE-ALT-FRAME', + frameRestoreAnsi: '\x1b[?1004h\x1b[?25l', + cols: 120, + rows: 40, + seq: hidden.length + 1, + alternateScreen: true, + scrollbackAnsi: 'preserved history' + }) + const pane = createPane(1) + let paneRect = createRect(0, 0) + pane.container.getBoundingClientRect = vi.fn(() => paneRect) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + onData?.('x', { seq: hidden.length + 1, rawLength: 1 }) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME') + expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l') + expect(transport.resize).not.toHaveBeenCalled() + + xtermContainerDisplay = 'block' + paneRect = createRect(800, 600) + safeFit(pane as never) + await flushAsyncTicks(12) + + const pulseStart = transport.resize.mock.calls.findIndex( + ([cols, rows]) => cols === 119 && rows === 40 + ) + expect(pulseStart).toBeGreaterThanOrEqual(0) + expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40]) + disposable.dispose() + }) + it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -21085,7 +21357,7 @@ describe('connectPanePty', () => { expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) - it('paints a parked SSH model before the remote connection settles', async () => { + it('keeps a too-wide parked SSH alt frame while no live process can repaint it', async () => { const { connectPanePty } = await import('./pty-connection') const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1') const sshConnect = createDeferred() @@ -21094,10 +21366,12 @@ describe('connectPanePty', () => { vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ data: 'PARKED-SSH-PAINTED-WITHOUT-NETWORK\r\n', - cols: 101, + cols: 140, rows: 31, seq: 123, - source: 'headless' + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n' }) await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { @@ -21111,6 +21385,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })) const { writes } = captureCallbackTerminalWrites(pane) const binding = connectPanePty( pane as never, @@ -21124,6 +21399,7 @@ describe('connectPanePty', () => { expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + // No live SSH process can repaint this preconnect frame after a SIGWINCH. expect(writes.join('')).toContain('PARKED-SSH-PAINTED-WITHOUT-NETWORK') expect(transport.connect).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index c48b0b29a91..5ee42f9c784 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -236,6 +236,10 @@ import { createCommandCodeOutputStatusDetector } from '../../../../shared/comman import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS +} from './deferred-reattach-live-data-queue' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { CONPTY_DA1_RESPONSE, @@ -259,7 +263,9 @@ import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-sc import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + readProposedTerminalCols, + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' import { decideSshReattachPaintSource, @@ -3744,21 +3750,10 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: - | { - data: string - ptyId: string | null - streamGeneration: number - meta?: PtyDataMeta - ackCredit?: () => void - }[] - | null = null - let deferredReattachLiveDataChars = 0 + let deferredReattachLiveData: DeferredReattachLiveDataQueue | null = null let reattachLiveDataDeferralDepth = 0 let deferredReattachLiveDataOwners = new Map() let transportStreamGeneration = 0 - const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 - const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() // Why: input must probe a wedged xterm even when the PTY produces no renderer output. @@ -7198,6 +7193,7 @@ export function connectPanePty( async function applyMainBufferSnapshot(snapshot: { data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -7221,6 +7217,7 @@ export function connectPanePty( const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) + let skippedAltFrame = false try { await structuralReplayCoordinator.run( async () => { @@ -7257,7 +7254,15 @@ export function connectPanePty( // Why shared: the SSH reattach model paint inlines the same // choreography (coordinator nesting would deadlock there); one // builder keeps the alt-screen branches from drifting. - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { + skippedAltFrame = + snapshot.alternateScreen === true && + snapshot.frameRestoreAnsi !== undefined && + shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), { + skipIfTargetUnknown: true + }) + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame + })) { writeReplayData(replayChunk) } // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). @@ -7291,7 +7296,14 @@ export function connectPanePty( return } const currentPtyId = transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + if (!currentPtyId) { + return + } + if (getFitOverrideForPty(currentPtyId)) { + safeFit(pane) + if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } return } const fit = safeFitAndThen( @@ -7305,6 +7317,10 @@ export function connectPanePty( ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay + if (skippedAltFrame) { + pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) + return + } if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { transport.resize(pane.terminal.cols, pane.terminal.rows) if (!isRemoteRuntimePtyId(currentPtyId)) { @@ -7313,7 +7329,13 @@ export function connectPanePty( } } }, - { shouldContinue: isCurrentRestore, retryIfUnmeasurable: true } + { + shouldContinue: isCurrentRestore, + retryIfUnmeasurable: true, + // A skipped frame is blank until this fit pushes the final + // grid/SIGWINCH, so carry the continuation through reveal. + deferIfHidden: true + } ) pendingHiddenSnapshotFit = fit try { @@ -7615,47 +7637,14 @@ export function connectPanePty( return } if (deferredReattachLiveData !== null) { - // Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded. - deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => { - const keep = chunk.streamGeneration === streamGeneration - if (!keep) { - chunk.ackCredit?.() - } - return keep - }) - deferredReattachLiveDataChars = deferredReattachLiveData.reduce( - (total, chunk) => total + chunk.data.length, - 0 - ) - const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS - const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data const ackCredit = takeCurrentTerminalDeliveryCredit() - deferredReattachLiveData.push({ - data: deferredData, + deferredReattachLiveData.enqueue({ + data, ptyId: transport.getPtyId(), streamGeneration, ...(meta ? { meta } : {}), ...(ackCredit ? { ackCredit } : {}) }) - deferredReattachLiveDataChars += deferredData.length - // Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame. - let dropped = oversized - while ( - deferredReattachLiveData.length > 1 && - (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) - ) { - const removed = deferredReattachLiveData.shift() - deferredReattachLiveDataChars -= removed?.data.length ?? 0 - removed?.ackCredit?.() - dropped = true - } - if (dropped && deferredReattachLiveData[0]) { - deferredReattachLiveData[0].meta = { - ...deferredReattachLiveData[0].meta, - droppedOutput: true - } - } return } if (data.length > 0) { @@ -7820,8 +7809,7 @@ export function connectPanePty( const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { reattachLiveDataDeferralDepth += 1 if (reattachLiveDataDeferralDepth === 1) { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + deferredReattachLiveData = new DeferredReattachLiveDataQueue() deferredReattachLiveDataOwners = new Map() } if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { @@ -7854,19 +7842,17 @@ export function connectPanePty( if (reattachLiveDataDeferralDepth > 0) { return } - const chunks = deferredReattachLiveData + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 const currentPtyId = transport.getPtyId() const currentGeneration = transportStreamGeneration const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) deferredReattachLiveDataOwners = new Map() - if (disposed || !chunks) { - for (const chunk of chunks ?? []) { - chunk.ackCredit?.() - } + if (disposed || !queue) { + queue?.discard() return } + const chunks = queue.takeAll() // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. let deliveredDeferredChunks = 0 for (const chunk of chunks) { @@ -7989,6 +7975,7 @@ export function connectPanePty( } } kittyKeyboardModes.scanReplay(modelData) + // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { writeReplayData(replayChunk) } @@ -8177,7 +8164,18 @@ export function connectPanePty( return } if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + const snapshotPrefixAnsi = connectResult.snapshotPrefixAnsi + const snapshotFrameAnsi = connectResult.snapshotFrameAnsi + const snapshotFrameRestoreAnsi = connectResult.snapshotFrameRestoreAnsi + const hasSplitDaemonAltFrame = + typeof snapshotPrefixAnsi === 'string' && + snapshotPrefixAnsi.length > 0 && + typeof snapshotFrameAnsi === 'string' && + snapshotFrameAnsi.length > 0 + const daemonSnapshotReplay = hasSplitDaemonAltFrame + ? snapshotPrefixAnsi + snapshotFrameAnsi + : connectResult.snapshot + rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true }) // Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI. const snapshotDimensions = resolvePositiveTerminalDimensions( connectResult.snapshotCols, @@ -8197,12 +8195,26 @@ export function connectPanePty( } writeReplayData('\x1b[2J\x1b[3J\x1b[H') // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone. + kittyKeyboardModes.scanReplay(daemonSnapshotReplay) + // A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint. + // A dead-owner restore keeps history plus its restored-session treatment; + // a frozen foreign-width frame would look live when no owner remains. + const daemonAltFrameSkippable = + hasSplitDaemonAltFrame && + typeof snapshotFrameRestoreAnsi === 'string' && + shouldSkipAltFrameForWidthMismatch( + connectResult.snapshotCols, + readProposedTerminalCols(pane), + { skipIfTargetUnknown: true } + ) + writeReplayData( + daemonAltFrameSkippable + ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi + : daemonSnapshotReplay + ) writeReplayData( reattachReplayResetSequence( - connectResult.snapshot, + daemonSnapshotReplay, Boolean(connectResult.coldRestore), connectResult.isAlternateScreen ) @@ -8256,7 +8268,15 @@ export function connectPanePty( // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips // the ?1049h marker when splitting scrollbackAnsi) — inlined here // because nesting structuralReplayCoordinator would deadlock. - for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { + skipAltFrame: shouldSkipAltFrameForWidthMismatch( + modelCols, + readProposedTerminalCols(pane), + { + skipIfTargetUnknown: true + } + ) + })) { writeReplayData(replayChunk) } writeReplayData( @@ -9258,11 +9278,9 @@ export function connectPanePty( } directSshPaneRetrySettlementTimers.clear() // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. - for (const chunk of deferredReattachLiveData ?? []) { - chunk.ackCredit?.() - } + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 + queue?.discard() reattachLiveDataDeferralDepth = 0 deferredReattachLiveDataOwners = new Map() cancelPendingSafeFitContinuations(pane) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index 45846d08143..fcaf70e0a5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -3,12 +3,16 @@ import { bufferPtyShutdownData, bufferPtyShutdownReplayData, drainRolledBackPtyShutdownData, + isPtyDataHandlerShutdownPending, ptyDataHandlers, + ptyDataSidecars, ptyReplayHandlers, ptyShutdownLifecycleHandlers, ptyTeardownHandlers, unregisterPtyDataHandlers } from './pty-shutdown-data-suspension' +import { bufferPreHandlerPtyData } from './pty-pre-handler-buffer' +import { PtyShutdownOutputQueue } from './pty-shutdown-output-queue' it('does not remove handlers installed by a remount while sleep is pending', () => { const ptyId = 'pty-shutdown-remount' @@ -33,16 +37,45 @@ it('does not remove handlers installed by a remount while sleep is pending', () it('replays rollback output in its original replay and live arrival order', () => { const ptyId = 'pty-shutdown-output-order' const delivered: string[] = [] + const meta = { seq: 9, rawLength: 3, transformed: true } + const sidecar = vi.fn((data: string) => delivered.push(`sidecar:${data}`)) ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([sidecar])) const [snapshot] = unregisterPtyDataHandlers([ptyId]) bufferPtyShutdownReplayData(ptyId, 'old') - bufferPtyShutdownData(ptyId, 'new') + bufferPtyShutdownData(ptyId, 'new', meta) snapshot.rollback() - expect(delivered).toEqual(['replay:old', 'data:new']) + expect(delivered).toEqual(['replay:old', 'data:new', 'sidecar:new']) + expect(sidecar).toHaveBeenCalledOnce() ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) + +it('drains pre-handler data before ordered shutdown output', () => { + const ptyId = 'pty-shutdown-pre-handler-order' + const delivered: string[] = [] + const preHandlerMeta = { seq: 1, rawLength: 3 } + const shutdownMeta = { seq: 2, rawLength: 4, background: true } + const dataHandler = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, dataHandler) + ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([(data) => delivered.push(`sidecar:${data}`)])) + + const [snapshot] = unregisterPtyDataHandlers([ptyId]) + bufferPreHandlerPtyData(ptyId, 'pre', preHandlerMeta) + bufferPtyShutdownReplayData(ptyId, 'old') + bufferPtyShutdownData(ptyId, 'live', shutdownMeta) + snapshot.rollback() + + expect(delivered).toEqual(['data:pre', 'sidecar:pre', 'replay:old', 'data:live', 'sidecar:live']) + expect(dataHandler).toHaveBeenNthCalledWith(1, 'pre', preHandlerMeta) + expect(dataHandler).toHaveBeenNthCalledWith(2, 'live', shutdownMeta) + ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) ptyReplayHandlers.delete(ptyId) }) @@ -50,27 +83,65 @@ it('retains ordered rollback output across detach and another pending shutdown', const ptyId = 'pty-shutdown-detached-overlap' const originalData = vi.fn() const originalReplay = vi.fn() + const drain = vi.spyOn(PtyShutdownOutputQueue.prototype, 'drain') + const encode = vi.spyOn(TextEncoder.prototype, 'encode') ptyDataHandlers.set(ptyId, originalData) ptyReplayHandlers.set(ptyId, originalReplay) const [first] = unregisterPtyDataHandlers([ptyId]) ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) - bufferPtyShutdownData(ptyId, 'live-first') + const meta = { seq: 3, rawLength: 10, transformed: true } + bufferPtyShutdownData(ptyId, 'live-first', meta) bufferPtyShutdownReplayData(ptyId, 'replay-second') first.rollback() + expect(drain).not.toHaveBeenCalled() const [second] = unregisterPtyDataHandlers([ptyId]) + expect(encode).not.toHaveBeenCalled() const delivered: string[] = [] - ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) + const replacementData = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, replacementData) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) drainRolledBackPtyShutdownData(ptyId) expect(delivered).toEqual([]) + expect(drain).not.toHaveBeenCalled() second.rollback() expect(delivered).toEqual(['data:live-first', 'replay:replay-second']) + expect(replacementData).toHaveBeenCalledWith('live-first', meta) + expect(drain).toHaveBeenCalledOnce() + drain.mockRestore() + encode.mockRestore() ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) ptyTeardownHandlers.delete(ptyId) ptyShutdownLifecycleHandlers.delete(ptyId) }) + +it('detaches the delivered batch from a reentrant shutdown queue', () => { + const ptyId = 'pty-shutdown-reentrant-delivery' + const delivered: string[] = [] + let reentrantSnapshot: ReturnType[number] | undefined + ptyDataHandlers.set(ptyId, (data) => { + delivered.push(data) + if (data === 'old-first') { + reentrantSnapshot = unregisterPtyDataHandlers([ptyId])[0] + bufferPtyShutdownData(ptyId, 'new-pending') + } + }) + ptyReplayHandlers.set(ptyId, vi.fn()) + + const [first] = unregisterPtyDataHandlers([ptyId]) + bufferPtyShutdownData(ptyId, 'old-first') + bufferPtyShutdownData(ptyId, 'old-second') + first.rollback() + + expect(delivered).toEqual(['old-first', 'old-second']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(true) + reentrantSnapshot?.rollback() + expect(delivered).toEqual(['old-first', 'old-second', 'new-pending']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(false) + ptyDataHandlers.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..508ea88b2be 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -1,7 +1,6 @@ -import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { clampUtf8Tail } from './pty-eager-buffer-clamp' import { clearPreHandlerPtyState, drainPreHandlerPtyData } from './pty-pre-handler-buffer' import type { PtyDataMeta } from './pty-dispatcher' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() @@ -25,8 +24,7 @@ export type PtyDataHandlerShutdownSnapshot = { type PendingPtyHandlerShutdown = { owners: number committed: boolean - bufferedBytes: number - events: PtyShutdownOutputEvent[] + outputQueue: PtyShutdownOutputQueue dataHandler?: (data: string, meta?: PtyDataMeta) => void replayHandler?: (data: string) => void teardownHandler?: () => void @@ -34,13 +32,8 @@ type PendingPtyHandlerShutdown = { } const pendingPtyHandlerShutdowns = new Map() -type PtyShutdownOutputEvent = - | { kind: 'data'; data: string; meta?: PtyDataMeta } - | { kind: 'replay'; data: string } - -const rolledBackShutdownEvents = new Map() +const rolledBackShutdownEvents = new Map() const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64 -const shutdownBufferTextEncoder = new TextEncoder() /** Suspend delivery until every overlapping shutdown owner commits or rolls back. */ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] { @@ -50,16 +43,12 @@ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutd if (pending) { pending.owners += 1 } else { - const retainedEvents = rolledBackShutdownEvents.get(id) ?? [] + const outputQueue = rolledBackShutdownEvents.get(id) ?? new PtyShutdownOutputQueue() rolledBackShutdownEvents.delete(id) pending = { owners: 1, committed: false, - bufferedBytes: retainedEvents.reduce( - (total, event) => total + shutdownBufferTextEncoder.encode(event.data).byteLength, - 0 - ), - events: retainedEvents, + outputQueue, dataHandler: ptyDataHandlers.get(id), replayHandler: ptyReplayHandlers.get(id), teardownHandler: ptyTeardownHandlers.get(id), @@ -113,17 +102,7 @@ function bufferPtyShutdownOutput(ptyId: string, event: PtyShutdownOutputEvent): if (!pending) { return false } - const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) - pending.events.push({ ...event, data: clamped.data }) - pending.bufferedBytes += clamped.bytes - while ( - pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && - pending.events.length > 1 - ) { - pending.bufferedBytes -= shutdownBufferTextEncoder.encode( - pending.events.shift()?.data ?? '' - ).byteLength - } + pending.outputQueue.enqueue(event) return true } @@ -131,14 +110,14 @@ export function drainRolledBackPtyShutdownData(ptyId: string): void { if (pendingPtyHandlerShutdowns.has(ptyId)) { return } - const events = rolledBackShutdownEvents.get(ptyId) + const outputQueue = rolledBackShutdownEvents.get(ptyId) const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) - if (!events || !dataHandler || !replayHandler) { + if (!outputQueue || !dataHandler || !replayHandler) { return } rolledBackShutdownEvents.delete(ptyId) - deliverShutdownEvents(ptyId, events, dataHandler, replayHandler) + outputQueue.drain((event) => deliverShutdownEvent(ptyId, event, dataHandler, replayHandler)) } function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { @@ -154,6 +133,7 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { pendingPtyHandlerShutdowns.delete(ptyId) if (pending.committed) { rolledBackShutdownEvents.delete(ptyId) + pending.outputQueue.discard() pending.lifecycleHandler?.commit() deleteCapturedHandler(ptyDataHandlers, ptyId, pending.dataHandler) deleteCapturedHandler(ptyReplayHandlers, ptyId, pending.replayHandler) @@ -173,10 +153,12 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) if (dataHandler && replayHandler) { - deliverShutdownEvents(ptyId, pending.events, dataHandler, replayHandler) - } else if (pending.events.length > 0) { + pending.outputQueue.drain((event) => + deliverShutdownEvent(ptyId, event, dataHandler, replayHandler) + ) + } else if (pending.outputQueue.length > 0) { // Why: a hidden pane can detach during the RPC; retain rollback output until both ordered channels reattach. - rolledBackShutdownEvents.set(ptyId, pending.events) + rolledBackShutdownEvents.set(ptyId, pending.outputQueue) while (rolledBackShutdownEvents.size > ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS) { const oldestPtyId = rolledBackShutdownEvents.keys().next().value if (typeof oldestPtyId !== 'string') { @@ -197,20 +179,18 @@ function deleteCapturedHandler( } } -function deliverShutdownEvents( +function deliverShutdownEvent( ptyId: string, - events: readonly PtyShutdownOutputEvent[], + event: PtyShutdownOutputEvent, dataHandler: (data: string, meta?: PtyDataMeta) => void, replayHandler: (data: string) => void ): void { - for (const event of events) { - if (event.kind === 'replay') { - replayHandler(event.data) - continue - } - dataHandler(event.data, event.meta) - for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { - sidecar(event.data) - } + if (event.kind === 'replay') { + replayHandler(event.data) + return + } + dataHandler(event.data, event.meta) + for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { + sidecar(event.data) } } diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts new file mode 100644 index 00000000000..b2d835e4258 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' + +const BYTE_LIMIT = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT +const CHUNK_SIZE = 64 + +class ReferenceShutdownOutputQueue { + private events: PtyShutdownOutputEvent[] = [] + private retainedBytes = 0 + private readonly encoder = new TextEncoder() + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, BYTE_LIMIT) + this.events.push({ ...event, data: clamped.data }) + this.retainedBytes += clamped.bytes + while (this.retainedBytes > BYTE_LIMIT && this.events.length > 1) { + this.retainedBytes -= this.encoder.encode(this.events.shift()?.data ?? '').byteLength + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events = this.events + this.events = [] + this.retainedBytes = 0 + return events + } +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +function createDifferentialData(value: number): string { + const bucket = value % 100 + if (bucket < 8) { + return '' + } + if (bucket < 48) { + return String.fromCharCode(65 + (value % 26)).repeat(value % 513) + } + if (bucket < 70) { + return '界'.repeat(value % 257) + } + if (bucket < 88) { + return '😀'.repeat(value % 129) + } + if (bucket < 96) { + return `\ud800${'x'.repeat(value % 511)}` + } + return String.fromCharCode(65 + (value % 26)).repeat(64 * 1024) +} + +describe('PTY shutdown output queue', () => { + it('does not shift or re-encode retained events while saturated', () => { + const originalShift = Array.prototype.shift + const originalEncode = TextEncoder.prototype.encode + const queue = new PtyShutdownOutputQueue() + + try { + Object.defineProperties(Array.prototype, { + shift: { + configurable: true, + writable: true, + value() { + throw new Error('Array.shift should not run on enqueue') + } + } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { + configurable: true, + writable: true, + value() { + throw new Error('TextEncoder.encode should not run on enqueue') + } + } + }) + for (let index = 0; index < 1_536; index += 1) { + queue.enqueue({ kind: 'data', data: 'x'.repeat(1_024), meta: { seq: index } }) + } + } finally { + Object.defineProperties(Array.prototype, { + shift: { configurable: true, writable: true, value: originalShift } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { configurable: true, writable: true, value: originalEncode } + }) + } + + expect(queue.takeAll()).toHaveLength(512) + }) + + it('keeps exact UTF-8 boundaries, Unicode tails, and metadata identity', () => { + const boundaryQueue = new PtyShutdownOutputQueue() + const emptyMeta = { rawLength: 9, transformed: true, droppedOutput: true } + boundaryQueue.enqueue({ kind: 'replay', data: 'a'.repeat(BYTE_LIMIT) }) + boundaryQueue.enqueue({ kind: 'data', data: '', meta: emptyMeta }) + expect(boundaryQueue.getStorageForTest()).toMatchObject({ + retainedBytes: BYTE_LIMIT, + retainedEvents: 2 + }) + boundaryQueue.enqueue({ kind: 'replay', data: 'b' }) + + const boundary = boundaryQueue.takeAll() + expect(boundary).toEqual([ + { kind: 'data', data: '', meta: emptyMeta }, + { kind: 'replay', data: 'b' } + ]) + expect(boundary[0]?.kind === 'data' ? boundary[0].meta : undefined).toBe(emptyMeta) + + const unicodeQueue = new PtyShutdownOutputQueue() + const emojiTail = '😀'.repeat(BYTE_LIMIT / 4) + const unicodeMeta = { seq: 7, rawLength: emojiTail.length + 1, background: true } + unicodeQueue.enqueue({ kind: 'data', data: `P${emojiTail}`, meta: unicodeMeta }) + const [unicode] = unicodeQueue.takeAll() + expect(unicode?.data).toBe(emojiTail) + expect(unicode?.kind === 'data' ? unicode.meta : undefined).toBe(unicodeMeta) + + const loneSurrogateQueue = new PtyShutdownOutputQueue() + const loneSurrogateTail = `\ud800${'z'.repeat(BYTE_LIMIT - 3)}` + loneSurrogateQueue.enqueue({ kind: 'replay', data: `P${loneSurrogateTail}` }) + expect(loneSurrogateQueue.takeAll()[0]?.data).toBe(loneSurrogateTail) + }) + + it('evicts whole oldest events without merging replay and live boundaries', () => { + const queue = new PtyShutdownOutputQueue() + const meta = { seq: 42, rawLength: BYTE_LIMIT - 3, transformed: true } + queue.enqueue({ kind: 'replay', data: 'old' }) + queue.enqueue({ kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }) + queue.enqueue({ kind: 'replay', data: 'new' }) + + const retained = queue.takeAll() + expect(retained).toEqual([ + { kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }, + { kind: 'replay', data: 'new' } + ]) + expect(retained[0]?.kind === 'data' ? retained[0].meta : undefined).toBe(meta) + }) + + it('retains zero-byte event boundaries without imposing a count cap', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < 4_097; index += 1) { + queue.enqueue({ kind: 'data', data: '', meta: { seq: index, transformed: true } }) + } + + expect(queue.getStorageForTest()).toMatchObject({ + retainedBytes: 0, + retainedEvents: 4_097 + }) + const retained = queue.takeAll() + expect(retained).toHaveLength(4_097) + expect(retained[0]).toEqual({ kind: 'data', data: '', meta: { seq: 0, transformed: true } }) + expect(retained.at(-1)).toEqual({ + kind: 'data', + data: '', + meta: { seq: 4_096, transformed: true } + }) + }) + + it('repeatedly compacts cleared prefixes and resets transferred storage', () => { + const queue = new PtyShutdownOutputQueue() + const fullEvent = '界'.repeat(Math.floor(BYTE_LIMIT / 3)) + for (let index = 0; index < 513; index += 1) { + queue.enqueue({ kind: 'replay', data: fullEvent }) + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(1) + expect(storage.retainedBytes).toBe(BYTE_LIMIT - 2) + expect(storage.backingLength).toBeLessThanOrEqual(CHUNK_SIZE) + expect(storage.byteBackingLength).toBeLessThanOrEqual(CHUNK_SIZE) + } + + expect(queue.takeAll()).toEqual([{ kind: 'replay', data: fullEvent }]) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + byteBackingLength: 0, + chunkBackingLength: 0, + head: 0, + headChunk: 0, + retainedBytes: 0, + retainedEvents: 0 + }) + + queue.enqueue({ kind: 'data', data: 'discarded' }) + queue.discard() + expect(queue.takeAll()).toEqual([]) + }) + + it('releases dead backing storage under release-scale one-byte churn', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < BYTE_LIMIT + CHUNK_SIZE * 3 + 17; index += 1) { + queue.enqueue({ kind: 'replay', data: 'x' }) + } + + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(BYTE_LIMIT) + expect(storage.retainedBytes).toBe(BYTE_LIMIT) + expect(storage.backingLength - storage.retainedEvents).toBeLessThan(CHUNK_SIZE) + expect(storage.byteBackingLength).toBe(0) + expect(storage.head).toBeLessThan(CHUNK_SIZE) + expect(storage.headChunk).toBeLessThan(storage.chunkBackingLength) + }) + + it('repeatedly compacts the consumed chunk cursor', () => { + const queue = new PtyShutdownOutputQueue() + const data = 'x'.repeat(BYTE_LIMIT / CHUNK_SIZE) + for (let index = 0; index < CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + + for (let cycle = 0; cycle < 3; cycle += 1) { + for (let index = 0; index < CHUNK_SIZE * CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + expect(queue.getStorageForTest()).toEqual({ + backingLength: CHUNK_SIZE, + byteBackingLength: 0, + chunkBackingLength: 1, + head: 0, + headChunk: 0, + retainedBytes: BYTE_LIMIT, + retainedEvents: CHUNK_SIZE + }) + } + }) + + it('recounts a returned Unicode event after mutation and re-enqueue', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const unicode: PtyShutdownOutputEvent = { kind: 'replay', data: '界' } + queue.enqueue(unicode) + reference.enqueue(unicode) + const returned = queue.takeAll()[0] + const referenceReturned = reference.takeAll()[0] + if (!returned || !referenceReturned) { + throw new Error('Expected queued events') + } + returned.data = 'x' + referenceReturned.data = 'x' + queue.enqueue(returned) + reference.enqueue(referenceReturned) + const newest: PtyShutdownOutputEvent = { kind: 'data', data: 'y'.repeat(BYTE_LIMIT) } + queue.enqueue(newest) + reference.enqueue(newest) + expect(queue.getStorageForTest().retainedBytes).toBe(BYTE_LIMIT) + const overflow: PtyShutdownOutputEvent = { kind: 'replay', data: 'z' } + queue.enqueue(overflow) + reference.enqueue(overflow) + + expect(queue.takeAll()).toEqual(reference.takeAll()) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const random = createSeededRandom(0x15c0ffee) + + for (let index = 0; index < 5_000; index += 1) { + const value = random() + const data = createDifferentialData(value) + const event: PtyShutdownOutputEvent = + value % 3 === 0 + ? { kind: 'replay', data } + : { + kind: 'data', + data, + meta: { + seq: index, + rawLength: data.length + (value % 5), + transformed: value % 2 === 0, + ...(value % 7 === 0 ? { background: true } : {}), + ...(value % 97 === 0 ? { droppedOutput: true } : {}) + } + } + queue.enqueue(event) + reference.enqueue(event) + + if ((index + 1) % 1_000 === 0) { + expect(queue.takeAll()).toEqual(reference.takeAll()) + } + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts new file mode 100644 index 00000000000..b54b787309f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts @@ -0,0 +1,149 @@ +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import type { PtyDataMeta } from './pty-dispatcher' + +const EVENT_CHUNK_SIZE = 64 +const COMPACTION_MIN_HEAD_CHUNKS = 64 + +export type PtyShutdownOutputEvent = + | { kind: 'data'; data: string; meta?: PtyDataMeta } + | { kind: 'replay'; data: string } + +type PtyShutdownOutputChunk = { + events: (PtyShutdownOutputEvent | undefined)[] + eventBytes?: Uint32Array +} + +export type PtyShutdownOutputQueueStorage = { + backingLength: number + byteBackingLength: number + chunkBackingLength: number + head: number + headChunk: number + retainedBytes: number + retainedEvents: number +} + +export class PtyShutdownOutputQueue { + private chunks: (PtyShutdownOutputChunk | undefined)[] = [] + private headChunk = 0 + private headOffset = 0 + private retainedBytes = 0 + private retainedEvents = 0 + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) + let tail = this.chunks.at(-1) + if (!tail || tail.events.length >= EVENT_CHUNK_SIZE) { + tail = { events: [] } + this.chunks.push(tail) + } + const tailOffset = tail.events.length + tail.events.push({ ...event, data: clamped.data }) + if (clamped.bytes !== clamped.data.length) { + tail.eventBytes ??= new Uint32Array(EVENT_CHUNK_SIZE) + tail.eventBytes[tailOffset] = clamped.bytes + } + this.retainedBytes += clamped.bytes + this.retainedEvents += 1 + + while (this.length > 1 && this.retainedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) { + this.evictOldest() + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events: PtyShutdownOutputEvent[] = [] + this.drain((event) => events.push(event)) + return events + } + + drain(consumer: (event: PtyShutdownOutputEvent) => void): void { + const chunks = this.chunks + const headChunk = this.headChunk + const headOffset = this.headOffset + this.reset() + for (let chunkIndex = headChunk; chunkIndex < chunks.length; chunkIndex += 1) { + const chunk = chunks[chunkIndex] + if (!chunk) { + continue + } + const start = chunkIndex === headChunk ? headOffset : 0 + for (let eventIndex = start; eventIndex < chunk.events.length; eventIndex += 1) { + const event = chunk.events[eventIndex] + if (event) { + consumer(event) + } + } + } + } + + discard(): void { + this.reset() + } + + get length(): number { + return this.retainedEvents + } + + getStorageForTest(): PtyShutdownOutputQueueStorage { + let backingLength = 0 + let byteBackingLength = 0 + for (const chunk of this.chunks) { + if (chunk) { + backingLength += chunk.events.length + byteBackingLength += chunk.eventBytes?.length ?? 0 + } + } + return { + backingLength, + byteBackingLength, + chunkBackingLength: this.chunks.length, + head: this.headOffset, + headChunk: this.headChunk, + retainedBytes: this.retainedBytes, + retainedEvents: this.length + } + } + + private evictOldest(): void { + const chunk = this.chunks[this.headChunk] + if (!chunk) { + throw new Error('Missing PTY shutdown output queue chunk') + } + const event = chunk.events[this.headOffset] + if (!event) { + throw new Error('Missing PTY shutdown output queue event') + } + this.retainedBytes -= chunk.eventBytes?.[this.headOffset] || event.data.length + chunk.events[this.headOffset] = undefined + if (chunk.eventBytes) { + chunk.eventBytes[this.headOffset] = 0 + } + this.headOffset += 1 + this.retainedEvents -= 1 + if (this.headOffset < chunk.events.length) { + return + } + this.chunks[this.headChunk] = undefined + this.headChunk += 1 + this.headOffset = 0 + this.compactChunks() + } + + private compactChunks(): void { + if (this.headChunk < COMPACTION_MIN_HEAD_CHUNKS || this.headChunk * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.headChunk) + this.headChunk = 0 + } + + private reset(): void { + this.chunks = [] + this.headChunk = 0 + this.headOffset = 0 + this.retainedBytes = 0 + this.retainedEvents = 0 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 8c82e7584a1..d0c20224e5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -18,6 +18,8 @@ import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime- export type PtyBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -59,6 +61,12 @@ export type PtyConnectResult = { snapshot?: string snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame. Both fields are absent on older hosts. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string isAlternateScreen?: boolean sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 902652ad082..14ff327146e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1763,7 +1763,7 @@ describe('createIpcPtyTransport', () => { expect(writeMock).not.toHaveBeenCalled() }) - it('preserves snapshot dimensions when reattaching', async () => { + it('preserves snapshot dimensions and split alt-frame strings when reattaching', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-reattach', @@ -1771,7 +1771,10 @@ describe('createIpcPtyTransport', () => { launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, - snapshotRows: 43 + snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state' }) ;(globalThis as { window: typeof window }).window = { @@ -1811,6 +1814,9 @@ describe('createIpcPtyTransport', () => { snapshot: 'snapshot data', snapshotCols: 132, snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state', isAlternateScreen: undefined, coldRestore: undefined, replay: undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f7248ec704d..3198281df9b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -893,6 +893,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra snapshot: spawnResult.snapshot, snapshotCols: spawnResult.snapshotCols, snapshotRows: spawnResult.snapshotRows, + ...(spawnResult.snapshotPrefixAnsi !== undefined + ? { snapshotPrefixAnsi: spawnResult.snapshotPrefixAnsi } + : {}), + ...(spawnResult.snapshotFrameAnsi !== undefined + ? { snapshotFrameAnsi: spawnResult.snapshotFrameAnsi } + : {}), + ...(spawnResult.snapshotFrameRestoreAnsi !== undefined + ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts new file mode 100644 index 00000000000..90825f42980 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' + +function makeState(overrides: Record): AppState { + return { + activeWorktreeId: null, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + removedSshTargetLabels: new Map(), + repos: [], + runtimeStatusByEnvironmentId: new Map(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + sshTargetLabels: new Map(), + sshTargetsHydrated: true, + worktreesByRepo: {}, + ...overrides + } as unknown as AppState +} + +describe('selectTerminalPaneHostState', () => { + it('resolves local and direct SSH workspaces without changing reconnect semantics', () => { + const localState = makeState({ + repos: [{ id: 'repo-local' }], + worktreesByRepo: { + 'repo-local': [{ id: 'wt-local', repoId: 'repo-local' }] + } + }) + + expect(selectTerminalPaneHostState(localState, 'wt-local')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + + const sshState = makeState({ + repos: [{ id: 'repo-ssh', connectionId: 'ssh-a' }], + sshConnectionStates: new Map([ + ['ssh-a', { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + sshTargetLabels: new Map([['ssh-a', 'devbox']]), + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' }] + } + }) + + expect(selectTerminalPaneHostState(sshState, 'wt-ssh')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: null, + sshReconnectStatus: 'connected', + sshReconnectTargetId: 'ssh-a', + sshReconnectTargetLabel: 'devbox', + sshReconnectTargetRemoved: false + }) + }) + + it('reads nested SSH state from the worktree owner runtime', () => { + const state = makeState({ + repos: [ + { + id: 'repo-runtime', + connectionId: 'ssh-nested', + executionHostId: 'runtime:env-a' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }] + ]), + sshStateByEnvironment: new Map([ + [ + 'env-a', + { + connectionStates: new Map([ + [ + 'ssh-nested', + { + targetId: 'ssh-nested', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + targetLabels: new Map([['ssh-nested', 'build box']]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]), + worktreesByRepo: { + 'repo-runtime': [ + { + id: 'wt-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:env-a', + runtimeOwnerEnvironmentId: 'env-a' + } + ] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-runtime')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: 'env-a', + sshReconnectStatus: 'disconnected', + sshReconnectTargetId: 'ssh-nested', + sshReconnectTargetLabel: 'build box', + sshReconnectTargetRemoved: false + }) + }) + + it('keeps runtime-owned SSH plumbing out of reconnect UI', () => { + const state = makeState({ + repos: [{ id: 'repo-ephemeral', connectionId: 'runtime-ssh-vm-a' }], + worktreesByRepo: { + 'repo-ephemeral': [{ id: 'wt-ephemeral', repoId: 'repo-ephemeral' }] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-ephemeral')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts new file mode 100644 index 00000000000..17a7e1a354c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts @@ -0,0 +1,61 @@ +import type { AppState } from '@/store/types' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from '@/store/slices/runtime-environment-ssh' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +export type TerminalPaneHostState = { + nativeChatTranscriptIsLocalReadable: boolean + sshReconnectEnvironmentId: string | null + sshReconnectStatus: ReturnType + sshReconnectTargetId: string | null + sshReconnectTargetLabel: string + sshReconnectTargetRemoved: boolean +} + +export function selectTerminalPaneHostState( + state: AppState, + worktreeId: string +): TerminalPaneHostState { + const connectionId = getConnectionIdFromState(state, worktreeId) + const nativeChatTranscriptIsLocalReadableResult = + isNativeChatTranscriptLocalReadable(connectionId) + const sshReconnectTargetId = + connectionId && !isRuntimeOwnedSshTargetId(connectionId) ? connectionId : null + if (!sshReconnectTargetId) { + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + } + } + const sshReconnectEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId, + sshReconnectStatus: selectRuntimeAwareSshStatus( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetId, + sshReconnectTargetLabel: selectRuntimeAwareSshTargetLabel( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetRemoved: selectRuntimeAwareSshTargetRemoved( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts index 394259d3923..7082f7aff9d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts @@ -1,6 +1,7 @@ import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste' import { TERMINAL_PASTE_CHUNK_MAX_BYTES } from './terminal-paste-limits' import type { TerminalPastePlan } from './terminal-paste-coordinator' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' const TERMINAL_PASTE_ESCAPE_CODE_POINT = 0x1b const TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b @@ -58,7 +59,7 @@ function* iterateTextByUtf8Bytes( : normalizedCodePoint === codePoint ? text.slice(index, index + codeUnitLength) : '\r' - const nextBytes = utf8BytesForCodePoint( + const nextBytes = getUtf8ByteLengthForCodePoint( sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint ) if (chunk && chunkBytes + nextBytes > maxBytes) { @@ -80,16 +81,3 @@ function* iterateTextByUtf8Bytes( yield chunk } } - -function utf8BytesForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index ccae12ee863..9a3aa6621c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' +function writeTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + describe('hasPositiveTerminalDimensions', () => { it('accepts only finite positive numeric pairs', () => { expect(hasPositiveTerminalDimensions(80, 24)).toBe(true) @@ -66,3 +72,104 @@ describe('buildMainModelSnapshotReplayWrites', () => { ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) }) }) + +describe('shouldSkipAltFrameForWidthMismatch', () => { + it('skips only when the snapshot is WIDER than the target', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(129, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(128, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(100, 120)).toBe(false) + }) + + it('never skips when a width is missing or nonsensical', () => { + // Why: an unknown width must not cost the user their restored frame. + expect(shouldSkipAltFrameForWidthMismatch(undefined, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(135, undefined)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(0, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false) + }) + + it('can conservatively skip a live frame until a hidden pane has a final grid', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe( + true + ) + expect( + shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true }) + ).toBe(false) + }) +}) + +describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { + it('keeps normal history and a clean alt grid through the real resize path', async () => { + const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) + const snapshot = { + data: '\x1b[1;1HWIDE-FRAME', + frameRestoreAnsi: '\x1b[?25l', + alternateScreen: true, + scrollbackAnsi: 'log' + } + + try { + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: true })) { + await writeTerminal(terminal, chunk) + } + terminal.resize(4, 5) + + expect(terminal.buffer.active.type).toBe('alternate') + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('') + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('log') + } finally { + terminal.dispose() + } + }) + + it('drops only the frame paint, keeping scrollback and the alt-buffer choreography', () => { + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true, + scrollbackAnsi: 'normal-history' + }, + { skipAltFrame: true } + ) + ).toEqual([ + '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + 'normal-history', + '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + 'complete-live-state' + ]) + }) + + it('still enters a cleared alt screen when skipping without split scrollback', () => { + // Why the clear still runs: the caller's SIGWINCH must land on a clean + // screen the application repaints, not the stale pre-park frame. + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true + }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + }) + + it('keeps composed data when an older producer omits the mode boundary', () => { + expect( + buildMainModelSnapshotReplayWrites( + { data: 'legacy-modes-and-frame', alternateScreen: true }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + }) + + it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { + expect( + buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) + ).toEqual(['\x1b[2J\x1b[3J\x1b[H', 'shell-output']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index b17bdd5435a..643ca0de925 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,3 +1,6 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' + /** * Shared guards and write choreography for painting a main-model snapshot into * a (possibly fresh) xterm. One source for the reattach/hidden-restore paint @@ -28,23 +31,65 @@ export function resolvePositiveTerminalDimensions( : null } +/** + * The column count the post-replay fit will land on. Why not terminal.cols: a + * pane that has not been fitted yet still reads xterm's 80-column default, so + * comparing against it would drop frames whose width actually matches the + * container. Returns undefined when the pane cannot be measured; callers with + * a repaint owner can clear and defer, while offline preconnect paint cannot. + */ +export function readProposedTerminalCols(pane: ManagedPane): number | undefined { + return readProposedPaneFitDimensions(pane)?.cols +} + +export function shouldSkipAltFrameForWidthMismatch( + snapshotCols: number | undefined, + targetCols: number | undefined, + options: { skipIfTargetUnknown?: boolean } = {} +): boolean { + if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) { + return false + } + if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) { + // A hidden pane has no final grid; its live app can repaint after the deferred fit. + return options.skipIfTargetUnknown === true + } + // Fixed-grid alt rows clip at narrower columns; normal history remains reflowable. + return snapshotCols > targetCols +} + /** * Ordered replay writes for a main-model snapshot, including the alt-screen * choreography: main strips the `?1049h` marker when splitting scrollbackAnsi * from an alt frame, so the restorer owns the transition — rebuild the normal * buffer while on it, then paint the alt frame clean. Callers write these * before their post-replay reset/escape-tail sequences. + * + * `skipAltFrame` drops only the frame paint, never the buffer choreography or + * scrollback or mode rehydration: the alt buffer is still entered and cleared + * so the caller's SIGWINCH lands on a clean screen the application repaints. */ -export function buildMainModelSnapshotReplayWrites(snapshot: { - data: string - alternateScreen?: boolean - scrollbackAnsi?: string -}): string[] { +export function buildMainModelSnapshotReplayWrites( + snapshot: { + data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string + alternateScreen?: boolean + scrollbackAnsi?: string + }, + options: { skipAltFrame?: boolean } = {} +): string[] { if (!snapshot.alternateScreen) { // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer // snapshot carries its own history in data (mirrors pty-transport.ts). return ['\x1b[2J\x1b[3J\x1b[H', snapshot.data] } + // Older snapshot producers do not expose the mode/frame boundary. Keep their + // composed data rather than dropping terminal modes together with the frame. + const altFrame = + options.skipAltFrame && snapshot.frameRestoreAnsi !== undefined + ? [snapshot.frameRestoreAnsi] + : [snapshot.data] if (snapshot.scrollbackAnsi !== undefined) { // Why: main serializes normal + alt buffers separately; rebuild normal // while active, then return to a clean alt frame. @@ -52,11 +97,11 @@ export function buildMainModelSnapshotReplayWrites(snapshot: { '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', snapshot.scrollbackAnsi, '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', - snapshot.data + ...altFrame ] } // Why: the snapshot's ?1049h no-ops when already on alt screen and skips // blank cells; clear the alt buffer so the pre-hide frame can't bleed // through blank cells (spares normal-buffer scrollback). - return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', snapshot.data] + return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', ...altFrame] } diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a8747ec81ff..4b7557ba057 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -183,6 +183,57 @@ describe('closeTerminalTab', () => { isWebTerminalSurfaceTabIdMock.mockReturnValue(false) }) + it('retires exact resume authority when explicit cleanup arrives after the tab disappeared', () => { + const closeTab = vi.fn() + const onClosed = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab', { + hostCloseReason: 'cleanup', + localPtyTeardownOwnedExternally: true, + onClosed + }) + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { + reason: 'cleanup', + localPtyTeardownOwnedExternally: true + }) + expect(onClosed).toHaveBeenCalledOnce() + }) + + it('retires exact resume authority when a user close arrives after the tab disappeared', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab') + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { reason: 'user' }) + }) + + it.each([ + ['local PTY exit', { reason: 'pty-exit' as const }], + ['paired host PTY exit', { hostCloseReason: 'pty-exit' as const }] + ])('preserves missing-tab recovery authority for %s', (_label, options) => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('unexpectedly-lost-tab', options) + + expect(closeTab).not.toHaveBeenCalled() + }) + it('delegates host-backed terminal closes to the paired runtime', () => { const closeTab = vi.fn() isWebRuntimeSessionActiveMock.mockReturnValue(true) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 55934e7ae88..8216696d217 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -65,6 +65,19 @@ export function closeTerminalTab( ) const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState) if (!target) { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + if (closeReason !== 'pty-exit') { + // Why: late explicit cleanup must still revoke tab-scoped resume authority after PTY exit removed the row. + state.closeTab(tabId, { + reason: closeReason, + ...(options?.localPtyTeardownOwnedExternally + ? { localPtyTeardownOwnedExternally: true } + : {}), + ...(options?.precomputedRetirementPlan + ? { precomputedRetirementPlan: options.precomputedRetirementPlan } + : {}) + }) + } options?.onClosed?.() return } diff --git a/src/renderer/src/components/ui/checkbox.tsx b/src/renderer/src/components/ui/checkbox.tsx index b5ae01c3da6..2e9e53e55e7 100644 --- a/src/renderer/src/components/ui/checkbox.tsx +++ b/src/renderer/src/components/ui/checkbox.tsx @@ -1,15 +1,20 @@ 'use client' import * as React from 'react' -import { CheckIcon } from 'lucide-react' +import { CheckIcon, MinusIcon } from 'lucide-react' import { Checkbox as CheckboxPrimitive } from 'radix-ui' import { cn } from '@/lib/utils' -function Checkbox({ className, ...props }: React.ComponentProps) { +function Checkbox({ + className, + checked, + ...props +}: React.ComponentProps) { return ( - + {checked === 'indeterminate' ? ( + + ) : ( + + )} ) diff --git a/src/renderer/src/components/ui/team-multi-combobox.test.ts b/src/renderer/src/components/ui/team-multi-combobox.test.ts deleted file mode 100644 index 9f231371c29..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../../shared/types' -import { - TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES, - filterTeamMultiComboboxTeams, - isTeamMultiComboboxQueryTooLarge -} from './team-multi-combobox' - -function team(overrides: Partial): LinearTeam { - return { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspaceId: 'workspace-1', - workspaceName: 'Workspace', - ...overrides - } -} - -describe('filterTeamMultiComboboxTeams', () => { - it('returns all teams for empty queries', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, '')).toBe(teams) - expect(filterTeamMultiComboboxTeams(teams, ' ')).toBe(teams) - }) - - it('matches team names and keys case-insensitively', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, 'ops')).toEqual([teams[1]]) - expect(filterTeamMultiComboboxTeams(teams, 'ENGINEER')).toEqual([teams[0]]) - }) - - it('rejects oversized pasted queries before reading teams', () => { - const oversizedQuery = 'secret-team-combobox'.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES) - const teams = [ - { - id: 'secret', - workspaceId: 'workspace-1', - get name(): string { - throw new Error('oversized team combobox queries must not scan names') - }, - get key(): string { - throw new Error('oversized team combobox queries must not scan keys') - } - } - ] as LinearTeam[] - - expect(isTeamMultiComboboxQueryTooLarge(oversizedQuery)).toBe(true) - expect(filterTeamMultiComboboxTeams(teams, oversizedQuery)).toEqual([]) - }) - - it('rejects oversized whitespace before trimming', () => { - const teams = [team({ id: 'eng' })] - - expect( - filterTeamMultiComboboxTeams(teams, ' '.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES + 1)) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/ui/team-multi-combobox.tsx b/src/renderer/src/components/ui/team-multi-combobox.tsx deleted file mode 100644 index e1795c4801a..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { LinearTeam } from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' - -type TeamMultiComboboxProps = { - teams: LinearTeam[] - selected: ReadonlySet - onChange: (next: ReadonlySet) => void - onSelectAll: () => void - triggerClassName?: string -} - -export const TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES = 2 * 1024 - -export function isTeamMultiComboboxQueryTooLarge( - query: string, - maxBytes = TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterTeamMultiComboboxTeams( - teams: readonly LinearTeam[], - query: string -): readonly LinearTeam[] { - if (isTeamMultiComboboxQueryTooLarge(query)) { - return [] - } - const trimmedQuery = query.trim() - if (!trimmedQuery) { - return teams - } - const lower = trimmedQuery.toLowerCase() - return teams.filter( - (team) => team.name.toLowerCase().includes(lower) || team.key.toLowerCase().includes(lower) - ) -} - -function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet): React.JSX.Element { - if (teams.length === 0) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - if (selected.size === teams.length) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - const selectedTeams = teams.filter((t) => selected.has(t.id)) - const [first, second, ...rest] = selectedTeams - return ( - - {first ? {first.key} : null} - {second ? , {second.key} : null} - {rest.length > 0 ? +{rest.length} : null} - - ) -} - -export default function TeamMultiCombobox({ - teams, - selected, - onChange, - onSelectAll, - triggerClassName -}: TeamMultiComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - - const filteredTeams = useMemo(() => filterTeamMultiComboboxTeams(teams, query), [teams, query]) - - const allSelected = selected.size === teams.length && teams.length > 0 - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) { - setQuery('') - } - }, []) - - const toggle = useCallback( - (teamId: string) => { - const next = new Set(selected) - if (next.has(teamId)) { - if (next.size <= 1) { - return - } - next.delete(teamId) - } else { - next.add(teamId) - } - onChange(next) - }, - [onChange, selected] - ) - - const handleSelectAll = useCallback(() => { - if (allSelected) { - const first = teams[0] - if (!first) { - return - } - onChange(new Set([first.id])) - return - } - onSelectAll() - }, [allSelected, onChange, onSelectAll, teams]) - - return ( - - - - - - - -
- -
- - - {translate( - 'auto.components.ui.team.multi.combobox.de83523bf9', - 'No teams match your search.' - )} - - {filteredTeams.map((team) => { - const isSelected = selected.has(team.id) - const isLastSelected = isSelected && selected.size <= 1 - return ( - toggle(team.id)} - disabled={isLastSelected} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - -
- - {team.name} - - {team.key} - - -
-
- ) - })} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx new file mode 100644 index 00000000000..7887b7978fc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -0,0 +1,332 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { readStoreListenerCount } from '@/store/store-listener-census' +import type { + WorkspaceCleanupCandidate, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import type * as WorkspaceCleanupPresentation from './workspace-cleanup-presentation' +import WorkspaceCleanupDialog from './WorkspaceCleanupDialog' +import { NOW, makeCandidate, makeState } from './workspace-cleanup-presentation-fixtures' + +const contentProbe = vi.hoisted(() => ({ + mounts: vi.fn(), + projections: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + unmounts: vi.fn() +})) + +const toastProbe = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + message: vi.fn(), + success: vi.fn(), + warning: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: toastProbe })) + +vi.mock('./workspace-cleanup-presentation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + filterWorkspaceCleanupCandidates: ( + ...args: Parameters + ) => { + contentProbe.projections() + return actual.filterWorkspaceCleanupCandidates(...args) + }, + getWorkspaceCleanupReviewInfo: ( + ...args: Parameters + ) => { + contentProbe.projections() + return actual.getWorkspaceCleanupReviewInfo(...args) + }, + sortWorkspaceCleanupCandidates: ( + ...args: Parameters + ) => { + contentProbe.projections() + return actual.sortWorkspaceCleanupCandidates(...args) + } + } +}) + +vi.mock('@/components/ui/dialog', async () => { + const React = await import('react') + const Passthrough = ({ children }: { children: React.ReactNode }) => <>{children} + const DialogContent = () => { + contentProbe.renders() + React.useEffect(() => { + contentProbe.mounts() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unmounts() + unsubscribe() + } + }, []) + // Why: render while closed so the marker measures Orca's content gate, not Radix's portal gate. + return
+ } + return { + Dialog: Passthrough, + DialogContent, + DialogDescription: Passthrough, + DialogFooter: Passthrough, + DialogHeader: Passthrough, + DialogTitle: Passthrough + } +}) + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +const initialAppState = useAppStore.getInitialState() +const fixtureState = makeState() +const initialCandidate = makeCandidate() +const initialScan = makeScan(1, initialCandidate) +let testContainer: HTMLDivElement +let testRoot: Root + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function makeScan(index: number, candidate: WorkspaceCleanupCandidate): WorkspaceCleanupScanResult { + return { + scannedAt: NOW + index, + candidates: [candidate], + errors: [] + } +} + +function makeChurnCandidate(index: number): WorkspaceCleanupCandidate { + return makeCandidate({ + worktreeId: `repo-1::/repo/background-${index}`, + displayName: `background-${index}`, + path: `/repo/background-${index}`, + fingerprint: `background-${index}` + }) +} + +function activeContentSubscriptions(): number { + return contentProbe.mounts.mock.calls.length - contentProbe.unmounts.mock.calls.length +} + +function requireStoreListenerCount(): number { + const count = readStoreListenerCount() + expect(count).not.toBeNull() + return count ?? 0 +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderDialog(): Promise { + await act(async () => testRoot.render()) + await flushEffects() +} + +async function openDialog(): Promise { + await act(async () => useAppStore.getState().openModal('workspace-cleanup')) + await flushEffects() +} + +async function closeDialog(): Promise { + await act(async () => useAppStore.getState().closeModal()) + await flushEffects() +} + +async function publishScanProgress(index: number): Promise { + const scan = makeScan(index, makeChurnCandidate(index)) + await act(async () => { + useAppStore.setState({ + workspaceCleanupScan: scan, + workspaceCleanupProgress: { + ...scan, + scanId: `scan-${index}`, + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + } + }) + }) +} + +async function publishReviewChurn(index: number): Promise { + await act(async () => { + useAppStore.setState({ + hostedReviewCache: { + [`background-${index}`]: { data: null, fetchedAt: NOW + index } + } + }) + }) +} + +async function publishDeleteChurn(index: number): Promise { + const candidate = makeChurnCandidate(index) + await act(async () => { + useAppStore.setState({ + deleteStateByWorktreeId: { + [candidate.worktreeId]: { + isDeleting: true, + phase: 'queued', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + } + }) + }) +} + +function seedStore( + scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] = vi.fn(async () => initialScan) +): void { + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + activeModal: 'none', + repos: fixtureState.repos, + worktreesByRepo: fixtureState.worktreesByRepo, + hostedReviewCache: {}, + deleteStateByWorktreeId: {}, + workspaceCleanupScan: initialScan, + workspaceCleanupProgress: null, + workspaceCleanupLoading: false, + workspaceCleanupError: null, + scanWorkspaceCleanup + }) +} + +describe('WorkspaceCleanupDialog mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + Object.values(contentProbe).forEach((probe) => probe.mockClear()) + Object.values(toastProbe).forEach((probe) => probe.mockClear()) + seedStore() + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('keeps heavy content through 299 ms, then drops its projections and subscriptions', async () => { + const listenerBaseline = requireStoreListenerCount() + await renderDialog() + const shellListenerCount = requireStoreListenerCount() + + expect(shellListenerCount).toBe(listenerBaseline + 1) + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + + await openDialog() + const openListenerCount = requireStoreListenerCount() + + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + expect(openListenerCount).toBeGreaterThan(shellListenerCount) + + await closeDialog() + await act(async () => vi.advanceTimersByTimeAsync(299)) + + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + const lingeringProjectionCount = contentProbe.projections.mock.calls.length + const lingeringNotificationCount = contentProbe.storeNotifications.mock.calls.length + await publishScanProgress(2) + await publishReviewChurn(2) + await publishDeleteChurn(2) + + expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(lingeringProjectionCount) + expect(contentProbe.storeNotifications.mock.calls.length).toBeGreaterThan( + lingeringNotificationCount + ) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + expect(requireStoreListenerCount()).toBe(shellListenerCount) + + const hiddenProjectionCount = contentProbe.projections.mock.calls.length + const hiddenRenderCount = contentProbe.renders.mock.calls.length + const hiddenNotificationCount = contentProbe.storeNotifications.mock.calls.length + for (let index = 3; index < 103; index += 1) { + await publishScanProgress(index) + await publishReviewChurn(index) + await publishDeleteChurn(index) + } + + expect(contentProbe.projections).toHaveBeenCalledTimes(hiddenProjectionCount) + expect(contentProbe.renders).toHaveBeenCalledTimes(hiddenRenderCount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(hiddenNotificationCount) + }) + + it('cancels the pending content unmount when reopened during the linger', async () => { + await renderDialog() + await openDialog() + await closeDialog() + await act(async () => vi.advanceTimersByTimeAsync(299)) + await openDialog() + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + const projectionCount = contentProbe.projections.mock.calls.length + await publishScanProgress(4) + expect(contentProbe.projections.mock.calls.length).toBeGreaterThan(projectionCount) + }) + + it('toasts when an open-time scan settles after the closed content unmounts', async () => { + const pendingScan = deferred() + const scanWorkspaceCleanup = vi.fn(() => pendingScan.promise) + seedStore(scanWorkspaceCleanup) + await renderDialog() + await openDialog() + + expect(scanWorkspaceCleanup).toHaveBeenCalledTimes(1) + + await closeDialog() + await act(async () => vi.advanceTimersByTimeAsync(300)) + expect(testContainer.querySelector('[data-workspace-cleanup-heavy-content]')).toBeNull() + + const completedScan = makeScan(5, makeChurnCandidate(5)) + await act(async () => { + pendingScan.resolve(completedScan) + await pendingScan.promise + await Promise.resolve() + }) + await flushEffects() + + expect(toastProbe.success).toHaveBeenCalledWith( + 'Inactive workspace scan ready', + expect.objectContaining({ + action: expect.objectContaining({ label: 'Review' }) + }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 0c870ee4485..586618d521b 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: the cleanup dialog keeps scan status, filters, row actions, localized review copy, and force-aware confirmation in one modal flow. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { AlertTriangle, Info, @@ -12,7 +12,6 @@ import { Trash2, X } from 'lucide-react' -import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { Dialog, @@ -41,7 +40,6 @@ import { Progress } from '@/components/ui/progress' import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -65,10 +63,7 @@ import { type WorkspaceCleanupSortKey, type WorkspaceCleanupTimeFilter } from './workspace-cleanup-presentation' -import { - startWorkspaceCleanupBackgroundRemoval, - type WorkspaceCleanupRemovalProgress -} from './workspace-cleanup-background-removal' +import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { countEstimatedInactiveWorkspaces } from './inactive-workspace-estimate' import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row' import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' @@ -85,16 +80,14 @@ import { type WorkspaceCleanupView, type WorkspaceCleanupViewCounts } from './workspace-cleanup-view-selection' -import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' +import { + DEFAULT_WORKSPACE_CLEANUP_FILTERS, + useWorkspaceCleanupDialogSession, + type WorkspaceCleanupDialogSession +} from './workspace-cleanup-dialog-session' import { translate } from '@/i18n/i18n' -const DEFAULT_FILTERS: WorkspaceCleanupFilters = { - query: '', - time: 'all', - review: 'all', - git: 'all', - context: 'all' -} +const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300 const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { hasReview: false, @@ -176,13 +169,67 @@ function formatScanErrorReason(message: string | undefined): string { return message.replace(/\.$/, '') } -export default function WorkspaceCleanupDialog(): React.JSX.Element { - const activeModal = useAppStore((s) => s.activeModal) - const openModal = useAppStore((s) => s.openModal) - const closeModal = useAppStore((s) => s.closeModal) +export default function WorkspaceCleanupDialog(): React.JSX.Element | null { + // Why: scans and removals outlive the modal; only the subscribed projection tree may unmount. + const session = useWorkspaceCleanupDialogSession() + const [lingering, setLingering] = useState(session.open) + useEffect(() => { + if (session.open) { + setLingering(true) + return + } + const timer = window.setTimeout(() => setLingering(false), WORKSPACE_CLEANUP_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [session.open]) + + if (!session.open && !lingering) { + return null + } + return +} + +function WorkspaceCleanupDialogContent({ + session +}: { + session: WorkspaceCleanupDialogSession +}): React.JSX.Element { + const { + open, + loading, + selectedIds, + setSelectedIds, + expandedRowIds, + setExpandedRowIds, + activeView, + setActiveView, + confirming, + confirmCandidates, + removalProgress, + removalInFlight, + rowFailures, + repoSelection, + setRepoSelection, + filters, + setFilters, + sortKey, + setSortKey, + sortDirection, + setSortDirection, + selectedDefaultsScanAtRef, + removalInFlightRef, + close: closeModal, + markCandidateViewed, + restoreDismissals: resetDismissals, + startScan: startWorkspaceCleanupScan, + ignoreCandidate, + applyScanDefaults, + openConfirmRemove, + cancelConfirmRemove, + backToWorkspaceCleanupList, + confirmRemove + } = session const scan = useAppStore((s) => s.workspaceCleanupScan) const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) - const loading = useAppStore((s) => s.workspaceCleanupLoading) const error = useAppStore((s) => s.workspaceCleanupError) const repos = useAppStore((s) => s.repos) const reviewStateInputs = useAppStore( @@ -193,13 +240,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { settings: s.settings })) ) - const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup) - const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) - const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) - const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) - const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) - const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) - const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) const deletionPhaseByWorktreeId = useAppStore( useShallow((s) => { const phases: Record = {} @@ -216,127 +256,16 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [deletionPhaseByWorktreeId] ) - const open = activeModal === 'workspace-cleanup' - const openRef = useRef(open) - const [selectedIds, setSelectedIds] = useState>(() => new Set()) - const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) - const [activeView, setActiveView] = useState('ready') - const [confirming, setConfirming] = useState(false) - const [confirmCandidates, setConfirmCandidates] = useState([]) - const [removalProgress, setRemovalProgress] = useState( - null - ) - // Why: `removalProgress` only arrives once the batch reports, so rendering - // needs its own in-flight flag; removalInFlightRef stays the synchronous guard. - const [removalInFlight, setRemovalInFlight] = useState(false) - const [rowFailures, setRowFailures] = useState>({}) - const [repoSelection, setRepoSelection] = useState>(() => new Set()) - const [filters, setFilters] = useState(DEFAULT_FILTERS) - const [sortKey, setSortKey] = useState('activity') - const [sortDirection, setSortDirection] = useState('asc') - const selectedDefaultsScanAtRef = useRef(null) - const autoScanAttemptedForOpenRef = useRef(false) - const latestReadyToastScanAtRef = useRef(null) - const wasOpenRef = useRef(false) - const removalInFlightRef = useRef(false) - // Why: the dialog stays mounted across cleanup runs, so late settlements from - // an earlier batch must not mutate a newer batch's row/selection state. - const removalBatchIdRef = useRef(0) - const mountedRef = useMountedRef() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) - useEffect(() => { - openRef.current = open - }, [open]) - - const startWorkspaceCleanupScan = useCallback( - (options: { notifyWhenReady?: boolean } = {}) => { - setRowFailures({}) - void scanWorkspaceCleanup() - .then((result) => { - if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { - return - } - if (latestReadyToastScanAtRef.current === result.scannedAt) { - return - } - latestReadyToastScanAtRef.current = result.scannedAt - const suggestedCount = result.candidates.filter( - (candidate) => candidate.selectedByDefault - ).length - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', - 'Inactive workspace scan ready' - ), - { - description: formatWorkspaceCleanupReadyToastDescription( - result.candidates.length, - suggestedCount - ), - action: { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', - 'Review' - ), - onClick: () => openModal('workspace-cleanup') - } - } - ) - }) - .catch((err: unknown) => { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', - 'Workspace cleanup scan failed' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } - }) - }, - [mountedRef, openModal, scanWorkspaceCleanup] - ) - - useEffect(() => { - if (!open) { - wasOpenRef.current = false - autoScanAttemptedForOpenRef.current = false - return - } - if (!wasOpenRef.current) { - wasOpenRef.current = true - autoScanAttemptedForOpenRef.current = false - if (!removalInFlightRef.current) { - setActiveView('ready') - setConfirming(false) - setRowFailures({}) - setFilters(DEFAULT_FILTERS) - setSortKey('activity') - setSortDirection('asc') - setSelectedIds(new Set()) - } - } - // Why: reopening mid-batch keeps the deletion progress view; a broad scan - // started here would be discarded by the removal's scan invalidation, so - // skip it while a removal batch is running (matches the reset guard above). - if (!loading && !autoScanAttemptedForOpenRef.current && !removalInFlightRef.current) { - autoScanAttemptedForOpenRef.current = true - startWorkspaceCleanupScan({ notifyWhenReady: true }) - } - }, [loading, open, startWorkspaceCleanupScan]) - useEffect(() => { if (!open) { return } setRepoSelection(new Set(eligibleRepoIds)) - }, [eligibleRepoIds, open]) + }, [eligibleRepoIds, open, setRepoSelection]) const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates]) const reviewInfoByWorktreeId = useMemo(() => { @@ -400,13 +329,8 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { return } selectedDefaultsScanAtRef.current = scan.scannedAt - if (removalInFlightRef.current) { - return - } - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) - setConfirming(false) - setRowFailures({}) - }, [deletingWorktreeIds, loading, scan]) + applyScanDefaults(scan.candidates, deletingWorktreeIds) + }, [applyScanDefaults, deletingWorktreeIds, loading, scan, selectedDefaultsScanAtRef]) const visibleCandidates = useMemo(() => { const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) @@ -501,7 +425,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, deletingWorktreeIds, open]) + }, [activeRowIds, confirming, deletingWorktreeIds, open, setSelectedIds]) const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -516,54 +440,19 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { startWorkspaceCleanupScan({ notifyWhenReady: true }) }, [startWorkspaceCleanupScan]) - const ignoreCandidate = useCallback( - (candidate: WorkspaceCleanupCandidate) => { - void dismissCandidates([candidate]) - .then(() => { - if (mountedRef.current) { - setSelectedIds((current) => { - const next = new Set(current) - next.delete(candidate.worktreeId) - return next - }) - } - }) - .catch((err: unknown) => { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', - 'Could not ignore cleanup suggestion' - ), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } - }) + const toggleExpandedRow = useCallback( + (worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) }, - [dismissCandidates, mountedRef] + [setExpandedRowIds] ) - const toggleExpandedRow = useCallback((worktreeId: string) => { - setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) - }, []) - - const toggleSelectedRow = useCallback((worktreeId: string) => { - setSelectedIds((current) => toggleSetMember(current, worktreeId)) - }, []) - - const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { - const nextCandidates = filterWorkspaceCleanupRemovalCandidates( - candidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (nextCandidates.length === 0) { - return - } - setConfirmCandidates(nextCandidates) - setConfirming(true) - }, []) + const toggleSelectedRow = useCallback( + (worktreeId: string) => { + setSelectedIds((current) => toggleSetMember(current, worktreeId)) + }, + [setSelectedIds] + ) // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow // instances from re-rendering on scan stream-in and selection changes. @@ -575,7 +464,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setSelectedIds(new Set([candidate.worktreeId])) openConfirmRemove([candidate]) }, - [loading, openConfirmRemove] + [loading, openConfirmRemove, removalInFlightRef, setSelectedIds] ) const handleViewCandidate = useCallback( @@ -587,143 +476,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [closeModal, markCandidateViewed] ) - const cancelConfirmRemove = useCallback(() => { - if (removalProgress) { - closeModal() - return - } - setConfirming(false) - setConfirmCandidates([]) - }, [closeModal, removalProgress]) - - // Why: the header X reads as "leave this screen", not "abandon the dialog". The batch - // keeps running in the background either way, and the list shows each row's progress. - // Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here - // removalProgress stays set until the batch settles, so re-entry is still blocked. - const backToWorkspaceCleanupList = useCallback(() => { - setConfirming(false) - setConfirmCandidates([]) - }, []) - - const clearQueuedDeleteState = useCallback( - (worktreeId: string) => { - const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] - // Why: candidates that fail before removal starts would otherwise stay - // marked "Queued for deletion" in the sidebar; rows already in the - // 'deleting' phase or failed with an error keep their own state. - if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { - clearWorktreeDeleteState(worktreeId) - } - }, - [clearWorktreeDeleteState] - ) - - const deselectRemovedIds = useCallback((removedIds: readonly string[]) => { - if (removedIds.length === 0) { - return - } - setSelectedIds((current) => { - const next = new Set(current) - for (const id of removedIds) { - next.delete(id) - } - return next - }) - }, []) - - const confirmRemove = useCallback(() => { - if (confirmCandidates.length === 0 || removalInFlightRef.current) { - return - } - const removableCandidates = filterWorkspaceCleanupRemovalCandidates( - confirmCandidates, - useAppStore.getState().deleteStateByWorktreeId - ) - if (removableCandidates.length === 0) { - setConfirming(false) - setConfirmCandidates([]) - return - } - removalInFlightRef.current = true - setRemovalInFlight(true) - removalBatchIdRef.current += 1 - const removalBatchId = removalBatchIdRef.current - // Why: a hung late settlement retains these callbacks for the renderer's - // lifetime; capture only ids so it cannot pin the candidate objects. - const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) - setRowFailures({}) - markWorktreesQueuedForDeletion(removableWorktreeIds) - startWorkspaceCleanupBackgroundRemoval({ - candidates: removableCandidates, - removeCandidates, - onProgress: (progress) => { - if (mountedRef.current) { - setRemovalProgress(progress) - } - }, - onRowFailed: (failure) => { - clearQueuedDeleteState(failure.worktreeId) - }, - onResult: (result) => { - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - clearQueuedDeleteState(failure.worktreeId) - } - if (mountedRef.current) { - setRowFailures(nextFailures) - deselectRemovedIds(result.removedIds) - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - }, - onLateResult: (result) => { - for (const failure of result.failures) { - // Why: a late failure can come from a hung preflight whose row never - // reached 'deleting'; clear its queued overlay like every other path. - clearQueuedDeleteState(failure.worktreeId) - } - if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { - return - } - setRowFailures((current) => { - const next = { ...current } - for (const id of result.removedIds) { - delete next[id] - } - for (const failure of result.failures) { - next[failure.worktreeId] = failure.message - } - return next - }) - deselectRemovedIds(result.removedIds) - }, - onError: () => { - for (const worktreeId of removableWorktreeIds) { - clearWorktreeDeleteState(worktreeId) - } - if (mountedRef.current) { - setRemovalProgress(null) - setRemovalInFlight(false) - setConfirming(false) - setConfirmCandidates([]) - } - removalInFlightRef.current = false - } - }) - }, [ - clearQueuedDeleteState, - clearWorktreeDeleteState, - confirmCandidates, - deselectRemovedIds, - markWorktreesQueuedForDeletion, - mountedRef, - removeCandidates - ]) - const selectedCount = selectedCandidates.length return ( @@ -961,7 +713,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e94b1f8bb4', 'Clear filters' )} - onAction={() => setFilters(DEFAULT_FILTERS)} + onAction={() => setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS)} /> ) : null} {!loading && @@ -1551,31 +1303,6 @@ function hasActiveWorkspaceCleanupPanelControls( ) } -function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[], - deletingWorktreeIds: ReadonlySet = new Set() -): Set { - return new Set( - candidates - .filter( - (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) - ) - .map((candidate) => candidate.worktreeId) - ) -} - -function formatWorkspaceCleanupReadyToastDescription( - inactiveCount: number, - suggestedCount: number -): string { - if (inactiveCount === 0) { - return 'No inactive workspaces found.' - } - const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' - const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' - return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` -} - function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { const deletedText = translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index 9b785d3ed71..2503a9a68fc 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -14,6 +14,12 @@ vi.mock('sonner', () => ({ } })) +vi.mock('../sidebar/preserved-branch-batch-toast', () => ({ + showPreservedBranchBatchToast: vi.fn() +})) + +import { showPreservedBranchBatchToast } from '../sidebar/preserved-branch-batch-toast' + async function settleBackgroundRemoval(): Promise { for (let index = 0; index < 10; index += 1) { await Promise.resolve() @@ -131,6 +137,54 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) }) + it('reports all preserved branches in one cleanup result', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const firstBranch = { + worktreeId: first.worktreeId, + branchName: 'feature/alpha', + expectedHead: 'alpha-head' + } + const secondBranch = { + worktreeId: second.worktreeId, + branchName: 'feature/beta', + expectedHead: 'beta-head' + } + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [first.worktreeId], + failures: [], + preservedBranches: [firstBranch] + }) + .mockResolvedValueOnce({ + removedIds: [second.worktreeId], + failures: [], + preservedBranches: [secondBranch] + }), + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(onResult).toHaveBeenCalledWith({ + removedIds: [first.worktreeId, second.worktreeId], + failures: [], + preservedBranches: [firstBranch, secondBranch] + }) + expect(showPreservedBranchBatchToast).toHaveBeenCalledWith(2, [firstBranch, secondBranch]) + expect(toast.success).not.toHaveBeenCalledWith('Removed workspaces: 2') + }) + it('removes nested candidates before their parent workspace', async () => { const parent = makeCandidate({ worktreeId: 'repo-1::/repo/parent', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index d9a04bf8af7..4c686fb28c7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -75,6 +75,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const count = candidates.length const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: NonNullable = [] const failedCandidates: WorkspaceCleanupCandidate[] = [] const lateSettlementTrackers: WorkspaceCleanupLateSettlementTracker[] = [] // Why: rows past the removal deadline are still removing; their skip fallout @@ -200,6 +201,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ pendingSettlementFailures.delete(timeoutFailure) provisionallyBlocked.delete(candidate) removedIds.push(...lateResult.removedIds) + preservedBranches.push(...(lateResult.preservedBranches ?? [])) reportFailures(lateResult.failures) if (lateResult.failures.length === 0) { removeArrayEntry(failedCandidates, candidate) @@ -212,6 +214,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ } const result = outcome.result removedIds.push(...result.removedIds) + preservedBranches.push(...(result.preservedBranches ?? [])) reportFailures(result.failures) if (result.failures.length > 0) { failedCandidates.push(candidate) @@ -244,7 +247,11 @@ export function startWorkspaceCleanupBackgroundRemoval({ } }) ) - const result = { removedIds, failures } + const result: WorkspaceCleanupRemoveResult = { + removedIds, + failures, + ...(preservedBranches.length > 0 ? { preservedBranches } : {}) + } try { onResult?.(result) } catch (callbackError) { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx new file mode 100644 index 00000000000..ae35cbc9b6d --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { useAppStore } from '@/store' +import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' +import { + useWorkspaceCleanupDialogSession, + type WorkspaceCleanupDialogSession +} from './workspace-cleanup-dialog-session' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: toastMocks })) + +const initialAppState = useAppStore.getInitialState() +let container: HTMLDivElement +let root: Root +let currentSession: WorkspaceCleanupDialogSession | null + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function Probe(): null { + currentSession = useWorkspaceCleanupDialogSession() + return null +} + +function session(): WorkspaceCleanupDialogSession { + if (!currentSession) { + throw new Error('Workspace cleanup session probe is not mounted') + } + return currentSession +} + +async function renderProbe(): Promise { + await act(async () => root.render()) +} + +describe('useWorkspaceCleanupDialogSession', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + activeModal: 'workspace-cleanup', + workspaceCleanupLoading: false, + deleteStateByWorktreeId: {} + }) + currentSession = null + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + currentSession = null + useAppStore.setState(initialAppState, true) + }) + + it('offers to reopen when a scan finishes after the dialog closes', async () => { + const candidate = makeCandidate() + const scan = deferred() + const scanWorkspaceCleanup = vi.fn(() => scan.promise) + useAppStore.setState({ scanWorkspaceCleanup }) + + await renderProbe() + expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() + + await act(async () => useAppStore.getState().closeModal()) + expect(session().open).toBe(false) + + await act(async () => { + scan.resolve({ scannedAt: 42, candidates: [candidate], errors: [] }) + await scan.promise + }) + + expect(toastMocks.success).toHaveBeenCalledWith( + 'Inactive workspace scan ready', + expect.objectContaining({ + description: '1 inactive workspace found, with 1 cleanup suggestion.', + action: expect.objectContaining({ label: 'Review' }) + }) + ) + const options = toastMocks.success.mock.lastCall?.[1] as { + action?: { onClick: () => void } + } + + await act(async () => options.action?.onClick()) + + expect(useAppStore.getState().activeModal).toBe('workspace-cleanup') + expect(session().open).toBe(true) + }) + + it('keeps a deferred removal alive and visible across close and reopen', async () => { + const candidate = makeCandidate() + const removal = deferred() + const removeWorkspaceCleanupCandidates = vi.fn(() => removal.promise) + const scanWorkspaceCleanup = vi.fn().mockResolvedValue({ + scannedAt: 41, + candidates: [candidate], + errors: [] + } satisfies WorkspaceCleanupScanResult) + useAppStore.setState({ removeWorkspaceCleanupCandidates, scanWorkspaceCleanup }) + await renderProbe() + + await act(async () => session().openConfirmRemove([candidate])) + await act(async () => session().confirmRemove()) + + expect(removeWorkspaceCleanupCandidates).toHaveBeenCalledWith([candidate.worktreeId], { + approvedCandidates: [candidate] + }) + expect(session()).toMatchObject({ + open: true, + confirming: true, + removalInFlight: true, + removalProgress: { + totalCount: 1, + processedCount: 0, + removedCount: 0, + failedCount: 0 + } + }) + + await act(async () => session().close()) + + expect(session()).toMatchObject({ + open: false, + confirming: true, + removalInFlight: true + }) + expect(session().removalProgress?.processedCount).toBe(0) + + await act(async () => useAppStore.getState().openModal('workspace-cleanup')) + + expect(session()).toMatchObject({ + open: true, + confirming: true, + removalInFlight: true + }) + expect(session().removalProgress?.processedCount).toBe(0) + expect(scanWorkspaceCleanup).toHaveBeenCalledOnce() + + await act(async () => { + removal.resolve({ removedIds: [candidate.worktreeId], failures: [] }) + await removal.promise + await Promise.resolve() + }) + + expect(session()).toMatchObject({ + open: true, + confirming: false, + confirmCandidates: [], + removalInFlight: false, + removalProgress: null + }) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts new file mode 100644 index 00000000000..670e73fa4dc --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-session.ts @@ -0,0 +1,205 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' +import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + useWorkspaceCleanupRemovalSession, + type WorkspaceCleanupRemovalSession +} from './workspace-cleanup-removal-session' +import { useWorkspaceCleanupScanSession } from './workspace-cleanup-scan-session' +import type { + WorkspaceCleanupFilters, + WorkspaceCleanupSortDirection, + WorkspaceCleanupSortKey +} from './workspace-cleanup-presentation' +import type { WorkspaceCleanupView } from './workspace-cleanup-view-selection' + +export const DEFAULT_WORKSPACE_CLEANUP_FILTERS: WorkspaceCleanupFilters = { + query: '', + time: 'all', + review: 'all', + git: 'all', + context: 'all' +} + +type WorkspaceCleanupStateSetter = Dispatch> +type PublicRemovalSession = Omit< + WorkspaceCleanupRemovalSession, + 'clearRowFailures' | 'resetForOpen' +> + +export type WorkspaceCleanupDialogSession = PublicRemovalSession & { + open: boolean + loading: boolean + selectedIds: Set + setSelectedIds: WorkspaceCleanupStateSetter> + expandedRowIds: Set + setExpandedRowIds: WorkspaceCleanupStateSetter> + activeView: WorkspaceCleanupView + setActiveView: WorkspaceCleanupStateSetter + repoSelection: ReadonlySet + setRepoSelection: WorkspaceCleanupStateSetter> + filters: WorkspaceCleanupFilters + setFilters: WorkspaceCleanupStateSetter + sortKey: WorkspaceCleanupSortKey + setSortKey: WorkspaceCleanupStateSetter + sortDirection: WorkspaceCleanupSortDirection + setSortDirection: WorkspaceCleanupStateSetter + selectedDefaultsScanAtRef: RefObject + close: () => void + markCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void + restoreDismissals: () => void + startScan: (options?: { notifyWhenReady?: boolean }) => void + ignoreCandidate: (candidate: WorkspaceCleanupCandidate) => void +} + +export function useWorkspaceCleanupDialogSession(): WorkspaceCleanupDialogSession { + const { + open, + loading, + openModal, + closeModal, + scanWorkspaceCleanup, + markCandidateViewed, + dismissCandidates, + resetDismissals, + removeCandidates, + markWorktreesQueuedForDeletion, + clearWorktreeDeleteState + } = useAppStore( + useShallow((state) => ({ + open: state.activeModal === 'workspace-cleanup', + loading: state.workspaceCleanupLoading, + openModal: state.openModal, + closeModal: state.closeModal, + scanWorkspaceCleanup: state.scanWorkspaceCleanup, + markCandidateViewed: state.markWorkspaceCleanupCandidateViewed, + dismissCandidates: state.dismissWorkspaceCleanupCandidates, + resetDismissals: state.resetWorkspaceCleanupDismissals, + removeCandidates: state.removeWorkspaceCleanupCandidates, + markWorktreesQueuedForDeletion: state.markWorktreesQueuedForDeletion, + clearWorktreeDeleteState: state.clearWorktreeDeleteState + })) + ) + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) + const [activeView, setActiveView] = useState('ready') + const [repoSelection, setRepoSelection] = useState>(() => new Set()) + const [filters, setFilters] = useState(DEFAULT_WORKSPACE_CLEANUP_FILTERS) + const [sortKey, setSortKey] = useState('activity') + const [sortDirection, setSortDirection] = useState('asc') + const selectedDefaultsScanAtRef = useRef(null) + const autoScanAttemptedForOpenRef = useRef(false) + const wasOpenRef = useRef(false) + const mountedRef = useMountedRef() + const removal = useWorkspaceCleanupRemovalSession({ + mountedRef, + setSelectedIds, + closeModal, + removeCandidates, + markWorktreesQueuedForDeletion, + clearWorktreeDeleteState + }) + const { clearRowFailures, resetForOpen, ...publicRemoval } = removal + const startScan = useWorkspaceCleanupScanSession({ + open, + mountedRef, + openModal, + scanWorkspaceCleanup, + clearRowFailures + }) + + useEffect(() => { + if (!open) { + wasOpenRef.current = false + autoScanAttemptedForOpenRef.current = false + return + } + if (!wasOpenRef.current) { + wasOpenRef.current = true + autoScanAttemptedForOpenRef.current = false + if (!removal.removalInFlightRef.current) { + setActiveView('ready') + setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS) + setSortKey('activity') + setSortDirection('asc') + resetForOpen() + } + } + // Why: reopening mid-batch must preserve progress and avoid a scan the removal would invalidate. + if (!loading && !autoScanAttemptedForOpenRef.current && !removal.removalInFlightRef.current) { + autoScanAttemptedForOpenRef.current = true + startScan({ notifyWhenReady: true }) + } + }, [loading, open, removal.removalInFlightRef, resetForOpen, startScan]) + + const ignoreCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + void dismissCandidates([candidate]) + .then(() => { + if (!mountedRef.current) { + return + } + setSelectedIds((current) => { + const next = new Set(current) + next.delete(candidate.worktreeId) + return next + }) + }) + .catch((error: unknown) => { + if (!mountedRef.current) { + return + } + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c', + 'Could not ignore cleanup suggestion' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + }, + [dismissCandidates, mountedRef] + ) + + const restoreDismissals = useCallback(() => { + void resetDismissals() + }, [resetDismissals]) + + return { + open, + loading, + selectedIds, + setSelectedIds, + expandedRowIds, + setExpandedRowIds, + activeView, + setActiveView, + repoSelection, + setRepoSelection, + filters, + setFilters, + sortKey, + setSortKey, + sortDirection, + setSortDirection, + selectedDefaultsScanAtRef, + close: closeModal, + markCandidateViewed, + restoreDismissals, + startScan, + ignoreCandidate, + ...publicRemoval + } +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts new file mode 100644 index 00000000000..e202ac8bfe5 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-session.ts @@ -0,0 +1,265 @@ +import { + useCallback, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { AppState } from '@/store/types' +import { useAppStore } from '@/store' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +type WorkspaceCleanupSelectedIdsSetter = Dispatch>> + +export type WorkspaceCleanupRemovalSession = { + confirming: boolean + confirmCandidates: WorkspaceCleanupCandidate[] + removalProgress: WorkspaceCleanupRemovalProgress | null + removalInFlight: boolean + rowFailures: Record + removalInFlightRef: RefObject + clearRowFailures: () => void + resetForOpen: () => void + applyScanDefaults: ( + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet + ) => void + openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void + cancelConfirmRemove: () => void + backToWorkspaceCleanupList: () => void + confirmRemove: () => void +} + +export function useWorkspaceCleanupRemovalSession({ + mountedRef, + setSelectedIds, + closeModal, + removeCandidates, + markWorktreesQueuedForDeletion, + clearWorktreeDeleteState +}: { + mountedRef: RefObject + setSelectedIds: WorkspaceCleanupSelectedIdsSetter + closeModal: AppState['closeModal'] + removeCandidates: AppState['removeWorkspaceCleanupCandidates'] + markWorktreesQueuedForDeletion: AppState['markWorktreesQueuedForDeletion'] + clearWorktreeDeleteState: AppState['clearWorktreeDeleteState'] +}): WorkspaceCleanupRemovalSession { + const [confirming, setConfirming] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) + // Why: progress arrives after the click; the ref synchronously rejects a second batch. + const [removalInFlight, setRemovalInFlight] = useState(false) + const [rowFailures, setRowFailures] = useState>({}) + const removalInFlightRef = useRef(false) + // Why: an older batch's late settlement must not mutate a newer batch's UI. + const removalBatchIdRef = useRef(0) + + const clearRowFailures = useCallback(() => setRowFailures({}), []) + + const resetForOpen = useCallback(() => { + setConfirming(false) + setRowFailures({}) + setSelectedIds(new Set()) + }, [setSelectedIds]) + + const applyScanDefaults = useCallback( + ( + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet + ) => { + if (removalInFlightRef.current) { + return + } + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(candidates, deletingWorktreeIds)) + setConfirming(false) + setRowFailures({}) + }, + [setSelectedIds] + ) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { + return + } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return + } + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + // Why: Back exposes per-row progress without cancelling the batch; Close dismisses the modal. + const backToWorkspaceCleanupList = useCallback(() => { + setConfirming(false) + setConfirmCandidates([]) + }, []) + + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: rows that fail before removal starts must not stay queued in the sidebar. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + + const deselectRemovedIds = useCallback( + (removedIds: readonly string[]) => { + if (removedIds.length === 0) { + return + } + setSelectedIds((current) => { + const next = new Set(current) + for (const id of removedIds) { + next.delete(id) + } + return next + }) + }, + [setSelectedIds] + ) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRemovalInFlight(true) + removalBatchIdRef.current += 1 + const removalBatchId = removalBatchIdRef.current + // Why: hung late settlements retain callbacks, so capture ids instead of candidate objects. + const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) + setRowFailures({}) + markWorktreesQueuedForDeletion(removableWorktreeIds) + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + deselectRemovedIds(result.removedIds) + setRemovalProgress(null) + setRemovalInFlight(false) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + }, + onLateResult: (result) => { + for (const failure of result.failures) { + // Why: a late preflight failure can leave a row that never started stuck as queued. + clearQueuedDeleteState(failure.worktreeId) + } + if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + deselectRemovedIds(result.removedIds) + }, + onError: () => { + // Why: the batch driver is gone, so nothing will settle these rows later; clear + // unconditionally (not just queued ones) or a hung 'deleting' row stays stuck in + // the sidebar and filterWorkspaceCleanupRemovalCandidates blocks every retry. + for (const worktreeId of removableWorktreeIds) { + clearWorktreeDeleteState(worktreeId) + } + if (mountedRef.current) { + setRemovalProgress(null) + setRemovalInFlight(false) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + } + }) + }, [ + clearQueuedDeleteState, + clearWorktreeDeleteState, + confirmCandidates, + deselectRemovedIds, + markWorktreesQueuedForDeletion, + mountedRef, + removeCandidates + ]) + + return { + confirming, + confirmCandidates, + removalProgress, + removalInFlight, + rowFailures, + removalInFlightRef, + clearRowFailures, + resetForOpen, + applyScanDefaults, + openConfirmRemove, + cancelConfirmRemove, + backToWorkspaceCleanupList, + confirmRemove + } +} + +function getDefaultSelectedWorkspaceCleanupIds( + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet +): Set { + return new Set( + candidates + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) + .map((candidate) => candidate.worktreeId) + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts index 74197632016..93604bc9f63 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts @@ -4,12 +4,15 @@ import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { showPreservedBranchBatchToast } from '../sidebar/preserved-branch-batch-toast' export function showWorkspaceCleanupRemovalResultToasts( result: WorkspaceCleanupRemoveResult, pendingSettlementFailures?: ReadonlySet ): void { - if (result.removedIds.length > 0) { + if (result.preservedBranches && result.preservedBranches.length > 0) { + showPreservedBranchBatchToast(result.removedIds.length, result.preservedBranches) + } else if (result.removedIds.length > 0) { toast.success( translate( 'auto.components.workspace.cleanup.backgroundRemoval.removed', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts new file mode 100644 index 00000000000..fc4cd59b1fd --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-session.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { toast } from 'sonner' +import type { AppState } from '@/store/types' +import { translate } from '@/i18n/i18n' + +export function useWorkspaceCleanupScanSession({ + open, + mountedRef, + openModal, + scanWorkspaceCleanup, + clearRowFailures +}: { + open: boolean + mountedRef: RefObject + openModal: AppState['openModal'] + scanWorkspaceCleanup: AppState['scanWorkspaceCleanup'] + clearRowFailures: () => void +}): (options?: { notifyWhenReady?: boolean }) => void { + const openRef = useRef(open) + const latestReadyToastScanAtRef = useRef(null) + + useEffect(() => { + openRef.current = open + }, [open]) + + return useCallback( + (options: { notifyWhenReady?: boolean } = {}) => { + clearRowFailures() + void scanWorkspaceCleanup() + .then((result) => { + if (!mountedRef.current || !options.notifyWhenReady || openRef.current) { + return + } + if (latestReadyToastScanAtRef.current === result.scannedAt) { + return + } + latestReadyToastScanAtRef.current = result.scannedAt + const suggestedCount = result.candidates.filter( + (candidate) => candidate.selectedByDefault + ).length + toast.success( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63', + 'Inactive workspace scan ready' + ), + { + description: formatWorkspaceCleanupReadyToastDescription( + result.candidates.length, + suggestedCount + ), + action: { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764', + 'Review' + ), + onClick: () => openModal('workspace-cleanup') + } + } + ) + }) + .catch((error: unknown) => { + if (!mountedRef.current) { + return + } + toast.error( + translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8', + 'Workspace cleanup scan failed' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + }, + [clearRowFailures, mountedRef, openModal, scanWorkspaceCleanup] + ) +} + +function formatWorkspaceCleanupReadyToastDescription( + inactiveCount: number, + suggestedCount: number +): string { + if (inactiveCount === 0) { + return 'No inactive workspaces found.' + } + const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces' + const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions' + return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` +} diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 4635fe85250..3da1c66787d 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -1,31 +1,13 @@ -// Why: when the dialog opens for a Project row whose repo differs from the -// active workspace, label/assignee lookups must target the row's repo via -// slug-addressed IPCs (`listLabelsBySlug` / `listAssignableUsersBySlug`), -// not via the workspace path. These hooks live in their own module so the -// existing repoPath-keyed hooks stay focused on the local-workspace flow -// and so this file remains under the lint line cap. -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: slug metadata hooks clear stale rows and track loading while async provider cache requests are in flight. */ -import { useEffect, useRef, useState } from 'react' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types' import type { ListAssignableUsersBySlugResult, ListLabelsBySlugResult } from '../../../shared/github-project-types' -import { - clearMetadataRequestStore, - createMetadataRequestStore, - getFreshMetadata, - loadMetadata -} from './metadata-request-cache' +import { clearMetadataRequestStore, createMetadataRequestStore } from './metadata-request-cache' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { githubProjectHost } from '../../../shared/github-project-identity' - -type MetadataState = { - data: T - loading: boolean - error: string | null -} +import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' const slugLabelStore = createMetadataRequestStore() const slugAssigneeStore = createMetadataRequestStore() @@ -40,86 +22,41 @@ export function useRepoLabelsBySlug( repo: string | null, settings?: Pick | null, host?: string -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) - // Why: parent selectors can pass a fresh settings object each render; keying - // the effect on the primitive env id keeps a failure's setState from re-running - // the effect and re-issuing the fetch in a render-paced loop (same class as - // the seedKey stabilization below). +): MetadataListState { const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const selectedOwner = owner ?? '' + const selectedRepo = repo ?? '' + const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null + const cacheKey = + repositoryKey && target.kind === 'environment' + ? `runtime:${target.environmentId}:${repositoryKey}` + : repositoryKey - useEffect(() => { - if (!owner || !repo) { - return - } - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) - const repositoryKey = githubRepoIdentityKey({ owner, repo, host }) - const key = - target.kind === 'environment' - ? `runtime:${target.environmentId}:${repositoryKey}` - : repositoryKey - - const cached = getFreshMetadata(slugLabelStore, key) - if (cached) { - if (activeKeyRef.current !== key) { - setState({ data: cached.data, loading: false, error: null }) - } - activeKeyRef.current = key - return - } - - if (activeKeyRef.current === key) { - return - } - activeKeyRef.current = key - const requestKey = key - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(slugLabelStore, key, () => + return useMetadataListRequest({ + cacheKey, + store: slugLabelStore, + errorFallback: 'Failed to load labels', + load: () => (target.kind === 'environment' ? callRuntimeRpc( target, 'github.project.listLabelsBySlug', - { owner, repo, host: githubProjectHost(host) }, + { owner: selectedOwner, repo: selectedRepo, host: githubProjectHost(host) }, { timeoutMs: 30_000 } ) - : window.api.gh.listLabelsBySlug({ owner, repo, host: githubProjectHost(host) }) + : window.api.gh.listLabelsBySlug({ + owner: selectedOwner, + repo: selectedRepo, + host: githubProjectHost(host) + }) ).then((res) => { if (!res.ok) { throw new Error(res.error.message) } return res.labels }) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [owner, repo, host, activeRuntimeEnvironmentId]) - - return state + }) } export function useRepoAssigneesBySlug( @@ -128,61 +65,30 @@ export function useRepoAssigneesBySlug( seedLogins?: string[], settings?: Pick | null, host?: string -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) - // Why: seedLogins is a new array reference each parent render. Stabilize on - // the joined-string identity so the effect doesn't re-fire on every render - // — this is the assignee popover refetch-storm fix. +): MetadataListState { const seedKey = (seedLogins ?? []).slice().sort().join(',') - // Why: see useRepoLabelsBySlug — primitive env id keeps failure setState from - // re-arming the effect through a fresh settings object identity. const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const selectedOwner = owner ?? '' + const selectedRepo = repo ?? '' + const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null + const cacheKey = repositoryKey + ? target.kind === 'environment' + ? `runtime:${target.environmentId}:${repositoryKey}#${seedKey}` + : `${repositoryKey}#${seedKey}` + : null + const args = { + owner: selectedOwner, + repo: selectedRepo, + host: githubProjectHost(host), + ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) + } - useEffect(() => { - if (!owner || !repo) { - return - } - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) - const repositoryKey = githubRepoIdentityKey({ owner, repo, host }) - const key = - target.kind === 'environment' - ? `runtime:${target.environmentId}:${repositoryKey}#${seedKey}` - : `${repositoryKey}#${seedKey}` - - const cached = getFreshMetadata(slugAssigneeStore, key) - if (cached) { - // Why: see useRepoLabelsBySlug — avoid cached no-op writes when only - // the settings object identity changed. - if (activeKeyRef.current !== key) { - setState({ data: cached.data, loading: false, error: null }) - } - activeKeyRef.current = key - return - } - - if (activeKeyRef.current === key) { - return - } - activeKeyRef.current = key - const requestKey = key - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - const args = { - owner, - repo, - host: githubProjectHost(host), - ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) - } - loadMetadata(slugAssigneeStore, key, () => + return useMetadataListRequest({ + cacheKey, + store: slugAssigneeStore, + errorFallback: 'Failed to load assignees', + load: () => (target.kind === 'environment' ? callRuntimeRpc( target, @@ -197,25 +103,5 @@ export function useRepoAssigneesBySlug( } return res.users }) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load assignees' - })) - }) - }, [owner, repo, host, seedKey, activeRuntimeEnvironmentId]) - - return state + }) } diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index c0b79d220d6..c633d543daa 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearLinearMetadataCache, + useRepoLabels, useTeamLabels, useTeamMembers, useTeamStates, @@ -17,6 +18,9 @@ const linearMocks = vi.hoisted(() => ({ linearTeamMembers: vi.fn() })) +const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) +const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() })) + vi.mock('@/runtime/runtime-linear-client', () => ({ linearTeamStates: linearMocks.linearTeamStates, linearTeamLabels: linearMocks.linearTeamLabels, @@ -24,7 +28,7 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn(), + callRuntimeRpc: runtimeMocks.callRuntimeRpc, getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string | null } | null) => settings?.activeRuntimeEnvironmentId ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } @@ -33,6 +37,13 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ const roots: Root[] = [] +function installWindowApi(): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { gh: { listLabels: githubMocks.listLabels } } + }) +} + async function flushEffects(): Promise { await act(async () => { await Promise.resolve() @@ -50,12 +61,15 @@ function renderProbe(element: React.ReactNode): void { }) } -describe('useIssueMetadata Linear hooks', () => { +describe('useIssueMetadata hooks', () => { beforeEach(() => { clearLinearMetadataCache() linearMocks.linearTeamStates.mockReset() linearMocks.linearTeamLabels.mockReset() linearMocks.linearTeamMembers.mockReset() + runtimeMocks.callRuntimeRpc.mockReset() + githubMocks.listLabels.mockReset() + installWindowApi() }) afterEach(() => { @@ -65,6 +79,51 @@ describe('useIssueMetadata Linear hooks', () => { document.body.replaceChildren() }) + it('routes repo-id-only folder metadata through local IPC', async () => { + let labels: string[] = [] + githubMocks.listLabels.mockResolvedValue(['folder']) + + function LabelsProbe(): null { + labels = useRepoLabels(null, 'folder-repo-id').data + return null + } + + renderProbe() + await flushEffects() + + expect(labels).toEqual(['folder']) + expect(githubMocks.listLabels).toHaveBeenCalledExactlyOnceWith({ + repoPath: '', + repoId: 'folder-repo-id' + }) + expect(runtimeMocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('prefers an explicit remote environment and repo id', async () => { + let labels: string[] = [] + runtimeMocks.callRuntimeRpc.mockResolvedValue(['remote']) + + function LabelsProbe(): null { + labels = useRepoLabels('/local/repo', 'remote-repo-id', { + runtimeEnvironmentId: ' env-explicit ', + activeRuntimeEnvironmentId: 'env-active' + }).data + return null + } + + renderProbe() + await flushEffects() + + expect(labels).toEqual(['remote']) + expect(runtimeMocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'env-explicit' }, + 'github.listLabels', + { repo: 'remote-repo-id' }, + { timeoutMs: 15_000 } + ) + expect(githubMocks.listLabels).not.toHaveBeenCalled() + }) + it('does not loop when cached team-state metadata is read with a fresh settings object', async () => { let renders = 0 let states: unknown[] = [] diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index b694c5491dc..8d8a44599ef 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: repo metadata hooks share TTL caches and -Linear/GitHub cache invalidation entrypoints used by the issue dialog. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: issue metadata hooks clear stale rows and track loading while async provider cache requests are in flight. */ import { useEffect, useMemo, useRef, useState } from 'react' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { @@ -24,20 +21,13 @@ import { loadMetadata, type MetadataRequestStore } from './metadata-request-cache' - -type MetadataState = { - data: T - loading: boolean - error: string | null -} +import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' type GitHubMetadataOptions = { runtimeEnvironmentId?: string | null activeRuntimeEnvironmentId?: string | null } -// ─── GitHub ──────────────────────────────────────────────── - const ghLabelStore = createMetadataRequestStore() const ghAssigneeStore = createMetadataRequestStore() @@ -45,44 +35,22 @@ export function useRepoLabels( repoPath: string | null, repoId?: string | null, options?: GitHubMetadataOptions -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repoSelector + : null - useEffect(() => { - if (!repoPath && !repoId) { - return - } - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - // Why: SSH/runtime metadata must not reuse host-path cache entries; the same - // repo id may resolve through a different credential/runtime boundary. - const cacheKey = runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - const cached = getFreshMetadata(ghLabelStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(ghLabelStore, cacheKey, () => + return useMetadataListRequest({ + cacheKey, + store: ghLabelStore, + errorFallback: 'Failed to load labels', + load: () => runtimeEnvironmentId ? callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -93,71 +61,29 @@ export function useRepoLabels( : window.api.gh .listLabels({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) .then((labels) => labels as string[]) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) - - return state + }) } export function useRepoAssignees( repoPath: string | null, repoId?: string | null, options?: GitHubMetadataOptions -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repoSelector + : null - useEffect(() => { - if (!repoPath && !repoId) { - return - } - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - // Why: SSH/runtime metadata must not reuse host-path cache entries; the same - // repo id may resolve through a different credential/runtime boundary. - const cacheKey = runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - const cached = getFreshMetadata(ghAssigneeStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(ghAssigneeStore, cacheKey, () => + return useMetadataListRequest({ + cacheKey, + store: ghAssigneeStore, + errorFallback: 'Failed to load assignees', + load: () => runtimeEnvironmentId ? callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -168,31 +94,9 @@ export function useRepoAssignees( : window.api.gh .listAssignableUsers({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) .then((users) => users as GitHubAssignableUser[]) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load assignees' - })) - }) - }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) - - return state + }) } -// ─── Linear ──────────────────────────────────────────────── - const linearStateStore = createMetadataRequestStore() const linearLabelStore = createMetadataRequestStore() const linearMemberStore = createMetadataRequestStore() @@ -221,205 +125,44 @@ export function useTeamStates( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearStateStore, + load: () => loadTeamStates(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load states' }) - const activeKeyRef = useRef(null) - // Why: parents can pass a fresh settings object each render; keying the effect - // on the derived cache key keeps a failure's setState from re-arming the fetch - // in a render-paced loop. The ref carries the latest settings for the call. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearStateStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearStateStore, cacheKey, () => - linearTeamStates(settingsRef.current, teamId, workspaceId).then( - (states) => states as LinearWorkflowState[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load states' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } export function useTeamLabels( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearLabelStore, + load: () => loadTeamLabels(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load labels' }) - const activeKeyRef = useRef(null) - // Why: see useTeamStates — cache-key deps + latest-settings ref stop the - // failure-setState render loop. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearLabelStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearLabelStore, cacheKey, () => - linearTeamLabels(settingsRef.current, teamId, workspaceId).then( - (labels) => labels as LinearLabel[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } export function useTeamMembers( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearMemberStore, + load: () => loadTeamMembers(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load members' }) - const activeKeyRef = useRef(null) - // Why: see useTeamStates — cache-key deps + latest-settings ref stop the - // failure-setState render loop. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearMemberStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearMemberStore, cacheKey, () => - linearTeamMembers(settingsRef.current, teamId, workspaceId).then( - (members) => members as LinearMember[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load members' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } -/** - * Load Linear team metadata for every selected team and union by id (#8739). - * Reuses the same per-team cache stores as the single-team hooks. - */ function useTeamsMetadataList( teamIds: readonly string[], settings: RuntimeLinearSettings | undefined, @@ -431,8 +174,8 @@ function useTeamsMetadataList( workspaceId: string | null | undefined ) => Promise, errorFallback: string -): MetadataState { - const [state, setState] = useState>({ +): MetadataListState { + const [state, setState] = useState>({ data: [], loading: false, error: null @@ -448,7 +191,6 @@ function useTeamsMetadataList( [teamIdsKey] ) - // Why: recompute each render (like useTeamStates cacheKey) so runtime target changes re-key. const requestKey = stableTeamIds.length === 0 ? null @@ -464,22 +206,12 @@ function useTeamsMetadataList( } activeKeyRef.current = requestKey - const capturedKey = requestKey - const teams = stableTeamIds - - // Fast path: every team still fresh in cache → union synchronously. - const cachedGroups: T[][] = [] - let allCached = true - for (const teamId of teams) { - const cacheKey = linearMetadataCacheKey(teamId, settingsRef.current, workspaceId) - const cached = getFreshMetadata(store, cacheKey) - if (!cached) { - allCached = false - break - } - cachedGroups.push(cached.data) - } - if (allCached) { + const cachedGroups = stableTeamIds.map( + (teamId) => + getFreshMetadata(store, linearMetadataCacheKey(teamId, settingsRef.current, workspaceId)) + ?.data + ) + if (cachedGroups.every((group): group is T[] => group !== undefined)) { setState({ data: unionLinearMetadataById(cachedGroups), loading: false, error: null }) return } @@ -492,7 +224,7 @@ function useTeamsMetadataList( })) void Promise.all( - teams.map((teamId) => { + stableTeamIds.map((teamId) => { const cacheKey = linearMetadataCacheKey(teamId, settingsRef.current, workspaceId) return loadMetadata(store, cacheKey, () => loadTeam(settingsRef.current, teamId, workspaceId) @@ -500,7 +232,7 @@ function useTeamsMetadataList( }) ) .then((groups) => { - if (activeKeyRef.current !== capturedKey) { + if (activeKeyRef.current !== requestKey) { return } setState({ @@ -510,7 +242,7 @@ function useTeamsMetadataList( }) }) .catch((err) => { - if (activeKeyRef.current !== capturedKey) { + if (activeKeyRef.current !== requestKey) { return } activeKeyRef.current = null @@ -546,12 +278,11 @@ const loadTeamMembers = ( ): Promise => linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[]) -/** Union of workflow states for every selected Linear team (multi-team filters). */ export function useTeamsStates( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, @@ -562,12 +293,11 @@ export function useTeamsStates( ) } -/** Union of labels for every selected Linear team (multi-team filters). */ export function useTeamsLabels( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, @@ -578,12 +308,11 @@ export function useTeamsLabels( ) } -/** Union of members for every selected Linear team (multi-team filters). */ export function useTeamsMembers( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, diff --git a/src/renderer/src/hooks/useMetadataListRequest.test.tsx b/src/renderer/src/hooks/useMetadataListRequest.test.tsx new file mode 100644 index 00000000000..3e0c6a68055 --- /dev/null +++ b/src/renderer/src/hooks/useMetadataListRequest.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { createMetadataRequestStore } from './metadata-request-cache' +import { useMetadataListRequest } from './useMetadataListRequest' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +afterEach(cleanup) + +describe('useMetadataListRequest', () => { + it('treats only null as disabled', async () => { + const store = createMetadataRequestStore() + let loads = 0 + const view = renderHook(() => + useMetadataListRequest({ + cacheKey: '', + store, + errorFallback: 'Failed to load metadata', + load: async () => { + loads += 1 + return ['loaded'] + } + }) + ) + + await act(() => Promise.resolve()) + expect(loads).toBe(1) + expect(view.result.current.data).toEqual(['loaded']) + }) + + it('ignores stale responses and retains the result while disabled', async () => { + const store = createMetadataRequestStore() + const first = deferred() + const second = deferred() + const view = renderHook( + ({ cacheKey }: { cacheKey: string | null }) => + useMetadataListRequest({ + cacheKey, + store, + errorFallback: 'Failed to load metadata', + load: () => (cacheKey === 'first' ? first.promise : second.promise) + }), + { initialProps: { cacheKey: 'first' as string | null } } + ) + + view.rerender({ cacheKey: 'second' }) + await act(async () => { + second.resolve(['second']) + await second.promise + }) + expect(view.result.current.data).toEqual(['second']) + + view.rerender({ cacheKey: null }) + await act(async () => { + first.resolve(['first']) + await first.promise + }) + expect(view.result.current).toEqual({ data: ['second'], loading: false, error: null }) + }) +}) diff --git a/src/renderer/src/hooks/useMetadataListRequest.ts b/src/renderer/src/hooks/useMetadataListRequest.ts new file mode 100644 index 00000000000..186b620d6cd --- /dev/null +++ b/src/renderer/src/hooks/useMetadataListRequest.ts @@ -0,0 +1,72 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react' +import { getFreshMetadata, loadMetadata, type MetadataRequestStore } from './metadata-request-cache' + +export type MetadataListState = { + data: T[] + loading: boolean + error: string | null +} + +type MetadataListRequest = { + cacheKey: string | null + store: MetadataRequestStore + load: () => Promise + errorFallback: string +} + +export function useMetadataListRequest({ + cacheKey, + store, + load, + errorFallback +}: MetadataListRequest): MetadataListState { + const [state, setState] = useState>({ + data: [], + loading: false, + error: null + }) + const activeKeyRef = useRef(null) + const loadLatest = useEffectEvent(load) + + useEffect(() => { + if (cacheKey === null) { + return + } + + const cached = getFreshMetadata(store, cacheKey) + if (cached) { + if (activeKeyRef.current !== cacheKey) { + setState({ data: cached.data, loading: false, error: null }) + } + activeKeyRef.current = cacheKey + return + } + activeKeyRef.current = cacheKey + const requestKey = cacheKey + setState((current) => ({ + ...current, + data: current.data.length ? [] : current.data, + loading: true, + error: null + })) + void loadMetadata(store, cacheKey, () => loadLatest()) + .then((data) => { + if (activeKeyRef.current === requestKey) { + setState({ data, loading: false, error: null }) + } + }) + .catch((error: unknown) => { + if (activeKeyRef.current !== requestKey) { + return + } + activeKeyRef.current = null + setState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : errorFallback + })) + }) + }, [cacheKey, errorFallback, store]) + + return state +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 34911f6f372..0ac928f9927 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2709,15 +2709,6 @@ }, "sheet": { "1189e9fe0a": "Close" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "No teams match your search.", - "301f2a796e": "All teams", - "18ec58881e": "Search teams..." - } - } } }, "terminal": { @@ -5334,6 +5325,45 @@ "connectName": "Connect to SSH host {{value0}}", "authFailedTooltip": "{{value0}} · authentication failed", "failedTooltip": "{{value0}} · connection failed" + }, + "preserved": { + "branch": { + "batch": { + "toast": { + "a3cdd9d9e6": "Git kept {{count}} local branches because they may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "a3cdd9d9e6_one": "Git kept {{count}} local branch because it may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "a3cdd9d9e6_other": "Git kept {{count}} local branches because they may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "6310412304": "Review {{count}} Branches", + "6310412304_one": "Review {{count}} Branch", + "6310412304_other": "Review {{count}} Branches", + "cea24c2b7d": "{{count}} workspaces removed", + "cea24c2b7d_one": "{{count}} workspace removed", + "cea24c2b7d_other": "{{count}} workspaces removed", + "0e0379f24a": "{{count}} branches kept", + "0e0379f24a_one": "{{count}} branch kept", + "0e0379f24a_other": "{{count}} branches kept", + "4cf75caab7": "{{value0}}, {{value1}}", + "e61d78054f": "Deleting local branches: {{value0}}", + "1e1a5f6763": "Local branches deleted: {{value0}}", + "43d9395605": "{{value0}} deleted, {{value1}} not deleted", + "d42f1f14e0": "Retry {{count}} Branches", + "d42f1f14e0_one": "Retry {{count}} Branch", + "d42f1f14e0_other": "Retry {{count}} Branches" + } + } + } + }, + "PreservedBranchBatchReviewDialog": { + "c4bf8e7eaf": "Review kept branches", + "f21976c9a8": "Select the local branches you want to force delete. Unselected branches stay in their repositories.", + "38c947f7c5": "Select all", + "9602129d38": "{{value0}} of {{value1}} selected", + "ee39e872d5": "May be unmerged", + "676db406fd": "Head unavailable", + "285e1e4882": "Cancel", + "a0f9863597": "Force Delete {{count}} Branches", + "a0f9863597_one": "Force Delete {{count}} Branch", + "a0f9863597_other": "Force Delete {{count}} Branches" } }, "shared": { @@ -10714,7 +10744,13 @@ "b25f63edd7": "open", "d2ca293f3d": "Working...", "ef064cb7c3": "default", - "59b4dccf70": "destructive" + "59b4dccf70": "destructive", + "9a41a687b7": "Queue through #{{pr}} · {{count}} PR", + "38a1bccb14": "Queue through #{{pr}} · {{count}} PRs", + "3de88351c5": "GitHub will add this pull request and every pull request below it to the merge queue.", + "a32fe6dba6": "GitHub will merge this pull request and every pull request below it in the stack.", + "73e0e1819d": "Queueing stack...", + "e555a41d32": "Merging stack..." }, "PortsPanel": { "3ea4a02a8f": "Cancel", @@ -11878,6 +11914,34 @@ "d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status." } } + }, + "pr": { + "stack": { + "confirmation": { + "84f6f5b9eb": "Included: {{numbers}}. ", + "541984b2eb": "Queue through #{{pr}}?", + "4809f55cdb": "{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.", + "be8f2621be": "{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.", + "92ca033e72": "Queue {{count}} PR", + "478a527b15": "Queue {{count}} PRs", + "1feef35ca4": "Merge through #{{pr}}?", + "c3e036c99f": "{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.", + "369aba4b32": "{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.", + "493c78f521": "Merge {{count}} PR", + "eb7051d268": "Merge {{count}} PRs" + }, + "merge": { + "55ae29b907": "Merge through #{{pr}} · {{count}} PR", + "b8446f6ec2": "Merge through #{{pr}} · {{count}} PRs", + "189d0ec614": "#{{pr}} is still a draft.", + "640fb50d9c": "#{{pr}} is closed.", + "46ffcbda75": "#{{pr}} has merge conflicts.", + "6dabefd63e": "#{{pr}} has requested changes.", + "2bb21fc326": "#{{pr}} still needs review approval.", + "c23faf74df": "#{{pr}} must be updated.", + "f561e80968": "#{{pr}} is blocked." + } + } } }, "PluginPanel": { @@ -11896,6 +11960,23 @@ "mayBeSlower": "May be slower", "slowest": "Slowest", "unlimited": "Unlimited" + }, + "GitHubPRStackMap": { + "3511405914": "closed", + "8a9bdc36c0": "merged", + "568c647ccd": "draft", + "bea9ade223": "conflicts", + "838aadf512": "checks failed", + "316039b5db": "checks pending", + "4b1e5ee9d3": "changes requested", + "9a17b5255c": "review needed", + "d3d97cf3f2": "approved", + "e6cb964305": "open", + "7737bd66be": "Collapse stack #{{value0}}", + "0c1645ebd0": "Expand stack #{{value0}}", + "e3ee2daa32": "Stack #{{value0}}", + "cb440931b7": "{{value0}} of {{value1}} · {{value2}}", + "525259fa17": "Stack details are temporarily unavailable." } } }, @@ -11966,17 +12047,6 @@ "yoloPermissionsInfo": "Agent permission info", "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents can message each other, take tasks, and coordinate handoffs.", - "399cf885c0": "Agent Orchestration", - "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", - "1ecfb490ac": "Computer Use", - "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", "47fc6cc6dc": "Skill setup command", @@ -12062,37 +12132,6 @@ "9f47f345a4": "It won't take long!", "e4726b2d50": "Skip onboarding?" }, - "OnboardingTourStep": { - "3f9586c043": "Take the tour", - "60c5576353": "Exit tour" - }, - "RepoStep": { - "e8fdb36338": "Scanning repositories. Click to stop.", - "b7c4da0504": "SSH? Set hosts up in Settings", - "c33b190ca3": "Host paths only", - "7b679207e4": "Workspace", - "24c7c8696c": "Clone into host path", - "7932e95f68": "Clone", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "Paste an HTTPS or SSH URL.", - "132425a3e3": "Clone a repo", - "6558d50c69": "Want to import many repos at once? Select the parent folder.", - "831524961f": "Choose any local directory, git repo or not.", - "f4e9c8dcf8": "Browse for a folder", - "e8214aa632": "Open as Folder", - "3863747c56": "Add Git Project", - "2ebbc26343": "/home/user/project", - "466108ab89": "Enter a path that exists on the selected host.", - "8cab104e3c": "Open a project on this host", - "2d20200346": "Import repositories", - "27ca610db1": "Back", - "cecd6593fa": "Scanned folder:", - "c7af322fc3": "Stop scanning", - "c3d9d44ca2": "Stop scan", - "cf23006ba7": "Selected host", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}Found {{value1}} {{value2}} in this folder." - }, "ThemeStep": { "a4b254779d": "macOS Option key", "6c51398942": "Mouse", @@ -12120,15 +12159,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "Could not open SSH settings", - "fd74e7558e": "Clone failed", "52acfbef51": "Could not save progress" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Enable capabilities" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Uses PowerShell 7+ when available, with Windows PowerShell as fallback.", @@ -12229,10 +12263,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" - }, "ProjectCombobox": { "empty": "No projects match your search.", "addProject": "Add a new project", @@ -12634,43 +12664,6 @@ "cda56c5915": "notes / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "Thinking...", - "ef8b164dd1": "review src/auth", - "952d3ddd9a": "session started", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "Running 12 tests", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "Splittable terminal", - "2a7cfc82c8": "Linked to GH #1842", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "Start", - "c1f28c03b2": "Worktree picker truncates", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub & Linear tasks", - "5171768676": "orchestrating 3 agents", - "cebc7769cd": "redesign auth flow", - "e44269e97d": "Agent orchestration", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "refactor billing webhook", - "9c812e0d7c": "speed up CI pipeline", - "3c4adfd821": "fix login race condition", - "56a0271428": "Isolated workspaces", - "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." - }, "FeatureWallBody": { "25ec5356d6": "Setup" }, @@ -12914,11 +12907,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "working" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "notes" @@ -14352,7 +14340,17 @@ "usageUnavailable": "Usage unavailable", "noRunUsageYet": "No run usage yet", "noWorkspace": "No workspace", - "latestSavedOutput": "Latest saved output" + "latestSavedOutput": "Latest saved output", + "backToList": "All automations", + "tableName": "Name", + "tableProject": "Project", + "tableStatus": "Status", + "tableActions": "Actions", + "newAutomation": "New Automation", + "rowActions": "Automation actions" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Loading automations" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", @@ -14469,9 +14467,12 @@ "AutomationListSearchField": { "tooLong": "Search text is too long — list is unfiltered", "label": "Search automations", - "placeholder": "Search by name, project, or prompt", + "placeholder": "Search...", "tooLongShort": "Too long", "clear": "Clear search" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Automation actions" } }, "agent": { @@ -14733,80 +14734,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca sign-in unavailable" - }, - "account": "Account", - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", - "create": { - "profile": { - "for": { - "org": "Create profile for org" - } - } - }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", - "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile", - "org": { - "members": "Organization members" - }, - "signInWaiting": "Waiting for sign-in…", - "signInAgain": "Sign in again", - "signIn": "Sign in to Orca", - "accountTitle": "Orca account", - "accountSignedIn": "Signed in", - "accountSignInRequired": "Sign-in required", - "accountSignedOut": "Signed out" - }, - "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects.", - "projectCountSingular": "{{count}} project", - "projectCountPlural": "{{count}} projects" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" - }, - "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" - }, - "live": { - "work": "Live work in this project" - }, - "cancel": "Cancel", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" - } - } - }, "signout": { "confirm": { "title": "Sign out of Orca?", @@ -14814,79 +14741,6 @@ "cancel": "Cancel", "action": "Sign out" } - }, - "switch": { - "confirm": { - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", - "live": { - "work": "Live work in this profile" - }, - "cancel": "Cancel", - "switch": "Switch profile", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" - } - }, - "org": { - "members": { - "role": { - "label": "Role", - "owner": "Owner", - "admin": "Admin", - "member": "Member" - }, - "not": { - "signed": { - "in": "They haven't signed in to Orca yet." - } - }, - "remove": { - "confirm": "Remove", - "cancel": "Cancel", - "label": "Remove teammate" - }, - "title": "Organization members", - "load": { - "error": "Couldn't load organization members." - }, - "retry": "Try again", - "pending": { - "title": "Pending invites", - "revoke": "Revoke" - }, - "invite": { - "title": "Invite", - "placeholder": "teammate@example.com", - "role": "Invite role", - "submit": "Send" - }, - "error": { - "forbidden": "Your role can't do that.", - "conflict": "Already a member or invited.", - "remove": { - "self": "You can't remove yourself." - }, - "own": { - "role": "You can't change your own role." - }, - "not": { - "found": "That teammate is no longer in this organization." - }, - "reconnect": "Reconnect your profile to manage members.", - "unconfigured": "Organization management is unavailable in this build.", - "failed": "Something went wrong. Please try again." - }, - "subtitle": { - "named": "People in {{orgName}} who can collaborate on Orca.", - "default": "People in your organization who can collaborate on Orca." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de7bb77346c..3870c3906ef 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "Cerrar" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "Ningún equipo coincide con tu búsqueda.", - "301f2a796e": "Todos los equipos", - "18ec58881e": "Buscar equipos..." - } - } } }, "terminal": { @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Información de permisos del agente", "yoloPermissionsTooltip": "Omite las comprobaciones de permisos de los agentes para reducir interrupciones" }, - "FeatureSetupChecklist": { - "77f74946f5": "Los agentes pueden enviarse mensajes entre sí, tomar tareas y coordinar handoffs.", - "399cf885c0": "Orquestación de agentes", - "c5292c409d": "Los agentes pueden inspeccionar ventanas de apps y operar apps locales cuando se lo pidas.", - "1ecfb490ac": "Uso de la computadora", - "01426f3a23": "Los agentes pueden navegar por sitios, inspeccionar páginas y trabajar en tareas del navegador.", - "ea85d9e628": "Uso del navegador por agentes", - "linearTicketsTitle": "Skill de agente de Linear", - "linearTicketsDescription": "Los agentes pueden usar tareas de Linear enlazadas para handoffs con más contexto de ticket.", - "linearTicketsSetupSummary": "Recomendado para espacios de trabajo de Linear; no afecta la configuración de conexión de Linear." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Pulsa Enter para ejecutar el comando y confirma npx si se te solicita. También puedes configurarlo más tarde en Configuración.", "47fc6cc6dc": "Comando de configuración de skills", @@ -11589,37 +11569,6 @@ "9f47f345a4": "¡No tardará mucho!", "e4726b2d50": "¿Saltar la incorporación?" }, - "OnboardingTourStep": { - "3f9586c043": "Haz el recorrido", - "60c5576353": "Salir del recorrido" - }, - "RepoStep": { - "e8fdb36338": "Escaneo de repositorios. Haga clic para detener.", - "b7c4da0504": "¿SSH? Configura hosts en Configuración", - "c33b190ca3": "Solo rutas del host", - "7b679207e4": "Espacio de trabajo", - "24c7c8696c": "Clonar en ruta del host", - "7932e95f68": "Clonar", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "Pegue una URL HTTPS o SSH.", - "132425a3e3": "Clonar un repositorio", - "6558d50c69": "¿Quieres importar muchos repositorios a la vez? Seleccione la carpeta principal.", - "831524961f": "Elija cualquier directorio local, repositorio git o no.", - "f4e9c8dcf8": "Buscar una carpeta", - "e8214aa632": "Abrir como carpeta", - "3863747c56": "Agregar proyecto Git", - "2ebbc26343": "/home/user/project", - "466108ab89": "Introduce una ruta que exista en el host seleccionado.", - "8cab104e3c": "Abrir un proyecto en este host", - "2d20200346": "Importar repositorios", - "27ca610db1": "Atrás", - "cecd6593fa": "Carpeta escaneada:", - "c7af322fc3": "Dejar de escanear", - "c3d9d44ca2": "Detener escaneo", - "cf23006ba7": "Host seleccionado", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}Se encontraron {{value1}} {{value2}} en esta carpeta." - }, "ThemeStep": { "a4b254779d": "Tecla de opción de macOS", "6c51398942": "Ratón", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "No se pudo abrir la configuración de SSH", - "fd74e7558e": "Clon falló", "52acfbef51": "No se pudo guardar el progreso" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Activar capacidades" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Usa PowerShell 7+ cuando está disponible, con Windows PowerShell como alternativa.", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hay hosts listos para este proyecto.", - "placeholder": "Elegir host" - }, "ProjectCombobox": { "empty": "Ningún proyecto coincide con tu búsqueda.", "addProject": "Add a new project", @@ -12135,43 +12075,6 @@ "cda56c5915": "notes / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "Pensando...", - "ef8b164dd1": "revisar src/auth", - "952d3ddd9a": "sesión iniciada", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "Ejecutando 12 pruebas", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "Terminal dividible", - "2a7cfc82c8": "Vinculado a GH #1842", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "Comenzar", - "c1f28c03b2": "El selector de worktree se trunca", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "Tareas de GitHub y Linear", - "5171768676": "orquestando 3 agentes", - "cebc7769cd": "rediseñar el flujo de autenticación", - "e44269e97d": "Orquestación de agentes", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "refactorizar el webhook de facturación", - "9c812e0d7c": "acelerar el proceso de CI", - "3c4adfd821": "arreglar la condición de carrera de inicio de sesión", - "56a0271428": "Espacios de trabajo aislados", - "ef737dcee1": "Tareas de GitHub y Linear", - "ac51c061e2": "codex", - "47f16ecf34": "Avanza en varias cosas a la vez. Cada workspace mantiene juntos su rama, terminal y actividad de agentes.", - "70aa182266": "Delega un objetivo y sigue con lo tuyo. Un agente coordinador se despliega y publica PRs en paralelo.", - "f10c14dd9d": "Evita cambiar de pestaña. Elige desde tu backlog de GitHub o Linear e inicia un workspace con un clic.", - "5d6ee181b6": "Abre cualquier workspace para volver a su terminal y luego divide paneles para tests, logs y agentes." - }, "FeatureWallBody": { "25ec5356d6": "Configuración" }, @@ -12415,11 +12318,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "laboral" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "notas" @@ -13816,7 +13714,27 @@ "a21f6c33ad": "Origen de automatización actualizado.", "53f06f0ad5": "Reintentar origen", "pendingAutomationMissing": "La automatización ya no está disponible.", - "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." + "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible.", + "noSearchMatches": "Ninguna automatización coincide con tu búsqueda.", + "paused": "Pausada", + "runCount": "{{count}} ejecuciones", + "runCount_one": "{{count}} ejecución", + "runCount_other": "{{count}} ejecuciones", + "projectDefaultBaseRef": "predeterminado del proyecto", + "createFromBaseRef": "Crear desde {{baseRef}}", + "missingWorkspace": "Espacio de trabajo ausente", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Uso no disponible", + "noRunUsageYet": "Aún no hay uso de ejecución", + "noWorkspace": "Sin espacio de trabajo", + "latestSavedOutput": "Última salida guardada", + "backToList": "Todas las automatizaciones", + "tableName": "Nombre", + "tableProject": "Proyecto", + "tableStatus": "Estado", + "tableActions": "Acciones", + "newAutomation": "Nueva automatización", + "rowActions": "Acciones de automatización" }, "CreateFromPicker": { "f061f49e3f": "Buscar ramas del repositorio...", @@ -13929,6 +13847,19 @@ "5a7863909c": "Ejecutar la configuración para cada espacio de trabajo nuevo", "18f000ad4e": "Avanzado", "874b72195b": "Cuando esta automatización crea un workspace, lo prepara igual que al crear un worktree manualmente: ejecuta la configuración del proyecto y abre sus pestañas de terminal." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Cargando automatizaciones" + }, + "AutomationListSearchField": { + "tooLong": "El texto de búsqueda es demasiado largo — la lista no está filtrada", + "label": "Buscar automatizaciones", + "placeholder": "Buscar...", + "tooLongShort": "Demasiado largo", + "clear": "Borrar búsqueda" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Acciones de automatización" } }, "agent": { @@ -14181,80 +14112,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Inicio de sesión en Orca no disponible" - }, - "account": "Cuenta", - "reconnect": "Reconectar perfil", - "connect": "Conectar perfil", - "organization": "Organización", - "create": { - "profile": { - "for": { - "org": "Crear perfil para organización" - } - } - }, - "signout": "Cerrar sesión", - "16e3681072": "Nuevo perfil local", - "e3b91a3d90": "Crear un perfil vacío para proyectos y worktrees separados.", - "f322e1f4d6": "Nombre del perfil", - "cfa59f8ad1": "Crear y cambiar", - "b4f9d1125d": "Local", - "4815f7d163": "Cambiar perfil", - "d00d853e2a": "Gestionar perfiles", - "c106c674fe": "Nuevo perfil local", - "org": { - "members": "Miembros de la organización" - }, - "signInWaiting": "Esperando el inicio de sesión…", - "signInAgain": "Volver a iniciar sesión", - "signIn": "Iniciar sesión en Orca", - "accountTitle": "Cuenta de Orca", - "accountSignedIn": "Sesión iniciada", - "accountSignInRequired": "Inicio de sesión requerido", - "accountSignedOut": "Sesión cerrada" - }, - "management": { - "04e7bd2a23": "Transferir", - "128c7dfe64": "Copiar a", - "df8b7d876b": "Mover a", - "9aa26347b3": "Proyecto movido", - "816ce624b6": "Proyecto copiado", - "2c45bda8d3": "Gestionar perfiles", - "2db945e4a0": "Copiar o mover proyectos del perfil activo a otro perfil local.", - "8668cb2946": "No hay proyectos en este perfil.", - "93034915ab": "Cree otro perfil antes de copiar proyectos.", - "projectCountSingular": "{{count}} proyecto", - "projectCountPlural": "{{count}} proyectos" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "¿Mover proyecto?", - "description": "Mover {{repoName}} a {{targetName}}. Orca lo elimina de {{activeProfileName}}, mantiene los archivos en su lugar y relanza en {{targetName}}.", - "action": "Mover proyecto" - }, - "copy": { - "title": "¿Copiar proyecto?", - "description": "Copiar {{repoName}} a {{targetName}}. Ambos perfiles apuntarán a los mismos archivos con metadatos de Orca separados.", - "action": "Copiar proyecto" - }, - "live": { - "work": "Trabajo activo en este proyecto" - }, - "cancel": "Cancelar", - "terminalSingular": "{{count}} pestaña de terminal activa", - "terminalPlural": "{{count}} pestañas de terminal activas", - "agentSingular": "{{count}} agente activo", - "agentPlural": "{{count}} agentes activos", - "browserSingular": "{{count}} espacio de trabajo del navegador", - "browserPlural": "{{count}} espacios de trabajo del navegador" - } - } - }, "signout": { "confirm": { "title": "¿Cerrar sesión en Orca?", @@ -14262,79 +14119,6 @@ "cancel": "Cancelar", "action": "Cerrar sesión" } - }, - "switch": { - "confirm": { - "target": "el perfil seleccionado", - "title": "¿Cambiar perfiles?", - "description": "Cambiar a {{targetName}} relanzará Orca y recargará el espacio de trabajo de {{activeProfileName}}.", - "live": { - "work": "Trabajo activo en este perfil" - }, - "cancel": "Cancelar", - "switch": "Cambiar perfil", - "terminalSingular": "{{count}} pestaña de terminal activa", - "terminalPlural": "{{count}} pestañas de terminal activas", - "agentSingular": "{{count}} agente activo", - "agentPlural": "{{count}} agentes activos", - "browserSingular": "{{count}} espacio de trabajo del navegador", - "browserPlural": "{{count}} espacios de trabajo del navegador" - } - }, - "org": { - "members": { - "role": { - "label": "Rol", - "owner": "Propietario", - "admin": "Administrador", - "member": "Miembro" - }, - "not": { - "signed": { - "in": "Aún no han iniciado sesión en Orca." - } - }, - "remove": { - "confirm": "Eliminar", - "cancel": "Cancelar", - "label": "Eliminar compañero" - }, - "title": "Miembros de la organización", - "load": { - "error": "No se pudieron cargar los miembros de la organización." - }, - "retry": "Intentar de nuevo", - "pending": { - "title": "Invitaciones pendientes", - "revoke": "Revocar" - }, - "invite": { - "title": "Invitar", - "placeholder": "teammate@example.com", - "role": "Rol de invitación", - "submit": "Enviar" - }, - "error": { - "forbidden": "Tu rol no puede hacer eso.", - "conflict": "Ya es miembro o fue invitado.", - "remove": { - "self": "No puedes eliminarte a ti mismo." - }, - "own": { - "role": "No puedes cambiar tu propio rol." - }, - "not": { - "found": "Ese compañero ya no está en esta organización." - }, - "reconnect": "Reconecta tu perfil para gestionar miembros.", - "unconfigured": "La gestión de organización no está disponible en esta compilación.", - "failed": "Algo salió mal. Por favor, intenta de nuevo." - }, - "subtitle": { - "named": "Personas en {{orgName}} que pueden colaborar en Orca.", - "default": "Personas en tu organización que pueden colaborar en Orca." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 76f154d39e8..6865fd30aa6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "閉じる" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "検索に一致するチームはありません。", - "301f2a796e": "全チーム", - "18ec58881e": "チームを検索…" - } - } } }, "terminal": { @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Agent 権限情報", "yoloPermissionsTooltip": "Agent の権限チェックをスキップして割り込みを減らす" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agent は相互にメッセージを送信し、タスクを実行し、引き継ぎを調整できます。", - "399cf885c0": "Agent オーケストレーション", - "c5292c409d": "Agent は、要求に応じてアプリウィンドウを検査し、ローカルアプリを操作できます。", - "1ecfb490ac": "コンピュータ操作", - "01426f3a23": "Agent はサイトを移動し、ページを検査し、ブラウザのタスクを実行できます。", - "ea85d9e628": "Agent ブラウザの使用", - "linearTicketsTitle": "Linear Agent スキル", - "linearTicketsDescription": "Agent はリンクされた Linear タスクを使って、チケットを踏まえたより詳しい引き継ぎができます。", - "linearTicketsSetupSummary": "Linear ワークスペースに推奨。Linear 接続設定には影響しません。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter キーを押してコマンドを実行し、要求されたら npx を確認します。これは、後で [設定] で設定することもできます。", "47fc6cc6dc": "スキルセットアップコマンド", @@ -11589,37 +11569,6 @@ "9f47f345a4": "それほど時間はかかりません!", "e4726b2d50": "オンボーディングをスキップしますか?" }, - "OnboardingTourStep": { - "3f9586c043": "ツアーに参加する", - "60c5576353": "出口ツアー" - }, - "RepoStep": { - "e8fdb36338": "リポジトリをスキャンしています。クリックして停止します。", - "b7c4da0504": "SSH? 「設定」でホストをセットアップする", - "c33b190ca3": "ホストパスのみ", - "7b679207e4": "ワークスペース", - "24c7c8696c": "ホストパスにクローンを作成します", - "7932e95f68": "クローン", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "HTTPS または SSH URL を貼り付けます。", - "132425a3e3": "リポジトリのクローンを作成する", - "6558d50c69": "一度に多くのリポジトリをインポートしたいですか?親フォルダーを選択します。", - "831524961f": "git repo かどうかに関係なく、任意のローカルディレクトリを選択します。", - "f4e9c8dcf8": "フォルダーを参照する", - "e8214aa632": "フォルダーとして開く", - "3863747c56": "Git プロジェクトの追加", - "2ebbc26343": "/home/user/project", - "466108ab89": "ホスト上に存在するパスを入力します。", - "8cab104e3c": "ホストプロジェクトを開く", - "2d20200346": "リポジトリのインポート", - "27ca610db1": "戻る", - "cecd6593fa": "スキャンしたフォルダー:", - "c7af322fc3": "スキャンを停止する", - "c3d9d44ca2": "スキャンの停止", - "cf23006ba7": "選択したホスト", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}このフォルダー内で {{value1}} {{value2}} が見つかりました。" - }, "ThemeStep": { "a4b254779d": "macOS の Option キー", "6c51398942": "マウス", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "SSH 設定を開けませんでした", - "fd74e7558e": "クローン作成に失敗しました", "52acfbef51": "進行状況を保存できませんでした" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "CLI とスキルをインストール" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "利用可能な場合は PowerShell 7+ を使い、代わりに Windows PowerShell を使用します。", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "この Jira Issue をリンクできませんでした。該当するサイトを選択するか Jira に再接続してから、もう一度お試しください。", "emoji": "絵文字" }, - "ProjectHostSetupCombobox": { - "empty": "このプロジェクトで準備が整ったホストはありません。", - "placeholder": "ホストを選択" - }, "ProjectCombobox": { "empty": "検索条件に一致するプロジェクトはありません。", "addProject": "新規プロジェクトを追加", @@ -12135,43 +12075,6 @@ "cda56c5915": "メモ / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "思考中…", - "ef8b164dd1": "review src/auth", - "952d3ddd9a": "セッションが開始されました", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "12 個のテストを実行する", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "分割可能なターミナル", - "2a7cfc82c8": "GH #1842 にリンク", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "開始", - "c1f28c03b2": "ワークツリーピッカーが切り捨てる", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub と Linear タスク", - "5171768676": "3 つの Agent を調整する", - "cebc7769cd": "認証フローを再設計する", - "e44269e97d": "Agent のオーケストレーション", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "請求 Webhook をリファクタリングする", - "9c812e0d7c": "CIパイプラインを高速化する", - "3c4adfd821": "ログイン競合状態を修正", - "56a0271428": "隔離されたワークスペース", - "ef737dcee1": "GitHub と Linear タスク", - "ac51c061e2": "codex", - "47f16ecf34": "複数の作業を同時に進めましょう。各ワークスペースはブランチ、ターミナル、Agent の活動をまとめます。", - "70aa182266": "目標を任せて離れます。コーディネーター Agent が作業を分担し、並列で PR を出します。", - "f10c14dd9d": "タブ切り替えは不要。GitHub または Linear のバックログから選んで、ワンクリックでワークスペースを開始します。", - "5d6ee181b6": "ワークスペースを開いてターミナルに戻り、テスト、ログ、Agent 用にペインを分割します。" - }, "FeatureWallBody": { "25ec5356d6": "設定" }, @@ -12415,11 +12318,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "実行中" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "メモ" @@ -13816,7 +13714,27 @@ "a21f6c33ad": "オートメーションのソースを更新しました。", "53f06f0ad5": "ソースを再試行", "pendingAutomationMissing": "自動化は利用できなくなりました。", - "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。" + "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。", + "noSearchMatches": "検索に一致する自動化がありません。", + "paused": "一時停止中", + "runCount": "{{count}} 回の実行", + "runCount_one": "{{count}} 回の実行", + "runCount_other": "{{count}} 回の実行", + "projectDefaultBaseRef": "プロジェクトのデフォルト", + "createFromBaseRef": "{{baseRef}} から作成", + "missingWorkspace": "ワークスペースがありません", + "runUsageSummary": "約 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "使用量を利用できません", + "noRunUsageYet": "まだ実行の使用量がありません", + "noWorkspace": "ワークスペースなし", + "latestSavedOutput": "最新の保存済み出力", + "backToList": "すべての自動化", + "tableName": "名前", + "tableProject": "プロジェクト", + "tableStatus": "ステータス", + "tableActions": "操作", + "newAutomation": "新しい自動化", + "rowActions": "自動化の操作" }, "CreateFromPicker": { "f061f49e3f": "リポジトリブランチを検索…", @@ -13929,6 +13847,19 @@ "5a7863909c": "新規ワークスペースごとにセットアップを実行", "18f000ad4e": "詳細設定", "874b72195b": "このオートメーションがワークスペースを作成するときに、手動でワークツリーを作成する場合と同じ準備を行います。プロジェクトのセットアップを実行し、ターミナルタブを開きます。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "自動化を読み込み中" + }, + "AutomationListSearchField": { + "tooLong": "検索テキストが長すぎます — リストは未フィルタです", + "label": "自動化を検索", + "placeholder": "検索...", + "tooLongShort": "長すぎます", + "clear": "検索をクリア" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自動化の操作" } }, "agent": { @@ -14181,80 +14112,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca へのサインインは利用できません" - }, - "reconnect": "プロファイルを再接続", - "connect": "プロファイルを接続", - "organization": "組織", - "create": { - "profile": { - "for": { - "org": "組織用のプロファイルを作成" - } - } - }, - "signout": "サインアウト", - "16e3681072": "新規ローカルプロファイル", - "e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。", - "f322e1f4d6": "プロファイル名", - "cfa59f8ad1": "作成して切り替え", - "b4f9d1125d": "ローカル", - "4815f7d163": "プロファイルを切り替え", - "d00d853e2a": "プロファイルを管理", - "c106c674fe": "新規ローカルプロファイル", - "account": "アカウント", - "org": { - "members": "組織メンバー" - }, - "signInWaiting": "サインインを待機中…", - "signInAgain": "もう一度サインイン", - "signIn": "Orca にサインイン", - "accountTitle": "Orca アカウント", - "accountSignedIn": "サインイン済み", - "accountSignInRequired": "サインインが必要です", - "accountSignedOut": "サインアウト済み" - }, - "management": { - "04e7bd2a23": "転送", - "128c7dfe64": "コピー先", - "df8b7d876b": "移動先", - "9aa26347b3": "プロジェクトを移動しました", - "816ce624b6": "プロジェクトをコピーしました", - "2c45bda8d3": "プロファイルを管理", - "2db945e4a0": "アクティブなプロファイルから別のローカルプロファイルへプロジェクトをコピーまたは移動します。", - "8668cb2946": "このプロファイルにはプロジェクトがありません。", - "93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。", - "projectCountSingular": "{{count}} 件のプロジェクト", - "projectCountPlural": "{{count}} 件のプロジェクト" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "プロジェクトを移動しますか?", - "description": "{{repoName}}を{{targetName}}に移動します。Orca は{{activeProfileName}}から削除し、ファイルはそのまま保持し、{{targetName}}に再起動します。", - "action": "プロジェクトを移動" - }, - "copy": { - "title": "プロジェクトをコピーしますか?", - "description": "{{repoName}}を{{targetName}}にコピーします。両方のプロファイルが同じファイルを別の Orca メタデータで参照します。", - "action": "プロジェクトをコピー" - }, - "live": { - "work": "このプロジェクトのアクティブな作業" - }, - "cancel": "キャンセル", - "terminalSingular": "{{count}}個のアクティブなターミナルタブ", - "terminalPlural": "{{count}}個のアクティブなターミナルタブ", - "agentSingular": "{{count}}個のアクティブな Agent", - "agentPlural": "{{count}}個のアクティブな Agent", - "browserSingular": "{{count}}個のブラウザワークスペース", - "browserPlural": "{{count}}個のブラウザワークスペース" - } - } - }, "signout": { "confirm": { "title": "Orca からログアウトしますか?", @@ -14262,79 +14119,6 @@ "cancel": "キャンセル", "action": "サインアウト" } - }, - "switch": { - "confirm": { - "target": "選択されたプロファイル", - "title": "プロファイルを切り替えますか?", - "description": "{{targetName}}に切り替えると、Orca が再起動され、{{activeProfileName}}のワークスペースが再読み込みされます。", - "live": { - "work": "このプロファイルのアクティブな作業" - }, - "cancel": "キャンセル", - "switch": "プロファイルを切り替え", - "terminalSingular": "{{count}}個のアクティブなターミナルタブ", - "terminalPlural": "{{count}}個のアクティブなターミナルタブ", - "agentSingular": "{{count}}個のアクティブな Agent", - "agentPlural": "{{count}}個のアクティブな Agent", - "browserSingular": "{{count}}個のブラウザワークスペース", - "browserPlural": "{{count}}個のブラウザワークスペース" - } - }, - "org": { - "members": { - "role": { - "label": "役割", - "owner": "オーナー", - "admin": "管理者", - "member": "メンバー" - }, - "not": { - "signed": { - "in": "まだ Orca にサインインしていません。" - } - }, - "remove": { - "confirm": "削除", - "cancel": "キャンセル", - "label": "チームメンバーを削除" - }, - "title": "組織メンバー", - "load": { - "error": "組織メンバーを読み込めませんでした。" - }, - "retry": "再試行", - "pending": { - "title": "保留中の招待", - "revoke": "取り消し" - }, - "invite": { - "title": "招待", - "placeholder": "teammate@example.com", - "role": "招待役割", - "submit": "送信" - }, - "error": { - "forbidden": "この役割では実行できません。", - "conflict": "既にメンバーまたは招待済みです。", - "remove": { - "self": "自分自身を削除することはできません。" - }, - "own": { - "role": "自分の役割を変更することはできません。" - }, - "not": { - "found": "そのチームメンバーはこの組織にいません。" - }, - "reconnect": "メンバーを管理するには、プロファイルを再接続してください。", - "unconfigured": "このビルドでは組織管理が利用できません。", - "failed": "問題が発生しました。もう一度お試しください。" - }, - "subtitle": { - "named": "{{orgName}} で Orca を共同利用できるメンバーです。", - "default": "組織内で Orca を共同利用できるメンバーです。" - } - } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1ee6d8ee0f3..8b1d695f107 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "닫기" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "검색어와 일치하는 팀이 없습니다.", - "301f2a796e": "모든 팀", - "18ec58881e": "팀 검색..." - } - } } }, "terminal": { @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Agent 권한 정보", "yoloPermissionsTooltip": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents는 서로 메시지를 보내고, 작업을 수행하고, 핸드오프를 조정할 수 있습니다.", - "399cf885c0": "Agent 오케스트레이션", - "c5292c409d": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다.", - "1ecfb490ac": "컴퓨터 사용", - "01426f3a23": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다.", - "ea85d9e628": "Agent 브라우저 사용", - "linearTicketsTitle": "Linear agent 스킬", - "linearTicketsDescription": "Agents는 연결된 Linear 작업을 사용해 티켓 맥락을 반영한 더 풍부한 인계를 할 수 있습니다.", - "linearTicketsSetupSummary": "Linear 워크스페이스에 권장됩니다. Linear 연결 설정에는 영향을 주지 않습니다." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter를 눌러 명령을 실행하고 요청 시 npx를 확인합니다. 나중에 설정에서 설정할 수도 있습니다.", "47fc6cc6dc": "스킬 설정 명령어", @@ -11589,37 +11569,6 @@ "9f47f345a4": "오래 걸리지 않을 거예요!", "e4726b2d50": "온보딩을 건너뛰시겠습니까?" }, - "OnboardingTourStep": { - "3f9586c043": "둘러보기", - "60c5576353": "투어 종료" - }, - "RepoStep": { - "e8fdb36338": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", - "b7c4da0504": "SSH? 설정에서 호스트를 설정하세요", - "c33b190ca3": "호스트 경로만", - "7b679207e4": "워크스페이스", - "24c7c8696c": "서버 경로로 복제", - "7932e95f68": "클론", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "HTTPS 또는 SSH URL을 붙여넣습니다.", - "132425a3e3": "repo 복제", - "6558d50c69": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요.", - "831524961f": "로컬 디렉터리(git repo 여부)를 선택하세요.", - "f4e9c8dcf8": "폴더 찾아보기", - "e8214aa632": "폴더로 열기", - "3863747c56": "Git 프로젝트 추가", - "2ebbc26343": "/home/user/project", - "466108ab89": "선택한 호스트에 존재하는 경로를 입력하세요.", - "8cab104e3c": "서버 프로젝트 열기", - "2d20200346": "repos 가져오기", - "27ca610db1": "뒤로", - "cecd6593fa": "스캔한 폴더:", - "c7af322fc3": "스캔 중지", - "c3d9d44ca2": "스캔 중지", - "cf23006ba7": "선택한 호스트", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}이 폴더에서 {{value1}} {{value2}}을(를) 찾았습니다." - }, "ThemeStep": { "a4b254779d": "macOS 옵션 키", "6c51398942": "마우스", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "SSH 설정을 열 수 없습니다.", - "fd74e7558e": "복제 실패", "52acfbef51": "진행 상황을 저장할 수 없습니다." } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "기능 활성화" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "사용할 수 있으면 PowerShell 7+를 사용하고, 없으면 Windows PowerShell을 사용합니다.", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "이 프로젝트에 준비된 호스트가 없습니다.", - "placeholder": "호스트 선택" - }, "ProjectCombobox": { "empty": "검색과 일치하는 프로젝트가 없습니다.", "addProject": "새 프로젝트 추가", @@ -12135,43 +12075,6 @@ "cda56c5915": "메모 / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "생각 중...", - "ef8b164dd1": "src/auth 리뷰", - "952d3ddd9a": "세션이 시작되었습니다", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "12개 테스트 실행", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "분할 가능한 terminal", - "2a7cfc82c8": "GH #1842와 연결됨", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "시작", - "c1f28c03b2": "작업 트리 선택 도구가 잘립니다.", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub 및 Linear 작업", - "5171768676": "agents 3개 조율", - "cebc7769cd": "인증 흐름 재설계", - "e44269e97d": "Agent 오케스트레이션", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "청구 웹훅 리팩터링", - "9c812e0d7c": "CI 파이프라인 속도 향상", - "3c4adfd821": "로그인 경쟁 조건 수정", - "56a0271428": "격리된 워크스페이스", - "ef737dcee1": "GitHub 및 Linear 작업", - "ac51c061e2": "codex", - "47f16ecf34": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다.", - "70aa182266": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다.", - "f10c14dd9d": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요.", - "5d6ee181b6": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, "FeatureWallBody": { "25ec5356d6": "설정" }, @@ -12415,11 +12318,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "작업 중" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "메모" @@ -13827,7 +13725,27 @@ "a21f6c33ad": "자동화 소스를 새로 고쳤습니다.", "53f06f0ad5": "소스 다시 시도", "pendingAutomationMissing": "자동화를 더 이상 사용할 수 없습니다.", - "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다." + "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", + "noSearchMatches": "검색과 일치하는 자동화가 없습니다.", + "paused": "일시 중지됨", + "runCount": "실행 {{count}}회", + "runCount_one": "실행 {{count}}회", + "runCount_other": "실행 {{count}}회", + "projectDefaultBaseRef": "프로젝트 기본값", + "createFromBaseRef": "{{baseRef}}에서 만들기", + "missingWorkspace": "워크스페이스 없음", + "runUsageSummary": "약 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "사용량을 사용할 수 없음", + "noRunUsageYet": "아직 실행 사용량 없음", + "noWorkspace": "워크스페이스 없음", + "latestSavedOutput": "최근 저장된 출력", + "backToList": "모든 자동화", + "tableName": "이름", + "tableProject": "프로젝트", + "tableStatus": "상태", + "tableActions": "작업", + "newAutomation": "새 자동화", + "rowActions": "자동화 작업" }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", @@ -13940,6 +13858,19 @@ "5a7863909c": "새 워크스페이스마다 설정 실행", "18f000ad4e": "고급", "874b72195b": "이 자동화가 워크스페이스를 만들 때, 워크트리를 직접 만들 때와 같은 방식으로 준비합니다 — 프로젝트 설정을 실행하고 터미널 탭을 엽니다." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "자동화 로딩 중" + }, + "AutomationListSearchField": { + "tooLong": "검색 텍스트가 너무 깁니다 — 목록이 필터링되지 않음", + "label": "자동화 검색", + "placeholder": "검색...", + "tooLongShort": "너무 김", + "clear": "검색 지우기" + }, + "AutomationListLocalRows": { + "c92c9463c6": "자동화 작업" } }, "agent": { @@ -14192,80 +14123,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca 로그인 사용 불가" - }, - "reconnect": "프로필 다시 연결", - "connect": "프로필 연결", - "organization": "조직", - "create": { - "profile": { - "for": { - "org": "조직용 프로필 생성" - } - } - }, - "signout": "로그아웃", - "16e3681072": "새 로컬 프로필", - "e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.", - "f322e1f4d6": "프로필 이름", - "cfa59f8ad1": "만들고 전환", - "b4f9d1125d": "로컬", - "4815f7d163": "프로필 전환", - "d00d853e2a": "프로필 관리", - "c106c674fe": "새 로컬 프로필", - "account": "계정", - "org": { - "members": "조직 구성원" - }, - "signInWaiting": "로그인 대기 중…", - "signInAgain": "다시 로그인", - "signIn": "Orca에 로그인", - "accountTitle": "Orca 계정", - "accountSignedIn": "로그인됨", - "accountSignInRequired": "로그인 필요", - "accountSignedOut": "로그아웃됨" - }, - "management": { - "04e7bd2a23": "전송", - "128c7dfe64": "복사 대상", - "df8b7d876b": "이동 대상", - "9aa26347b3": "프로젝트를 이동했습니다", - "816ce624b6": "프로젝트를 복사했습니다", - "2c45bda8d3": "프로필 관리", - "2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.", - "8668cb2946": "이 프로필에는 프로젝트가 없습니다.", - "93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.", - "projectCountSingular": "{{count}}개의 프로젝트", - "projectCountPlural": "{{count}}개의 프로젝트" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", - "action": "프로젝트 이동" - }, - "copy": { - "title": "프로젝트를 복사하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필 모두 동일한 파일을 별도의 Orca 메타데이터와 함께 가리킵니다.", - "action": "프로젝트 복사" - }, - "live": { - "work": "이 프로젝트의 활성 작업" - }, - "cancel": "취소", - "terminalSingular": "활성 터미널 탭 {{count}}개", - "terminalPlural": "활성 터미널 탭 {{count}}개", - "agentSingular": "활성 에이전트 {{count}}개", - "agentPlural": "활성 에이전트 {{count}}개", - "browserSingular": "브라우저 작업 공간 {{count}}개", - "browserPlural": "브라우저 작업 공간 {{count}}개" - } - } - }, "signout": { "confirm": { "title": "Orca에서 로그아웃할까요?", @@ -14273,79 +14130,6 @@ "cancel": "취소", "action": "로그아웃" } - }, - "switch": { - "confirm": { - "target": "선택된 프로필", - "title": "프로필을 전환하시겠습니까?", - "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 작업 공간이 다시 로드됩니다.", - "live": { - "work": "이 프로필의 활성 작업" - }, - "cancel": "취소", - "switch": "프로필 전환", - "terminalSingular": "활성 터미널 탭 {{count}}개", - "terminalPlural": "활성 터미널 탭 {{count}}개", - "agentSingular": "활성 에이전트 {{count}}개", - "agentPlural": "활성 에이전트 {{count}}개", - "browserSingular": "브라우저 작업 공간 {{count}}개", - "browserPlural": "브라우저 작업 공간 {{count}}개" - } - }, - "org": { - "members": { - "role": { - "label": "역할", - "owner": "소유자", - "admin": "관리자", - "member": "구성원" - }, - "not": { - "signed": { - "in": "아직 Orca에 로그인하지 않았습니다." - } - }, - "remove": { - "confirm": "제거", - "cancel": "취소", - "label": "팀원 제거" - }, - "title": "조직 구성원", - "load": { - "error": "조직 구성원을 로드할 수 없습니다." - }, - "retry": "다시 시도", - "pending": { - "title": "대기 중인 초대", - "revoke": "취소" - }, - "invite": { - "title": "초대", - "placeholder": "teammate@example.com", - "role": "초대 역할", - "submit": "보내기" - }, - "error": { - "forbidden": "이 작업을 수행할 권한이 없습니다.", - "conflict": "이미 구성원이거나 초대되었습니다.", - "remove": { - "self": "자신을 제거할 수 없습니다." - }, - "own": { - "role": "자신의 역할을 변경할 수 없습니다." - }, - "not": { - "found": "해당 팀원은 이 조직에 없습니다." - }, - "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", - "unconfigured": "이 빌드에서 조직 관리를 사용할 수 없습니다.", - "failed": "문제가 발생했습니다. 다시 시도해 주세요." - }, - "subtitle": { - "named": "{{orgName}}에서 Orca에 협업할 수 있는 사람들.", - "default": "조직에서 Orca에 협업할 수 있는 사람들." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6ce82f72417..f91467d7bff 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2616,15 +2616,6 @@ }, "sheet": { "1189e9fe0a": "关闭" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "没有团队符合您的搜索。", - "301f2a796e": "所有团队", - "18ec58881e": "搜索团队..." - } - } } }, "terminal": { @@ -11513,17 +11504,6 @@ "yoloPermissionsInfo": "智能体权限信息", "yoloPermissionsTooltip": "跳过智能体权限检查,减少打断" }, - "FeatureSetupChecklist": { - "77f74946f5": "智能体可互相通信、领取任务并协调交接。", - "399cf885c0": "智能体编排", - "c5292c409d": "按你的要求,智能体可查看应用窗口并操作本地应用。", - "1ecfb490ac": "计算机控制", - "01426f3a23": "智能体可浏览网站、检查页面并完成浏览器任务。", - "ea85d9e628": "智能体浏览器操控", - "linearTicketsTitle": "Linear 智能体技能", - "linearTicketsDescription": "智能体可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", - "linearTicketsSetupSummary": "推荐用于 Linear 工作区;不会影响 Linear 连接设置。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "按 Enter 运行命令并在询问时确认 npx。您也可以稍后在“设置”中进行设置。", "47fc6cc6dc": "技能设置命令", @@ -11609,37 +11589,6 @@ "9f47f345a4": "不会花很长时间的!", "e4726b2d50": "跳过入门引导?" }, - "OnboardingTourStep": { - "3f9586c043": "开始导览", - "60c5576353": "退出导览" - }, - "RepoStep": { - "e8fdb36338": "扫描存储库。单击停止。", - "b7c4da0504": "SSH?在“设置”中设置主机", - "c33b190ca3": "仅服务器路径", - "7b679207e4": "工作区", - "24c7c8696c": "克隆到服务器路径", - "7932e95f68": "克隆", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "粘贴 HTTPS 或 SSH URL。", - "132425a3e3": "克隆一个仓库", - "6558d50c69": "想要一次导入多个仓库?选择父文件夹。", - "831524961f": "选择任何本地目录,无论是否为 git 仓库。", - "f4e9c8dcf8": "浏览文件夹", - "e8214aa632": "作为文件夹打开", - "3863747c56": "添加 Git 项目", - "2ebbc26343": "/home/user/project", - "466108ab89": "输入运行时服务器上存在的路径。", - "8cab104e3c": "打开服务器项目", - "2d20200346": "导入存储库", - "27ca610db1": "返回", - "cecd6593fa": "扫描的文件夹:", - "c7af322fc3": "停止扫描", - "c3d9d44ca2": "停止扫描", - "cf23006ba7": "运行时服务器", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}在此文件夹中找到{{value1}} {{value2}}。" - }, "ThemeStep": { "a4b254779d": "macOS Option 键", "6c51398942": "鼠标", @@ -11667,15 +11616,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "无法打开 SSH 设置", - "fd74e7558e": "克隆失败", "52acfbef51": "无法保存进度" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "安装 CLI 和技能" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "在可用时使用 PowerShell 7+,否则回退到 Windows PowerShell。", @@ -11776,10 +11720,6 @@ "jiraSelectBindFailed": "无法关联此 Jira 议题。请选择匹配的站点或重新连接 Jira,然后重试。", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "此项目没有已就绪的主机。", - "placeholder": "选择主机" - }, "ProjectCombobox": { "empty": "没有匹配搜索的项目。", "addProject": "添加新项目", @@ -12155,43 +12095,6 @@ "cda56c5915": "笔记/launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "思维...", - "ef8b164dd1": "审查 src/auth", - "952d3ddd9a": "会话开始", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "运行 12 项测试", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "可拆分终端", - "2a7cfc82c8": "链接到 GH #1842", - "3822d8d14b": "修复/worktree-picker-截断", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "开始", - "c1f28c03b2": "工作树选择器截断", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub 和 Linear 任务", - "5171768676": "协调3个智能体", - "cebc7769cd": "重新设计身份验证流程", - "e44269e97d": "智能体编排", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "重构计费 Webhook", - "9c812e0d7c": "加速 CI 管道", - "3c4adfd821": "修复登录竞争条件", - "56a0271428": "独立的工作区", - "ef737dcee1": "GitHub 和 Linear 任务", - "ac51c061e2": "codex", - "47f16ecf34": "同时推进多项工作。每个工作区都会把分支、终端和智能体活动放在一起。", - "70aa182266": "交给一个目标后就可以离开。协调智能体会分发工作并并行交付 PR。", - "f10c14dd9d": "跳过来回切换标签页。从 GitHub 或 Linear 待办中挑选任务,一键启动工作区。", - "5d6ee181b6": "打开任意工作区即可回到它的终端,然后拆分窗格运行测试、日志和智能体。" - }, "FeatureWallBody": { "25ec5356d6": "设置" }, @@ -12435,11 +12338,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "处理中" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "笔记" @@ -13836,7 +13734,27 @@ "a21f6c33ad": "自动化来源已刷新。", "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", - "pendingAutomationRunMissing": "运行历史已不可用。" + "pendingAutomationRunMissing": "运行历史已不可用。", + "noSearchMatches": "没有匹配的自动化。", + "paused": "已暂停", + "runCount": "{{count}} 次运行", + "runCount_one": "{{count}} 次运行", + "runCount_other": "{{count}} 次运行", + "projectDefaultBaseRef": "项目默认", + "createFromBaseRef": "从 {{baseRef}} 创建", + "missingWorkspace": "缺少工作区", + "runUsageSummary": "约 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "用量不可用", + "noRunUsageYet": "尚无运行用量", + "noWorkspace": "无工作区", + "latestSavedOutput": "最近保存的输出", + "backToList": "全部自动化", + "tableName": "名称", + "tableProject": "项目", + "tableStatus": "状态", + "tableActions": "操作", + "newAutomation": "新建自动化", + "rowActions": "自动化操作" }, "CreateFromPicker": { "f061f49e3f": "搜索仓库分支...", @@ -13949,6 +13867,19 @@ "5a7863909c": "为每个新工作区运行设置", "18f000ad4e": "高级", "874b72195b": "当此自动化创建工作区时,使用与手动创建工作树相同的方式准备——运行项目的设置并打开其终端标签页。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "正在加载自动化" + }, + "AutomationListSearchField": { + "tooLong": "搜索文本过长 — 列表未筛选", + "label": "搜索自动化", + "placeholder": "搜索...", + "tooLongShort": "过长", + "clear": "清除搜索" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自动化操作" } }, "agent": { @@ -14201,80 +14132,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca 登录不可用" - }, - "reconnect": "重新连接配置文件", - "connect": "连接配置文件", - "organization": "组织", - "create": { - "profile": { - "for": { - "org": "为组织创建配置文件" - } - } - }, - "signout": "退出登录", - "16e3681072": "新建本地配置文件", - "e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。", - "f322e1f4d6": "配置文件名称", - "cfa59f8ad1": "创建并切换", - "b4f9d1125d": "本地", - "4815f7d163": "切换配置文件", - "d00d853e2a": "管理配置文件", - "c106c674fe": "新建本地配置文件", - "account": "帐户", - "org": { - "members": "组织成员" - }, - "signInWaiting": "正在等待登录…", - "signInAgain": "重新登录", - "signIn": "登录 Orca", - "accountTitle": "Orca 帐户", - "accountSignedIn": "已登录", - "accountSignInRequired": "需要登录", - "accountSignedOut": "已退出登录" - }, - "management": { - "04e7bd2a23": "转移", - "128c7dfe64": "复制到", - "df8b7d876b": "移动到", - "9aa26347b3": "项目已移动", - "816ce624b6": "项目已复制", - "2c45bda8d3": "管理配置文件", - "2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。", - "8668cb2946": "此配置文件中没有项目。", - "93034915ab": "复制项目前,请先创建另一个配置文件。", - "projectCountSingular": "{{count}} 个项目", - "projectCountPlural": "{{count}} 个项目" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "移动项目?", - "description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。", - "action": "移动项目" - }, - "copy": { - "title": "复制项目?", - "description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。", - "action": "复制项目" - }, - "live": { - "work": "此项目中正在进行的工作" - }, - "cancel": "取消", - "terminalSingular": "{{count}} 个正在运行的终端选项卡", - "terminalPlural": "{{count}} 个正在运行的终端选项卡", - "agentSingular": "{{count}} 个活动的智能体", - "agentPlural": "{{count}} 个活动的智能体", - "browserSingular": "{{count}} 个浏览器工作区", - "browserPlural": "{{count}} 个浏览器工作区" - } - } - }, "signout": { "confirm": { "title": "退出 Orca?", @@ -14282,79 +14139,6 @@ "cancel": "取消", "action": "退出登录" } - }, - "switch": { - "confirm": { - "target": "所选配置文件", - "title": "切换配置文件?", - "description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。", - "live": { - "work": "此配置文件中正在进行的工作" - }, - "cancel": "取消", - "switch": "切换配置文件", - "terminalSingular": "{{count}} 个正在运行的终端选项卡", - "terminalPlural": "{{count}} 个正在运行的终端选项卡", - "agentSingular": "{{count}} 个活动的智能体", - "agentPlural": "{{count}} 个活动的智能体", - "browserSingular": "{{count}} 个浏览器工作区", - "browserPlural": "{{count}} 个浏览器工作区" - } - }, - "org": { - "members": { - "role": { - "label": "角色", - "owner": "所有者", - "admin": "管理员", - "member": "成员" - }, - "not": { - "signed": { - "in": "他们尚未登录 Orca。" - } - }, - "remove": { - "confirm": "移除", - "cancel": "取消", - "label": "移除成员" - }, - "title": "组织成员", - "load": { - "error": "无法加载组织成员。" - }, - "retry": "重试", - "pending": { - "title": "待处理的邀请", - "revoke": "撤销" - }, - "invite": { - "title": "邀请", - "placeholder": "teammate@example.com", - "role": "邀请角色", - "submit": "发送" - }, - "error": { - "forbidden": "你的角色无法执行此操作。", - "conflict": "已是成员或已被邀请。", - "remove": { - "self": "无法移除你自己。" - }, - "own": { - "role": "无法更改你自己的角色。" - }, - "not": { - "found": "该成员已不在此组织中。" - }, - "reconnect": "请重新连接你的配置文件以管理成员。", - "unconfigured": "此版本不支持组织管理。", - "failed": "出现问题。请重试。" - }, - "subtitle": { - "named": "{{orgName}} 中可以协作使用 Orca 的成员。", - "default": "你的组织中可以协作使用 Orca 的成员。" - } - } } } }, diff --git a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts index c286d80858e..5cf9963175b 100644 --- a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts +++ b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts @@ -43,12 +43,6 @@ const repairedEntries = [ ['zh', 'auto.components.settings.repository.search.603c68b68c'], ['zh', 'auto.components.right.sidebar.index.6306b48afd'], ['zh', 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.24fedd5a52'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.6218a9014d'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.04d54d50ec'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.d54aefe09e'], - ['zh', 'auto.components.feature.wall.FeatureTourPreview.0688842445'], ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.defe550fe2'], ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.4371cc9931'], ['zh', 'auto.components.feature.wall.ReviewAnimatedVisual.8df4d52b68'], @@ -70,7 +64,7 @@ function readValue(catalog: object, key: string): unknown { describe('technical literal catalog repairs (#13121)', () => { it('keeps every repaired value identical to English', () => { - expect(repairedEntries).toHaveLength(49) + expect(repairedEntries).toHaveLength(43) for (const [locale, key] of repairedEntries) { expect(readValue(catalogs[locale], key), `${locale}:${key}`).toBe(readValue(en, key)) diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 9f31770be7e..b6c381dc5a9 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -1,5 +1,6 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' import type { GlobalSettings } from '../../../shared/types' +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -189,19 +190,6 @@ function getSanitizedUtf8ByteLengthForCodePoint(codePoint: number): number { ) } -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, diff --git a/src/renderer/src/lib/comment-body-submit-state.ts b/src/renderer/src/lib/comment-body-submit-state.ts index be3cc7ddb2e..4a7066f6408 100644 --- a/src/renderer/src/lib/comment-body-submit-state.ts +++ b/src/renderer/src/lib/comment-body-submit-state.ts @@ -1,3 +1,5 @@ +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' + export const COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES = 64 * 1024 export type CommentBodySubmitState = @@ -7,19 +9,6 @@ export type CommentBodySubmitState = type CommentBodyPresence = 'empty' | 'present' | 'too-large-leading-whitespace' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - function getCommentBodyPresence( body: string, maxScanBytes = COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES @@ -29,7 +18,7 @@ function getCommentBodyPresence( for (let index = 0; index < body.length; index += 1) { const codePoint = body.codePointAt(index) ?? 0 const codeUnitLength = codePoint > 0xffff ? 2 : 1 - scannedBytes += getCodePointUtf8ByteLength(codePoint) + scannedBytes += getUtf8ByteLengthForCodePoint(codePoint) if (scannedBytes > maxScanBytes) { return 'too-large-leading-whitespace' } diff --git a/src/renderer/src/lib/flatten-retained-slice.test.ts b/src/renderer/src/lib/flatten-retained-slice.test.ts new file mode 100644 index 00000000000..2eedfc5c6ad --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { flattenRetainedSlice } from './flatten-retained-slice' + +// Why 1 MB: comfortably above V8's SlicedString threshold, so a raw slice really does keep the +// parent alive and the difference between flattened and not is unmistakable in heapUsed. +const PARENT_CHARS = 1024 * 1024 +const TAIL_CHARS = 512 +const PARENTS = 8 + +function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed +} + +// Distinct leading chars stop V8 sharing one backing store across the parents. +function makeParent(index: number, filler: string): string { + return String.fromCharCode(0x41 + index) + filler.repeat(PARENT_CHARS - 1) +} + +function retainedBytesForTails(filler: string, transform: (value: string) => string): number { + const parents = Array.from({ length: PARENTS }, (_unused, index) => makeParent(index, filler)) + const baseline = collect() + const tails = parents.map((parent) => transform(parent.slice(parent.length - TAIL_CHARS))) + // Drop the parents; only the tails stay reachable. + parents.length = 0 + const retained = collect() - baseline + expect(tails).toHaveLength(PARENTS) + expect(tails.every((tail) => tail.length === TAIL_CHARS)).toBe(true) + return retained +} + +describe('flattenRetainedSlice', () => { + it('preserves content exactly', () => { + expect(flattenRetainedSlice('')).toBe('') + expect(flattenRetainedSlice('a')).toBe('a') + const source = 'hello world, 漢字, , \u{1f600}' + expect(flattenRetainedSlice(source.slice(3))).toBe(source.slice(3)) + }) + + it.each([ + ['one-byte', 'a'], + ['two-byte', '漢'] + ])('drops the %s parent a raw slice would pin', (_label, filler) => { + const raw = retainedBytesForTails(filler, (value) => value) + const flattened = retainedBytesForTails(filler, flattenRetainedSlice) + + // A raw slice pins all 8 parents; flattening must keep only the tails. + expect(raw).toBeGreaterThan(PARENTS * PARENT_CHARS * 0.5) + expect(flattened).toBeLessThan(PARENTS * PARENT_CHARS * 0.05) + }) +}) diff --git a/src/renderer/src/lib/flatten-retained-slice.ts b/src/renderer/src/lib/flatten-retained-slice.ts new file mode 100644 index 00000000000..45585c3a75e --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.ts @@ -0,0 +1,4 @@ +// V8 slices can retain their full parent; force a standalone copy for values that outlive it. +export function flattenRetainedSlice(value: string): string { + return value.length === 0 ? value : `${value} `.slice(0, -1) +} diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts deleted file mode 100644 index ef7adfdb2b9..00000000000 --- a/src/renderer/src/lib/github-work-item-background-create.test.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import type { RuntimeStatus } from '../../../shared/runtime-types' -import type { GitHubWorkItem, Repo } from '../../../shared/types' -import { - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION -} from '../../../shared/protocol-version' - -vi.mock('@/lib/tui-agent-startup', () => ({ - buildAgentDraftLaunchPlan: vi.fn(() => null), - buildAgentStartupPlan: vi.fn(() => ({ - agent: 'codex', - launchCommand: 'codex', - expectedProcess: 'codex', - followupPrompt: null, - launchConfig: { kind: 'shell', command: 'codex' } - })) -})) - -vi.mock('@/lib/telemetry', () => ({ - tuiAgentToAgentKind: (agent: string) => agent -})) - -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' -import { createGitHubWorkItemWorkspaceInBackground } from './github-work-item-background-create' - -const repo: Repo = { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: 'blue', - addedAt: 1 -} - -function makeRuntimeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-1', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - ...overrides - } -} - -function makeIssue(overrides: Partial = {}): GitHubWorkItem { - return { - id: 'I_42', - repoId: 'repo-1', - type: 'issue', - number: 42, - title: 'Make issue workspace creation async', - url: 'https://github.com/stablyai/orca/issues/42', - state: 'open', - author: null, - labels: [], - assignees: [], - createdAt: '2026-06-22T00:00:00Z', - updatedAt: '2026-06-22T00:00:00Z', - commentsCount: 0, - ...overrides - } as GitHubWorkItem -} - -function makeStore(overrides: Partial> = {}) { - return { ...baseStore(), ...overrides } -} - -function baseStore() { - return { - activeView: 'tasks' as const, - repos: [repo], - pendingWorktreeCreations: {}, - sshConnectionStates: new Map(), - runtimeStatusByEnvironmentId: new Map(), - settings: { - activeRuntimeEnvironmentId: null, - defaultTuiAgent: 'codex' as const, - disabledTuiAgents: [] - }, - ensureDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRuntimeDetectedAgents: vi.fn().mockResolvedValue([]) - } -} - -function makeDeps(store = makeStore()) { - return { - getStore: () => store, - getActiveView: vi.fn(() => store.activeView), - hasPendingCreate: vi.fn(() => true), - isPendingCreateActive: vi.fn(() => true), - resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }), - resolvePrStartPoint: vi.fn(), - confirmHooks: vi.fn().mockResolvedValue('run'), - readIssueCommand: vi.fn().mockResolvedValue({ - effectiveContent: null, - localContent: null, - sharedContent: null, - localFilePath: '', - source: 'none' - }), - beginBackgroundCreate: vi.fn(() => 'creation-1'), - continueBackgroundCreate: vi.fn(() => true), - activatePendingCreate: vi.fn(), - removePendingCreate: vi.fn(), - setActiveView: vi.fn(), - toastError: vi.fn() - } -} - -describe('createGitHubWorkItemWorkspaceInBackground', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('starts a background issue workspace without opening the composer', async () => { - const deps = makeDeps() - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - telemetrySource: 'sidebar', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - expect(openModalFallback).not.toHaveBeenCalled() - expect(deps.beginBackgroundCreate).toHaveBeenCalledWith( - expect.objectContaining({ - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - displayName: 'Issue 42 Make Issue Workspace', - linkedIssue: 42, - telemetrySource: 'sidebar', - setupDecision: 'inherit', - agent: null - }) - ) - expect(deps.continueBackgroundCreate).toHaveBeenCalledWith( - 'creation-1', - expect.objectContaining({ - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - displayName: 'Issue 42 Make Issue Workspace', - linkedIssue: 42, - telemetrySource: 'sidebar', - setupDecision: 'inherit', - agent: null, - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - }), - { revealCreationSurface: false } - ) - }) - - it('does not resolve a PR start point for a stale PR-typed issue URL', async () => { - const deps = makeDeps() - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ - type: 'pr', - number: 6933, - title: 'The board columns are displayed backwards', - url: 'https://github.com/stablyai/orca/issues/6933', - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }), - repoId: 'repo-1', - telemetrySource: 'sidebar', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.resolvePrStartPoint).not.toHaveBeenCalled() - const beginCalls = deps.beginBackgroundCreate.mock.calls as unknown as [ - WorktreeCreationRequest - ][] - expect(beginCalls[0]?.[0].linkedIssue).toBe(6933) - expect(beginCalls[0]?.[0].linkedPR).toBeUndefined() - const continueCalls = deps.continueBackgroundCreate.mock.calls as unknown as [ - string, - WorktreeCreationRequest - ][] - const request = continueCalls[0]?.[1] - expect(request?.linkedIssue).toBe(6933) - expect(request?.linkedPR).toBeUndefined() - expect(request?.baseBranch).toBeUndefined() - expect(request?.pushTarget).toBeUndefined() - expect(request?.branchNameOverride).toBeUndefined() - expect(request?.compareBaseRef).toBeUndefined() - }) - - it('shows the pending workspace before async preflight resolves', async () => { - let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null = - null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'decided'; decision: 'inherit' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.beginBackgroundCreate).toHaveBeenCalledTimes(1) - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'decided', decision: 'inherit' }) - await pending - - expect(deps.continueBackgroundCreate).toHaveBeenCalledTimes(1) - }) - - it('reuses an existing pending issue create instead of staging a duplicate', async () => { - const pendingRequest: WorktreeCreationRequest = { - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - setupDecision: 'inherit', - linkedIssue: 42, - agent: null, - pendingFirstAgentMessageRename: false, - note: '', - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - } - const deps = makeDeps( - makeStore({ - pendingWorktreeCreations: { - 'creation-existing': { - creationId: 'creation-existing', - phase: 'preparing', - status: 'creating', - indeterminate: false, - loaderVisible: true, - request: pendingRequest - } - } - }) - ) - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - expect(deps.activatePendingCreate).toHaveBeenCalledWith('creation-existing') - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back without staging when the SSH repo is disconnected', async () => { - const sshRepo: Repo = { - ...repo, - connectionId: 'devbox' - } - const deps = makeDeps( - makeStore({ - repos: [sshRepo], - sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - }) - - it('falls back without staging when the runtime repo is unreachable', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const deps = makeDeps( - makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([['env-1', { status: null, checkedAt: 1 }]]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back without staging when the runtime host platform is unknown', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const deps = makeDeps( - makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([ - ['env-1', { status: makeRuntimeStatus({ runtimeId: 'runtime-env-1' }), checkedAt: 1 }] - ]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('uses the preferred quick agent when one is available', async () => { - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.agent).toBe('codex') - expect(request.startupPlan?.launchCommand).toBe('codex') - expect(request.startup).toBeUndefined() - expect(request.quickTelemetry).toEqual({ - agent_kind: 'codex', - launch_source: 'new_workspace_composer', - request_kind: 'new' - }) - expect(buildAgentStartupPlan).toHaveBeenCalled() - }) - - it('uses runtime-owned detection and indeterminate progress for runtime repos', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const store = makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([ - [ - 'env-1', - { - status: makeRuntimeStatus({ runtimeId: 'runtime-env-1', hostPlatform: 'win32' }), - checkedAt: 1 - } - ] - ]), - ensureDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRuntimeDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(store.ensureRuntimeDetectedAgents).toHaveBeenCalledWith('env-1') - expect(store.ensureDetectedAgents).not.toHaveBeenCalled() - expect(store.ensureRemoteDetectedAgents).not.toHaveBeenCalled() - expect(deps.beginBackgroundCreate).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeCreateProgressMode: 'indeterminate', - workspaceRunContext: expect.objectContaining({ - hostId: 'runtime:env-1', - repoId: 'repo-1' - }) - }) - ) - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.worktreeCreateProgressMode).toBe('indeterminate') - expect(request.agent).toBe('codex') - expect(buildAgentStartupPlan).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'win32' }) - ) - }) - - it('falls back before creating when the selected agent startup plan cannot be built', async () => { - vi.mocked(buildAgentStartupPlan).mockReturnValueOnce(null) - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'agent-startup' }) - expect(deps.toastError).toHaveBeenCalledWith('Could not build the agent launch command.') - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('stops before opening the composer when the staged create is cancelled during setup preflight', async () => { - let resolveSetupDecision: ((value: { kind: 'needs-modal' }) => void) | null = null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'needs-modal' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - const openModalFallback = vi.fn() - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - deps.hasPendingCreate.mockReturnValue(false) - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'needs-modal' }) - - expect(await pending).toEqual({ kind: 'background-started' }) - expect(openModalFallback).not.toHaveBeenCalled() - expect(deps.removePendingCreate).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('stops before hook trust and agent detection when the staged create is cancelled', async () => { - let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null = - null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'decided'; decision: 'inherit' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - deps.hasPendingCreate.mockReturnValue(false) - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'decided', decision: 'inherit' }) - - expect(await pending).toEqual({ kind: 'background-started' }) - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back to the composer when setup policy requires an explicit choice', async () => { - const deps = makeDeps() - deps.resolveSetupDecision.mockResolvedValueOnce({ kind: 'needs-modal' }) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'setup-ask' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('leaves the current view alone on fallback after the user leaves the staged create', async () => { - const deps = makeDeps() - deps.resolveSetupDecision.mockResolvedValueOnce({ kind: 'needs-modal' }) - deps.isPendingCreateActive.mockReturnValueOnce(false) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).not.toHaveBeenCalled() - }) - - it('falls back to the composer when PR start point cannot be resolved', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockRejectedValueOnce(new Error('No PR head')) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'pr-start-point' }) - expect(deps.toastError).toHaveBeenCalledWith('No PR head') - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('includes resolved PR start-point data in the background request', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockResolvedValueOnce({ - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ - type: 'pr', - number: 6934, - url: 'https://github.com/stablyai/orca/pull/6934', - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.resolvePrStartPoint).toHaveBeenCalledWith( - 'repo-1', - 6934, - expect.anything(), - expect.objectContaining({ - type: 'pr', - number: 6934, - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }) - ) - expect(deps.continueBackgroundCreate).toHaveBeenCalledWith( - 'creation-1', - expect.objectContaining({ - linkedPR: 6934, - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }), - { revealCreationSurface: false } - ) - }) - - it('prefers native draft startup when the agent supports it', async () => { - vi.mocked(buildAgentDraftLaunchPlan).mockReturnValueOnce({ - agent: 'codex', - launchCommand: 'codex --prompt-file', - expectedProcess: 'codex', - launchConfig: { agentCommand: 'codex', agentArgs: '--prompt-file', agentEnv: {} } - }) - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.startupPlan?.launchCommand).toBe('codex --prompt-file') - expect(request.startup?.command).toBe('codex --prompt-file') - expect(buildAgentStartupPlan).not.toHaveBeenCalled() - }) - - it('attaches the rendered issue command when the repo configured one and trust runs', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}', - localContent: null, - sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}', - localFilePath: '', - source: 'shared' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup') - expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand?.command).toBe( - 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' - ) - }) - - it('omits the issue command when the repo configured none', async () => { - const deps = makeDeps() - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - }) - - it('skips the issue command when setup trust was declined', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - // Why: the setup confirmHooks call resolves 'skip', which mirrors the - // composer suppressing the issue command when setup trust is declined. - deps.confirmHooks.mockResolvedValue('skip') - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - // Why: a declined setup trust short-circuits before the (up-to-15s) read, - // so workspace creation is never stalled to fetch a command we will drop. - expect(deps.readIssueCommand).not.toHaveBeenCalled() - }) - - it('does not run the issue command for PR items', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockResolvedValueOnce({ - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - }) - - it('fails closed when reading the issue command rejects', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline')) - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - }) - - it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => { - const snapshots = [makeStore(), makeStore(), makeStore()] - let getStoreCall = 0 - const deps = makeDeps(snapshots[0]) - deps.getStore = vi.fn( - () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0] - ) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: null, - sharedContent: 'echo {{issue}}', - localFilePath: '', - source: 'shared' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - // Why: an "Always trust" stamped by the setup prompt only exists in a fresh - // snapshot. Each trust check must read the store at call time instead of - // reusing the snapshot captured when the flow began. - const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup') - const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand') - expect(setupCall?.[0]).toBeDefined() - expect(issueCall?.[0]).toBeDefined() - expect(setupCall?.[0]).not.toBe(snapshots[0]) - expect(issueCall?.[0]).not.toBe(snapshots[0]) - expect(issueCall?.[0]).not.toBe(setupCall?.[0]) - }) - - it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => { - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link; - // the issue command runs as a side-pane split, not as the agent prompt. - expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42') - expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete') - expect(request.quickPrompt).not.toContain('Complete') - // The issue command itself still threads through as a side-pane split. - expect(request.issueCommand?.command).toBe('echo 42') - }) -}) diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts deleted file mode 100644 index 8f08c1b30cc..00000000000 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { toast } from 'sonner' - -import { useAppStore } from '@/store' -import { - beginBackgroundWorktreePreparation, - continueBackgroundWorktreeCreation -} from '@/lib/worktree-creation-flow' -import { - buildGitHubWorkItemBackendStartup, - buildGitHubWorkItemStartupPlan, - buildInitialGitHubWorkItemRequest, - type GitHubWorkItemBackgroundStoreSnapshot, - resolvePreferredQuickAgentForGitHubWorkItem -} from '@/lib/github-work-item-background-request' -import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import { - resolveDirectPrStartPoint, - resolveDirectSetupDecision -} from '@/lib/launch-work-item-direct-preflight' -import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' -import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import { renderIssueCommandTemplate } from '@/lib/new-workspace' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' -import { isGitRepoKind } from '../../../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' -import { evaluateRuntimeCompat } from '../../../shared/protocol-compat' -import { - MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - RUNTIME_PROTOCOL_VERSION -} from '../../../shared/protocol-version' -import type { GitHubWorkItem, SetupDecision, Repo } from '../../../shared/types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' - -export type BackgroundGitHubWorkItemCreateResult = - | { kind: 'background-started' } - | { kind: 'error' } - | { - kind: 'fallback' - reason: 'repo-missing' | 'host-unavailable' | 'setup-ask' | 'pr-start-point' | 'agent-startup' - } - -type AppActiveView = ReturnType['activeView'] - -type BackgroundGitHubWorkItemCreateDeps = { - getStore: () => GitHubWorkItemBackgroundStoreSnapshot - getActiveView: () => AppActiveView - hasPendingCreate: (creationId: string) => boolean - isPendingCreateActive: (creationId: string) => boolean - resolveSetupDecision: typeof resolveDirectSetupDecision - resolvePrStartPoint: typeof resolveDirectPrStartPoint - confirmHooks: ( - store: GitHubWorkItemBackgroundStoreSnapshot, - repoId: string, - scope: 'setup' | 'issueCommand' - ) => ReturnType - readIssueCommand: typeof readRuntimeIssueCommand - beginBackgroundCreate: typeof beginBackgroundWorktreePreparation - continueBackgroundCreate: typeof continueBackgroundWorktreeCreation - activatePendingCreate: (creationId: string) => void - removePendingCreate: (creationId: string) => void - setActiveView: (view: AppActiveView) => void - toastError: (message: string) => void -} - -export type BackgroundGitHubWorkItemCreateArgs = { - item: GitHubWorkItem - repoId: string - taskSourceContext?: TaskSourceContext | null - workspaceRunContext?: WorkspaceRunContext | null - telemetrySource?: WorktreeCreationRequest['telemetrySource'] - openModalFallback: () => void -} - -const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = { - getStore: () => useAppStore.getState(), - getActiveView: () => useAppStore.getState().activeView, - hasPendingCreate: (creationId) => - useAppStore.getState().pendingWorktreeCreations[creationId] != null, - isPendingCreateActive: (creationId) => - useAppStore.getState().activePendingCreationId === creationId, - resolveSetupDecision: resolveDirectSetupDecision, - resolvePrStartPoint: resolveDirectPrStartPoint, - confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') => - ensureHooksConfirmed(store as ReturnType, repoId, scope), - readIssueCommand: readRuntimeIssueCommand, - beginBackgroundCreate: beginBackgroundWorktreePreparation, - continueBackgroundCreate: continueBackgroundWorktreeCreation, - activatePendingCreate: (creationId) => { - const store = useAppStore.getState() - store.setActivePendingWorktreeCreation(creationId) - store.setActiveView('terminal') - store.setSidebarOpen(true) - }, - removePendingCreate: (creationId) => - useAppStore.getState().removePendingWorktreeCreation(creationId), - setActiveView: (view) => useAppStore.getState().setActiveView(view), - toastError: (message) => toast.error(message) -} - -function findPendingGitHubWorkItemCreate( - store: GitHubWorkItemBackgroundStoreSnapshot, - request: WorktreeCreationRequest -): string | null { - if (!request.linkedIssue && !request.linkedPR) { - return null - } - const match = Object.values(store.pendingWorktreeCreations).find((entry) => { - const pending = entry.request - return ( - pending.repoId === request.repoId && - pending.linkedIssue === request.linkedIssue && - pending.linkedPR === request.linkedPR - ) - }) - return match?.creationId ?? null -} - -function repoHostUnavailable(store: GitHubWorkItemBackgroundStoreSnapshot, repo: Repo): boolean { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (host?.kind === 'ssh') { - return store.sshConnectionStates.get(host.targetId)?.status !== 'connected' - } - if (host?.kind !== 'runtime') { - return false - } - const status = store.runtimeStatusByEnvironmentId.get(host.environmentId)?.status - if (!status) { - return true - } - if (!status.hostPlatform) { - return true - } - const compatibility = evaluateRuntimeCompat({ - clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, - serverMinCompatibleClientProtocolVersion: - status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion - }) - return compatibility.kind === 'blocked' -} - -function abandonStagedCreate( - creationId: string, - restoreView: AppActiveView, - deps: BackgroundGitHubWorkItemCreateDeps -): void { - // Why: fallback paths abandon the temporary creation surface, so return to the - // flow that launched it unless the user already activated something else. - const shouldRestoreView = deps.isPendingCreateActive(creationId) - deps.removePendingCreate(creationId) - if (shouldRestoreView) { - deps.setActiveView(restoreView) - } -} - -export async function createGitHubWorkItemWorkspaceInBackground( - args: BackgroundGitHubWorkItemCreateArgs, - deps: BackgroundGitHubWorkItemCreateDeps = DEFAULT_DEPS -): Promise { - const store = deps.getStore() - const repo = store.repos.find((candidate) => candidate.id === args.repoId) - if (!repo) { - args.openModalFallback() - return { kind: 'fallback', reason: 'repo-missing' } - } - - const initialRequest = buildInitialGitHubWorkItemRequest(args, repo) - const existingPendingCreateId = findPendingGitHubWorkItemCreate(store, initialRequest) - if (existingPendingCreateId) { - deps.activatePendingCreate(existingPendingCreateId) - return { kind: 'background-started' } - } - // Why: disconnected hosts make hook and agent probes fall back to skip/no-agent; - // keep the old composer gate so Retry cannot reuse degraded preflight values. - if (repoHostUnavailable(store, repo)) { - args.openModalFallback() - return { kind: 'fallback', reason: 'host-unavailable' } - } - - const restoreView = deps.getActiveView() - const creationId = deps.beginBackgroundCreate(initialRequest) - const itemIdentity = resolveGitHubWorkItemIdentity(args.item) - - try { - const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, args.repoId) - const setupResolution = await deps.resolveSetupDecision(args.repoId, repo, repoOwnerSettings) - // Why: once the staged row disappears, the user already cancelled or moved - // on, so every later preflight await must exit without reopening UI. - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (setupResolution.kind === 'needs-modal') { - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'setup-ask' } - } - - let baseBranch: string | undefined - let pushTarget: WorktreeCreationRequest['pushTarget'] - let branchNameOverride: string | undefined - let compareBaseRef: string | undefined - if (itemIdentity.type === 'pr' && itemIdentity.number) { - try { - const result = await deps.resolvePrStartPoint( - args.repoId, - itemIdentity.number, - repoOwnerSettings, - args.item - ) - baseBranch = result.baseBranch - pushTarget = result.pushTarget - branchNameOverride = result.branchNameOverride - compareBaseRef = result.compareBaseRef - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - } catch (error) { - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - deps.toastError(error instanceof Error ? error.message : 'Unable to resolve pull request.') - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'pr-start-point' } - } - } - - // Why: trust prompts are serialized app-wide, so read the store fresh at - // each check — an "Always trust" stamped by an earlier prompt (including - // this flow's own setup prompt) must short-circuit instead of re-prompting. - const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup') - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - const setupDecision: SetupDecision = - trustDecision === 'skip' ? 'skip' : setupResolution.decision - const agent = await resolvePreferredQuickAgentForGitHubWorkItem(store, repo) - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - const { startupPlan, quickPrompt, launchDraftPrompt, quickTelemetry } = - buildGitHubWorkItemStartupPlan({ - agent, - item: args.item, - repo, - store - }) - if (agent && !startupPlan) { - deps.toastError(agentLaunchCommandErrorMessage()) - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'agent-startup' } - } - const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry) - - // Why: mirror the composer's trust-gated issue-command split. Only GitHub - // issues (numeric issue number) on git repos run it; PRs/Linear/folders never - // do. Reuse the setup trust decision: a 'skip' there also skips the command. - let issueCommand: WorktreeCreationRequest['issueCommand'] - // Why: a declined setup trust also skips the issue command, so short-circuit - // before the (up-to-15s) read rather than reading just to drop the result. - if ( - trustDecision !== 'skip' && - isGitRepoKind(repo) && - args.item.type === 'issue' && - typeof args.item.number === 'number' - ) { - // Why: read failures fail closed (no command), so create still proceeds. - let effectiveContent = '' - try { - const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId) - effectiveContent = (issueCommandRead.effectiveContent ?? '').trim() - } catch { - effectiveContent = '' - } - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (effectiveContent.length > 0) { - const issueCommandTrust = await deps.confirmHooks( - deps.getStore(), - args.repoId, - 'issueCommand' - ) - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (issueCommandTrust === 'run') { - issueCommand = { - command: renderIssueCommandTemplate(effectiveContent, { - issueNumber: args.item.number, - artifactUrl: args.item.url ?? null - }) - } - } - } - } - - const request: WorktreeCreationRequest = { - ...initialRequest, - ...(baseBranch ? { baseBranch } : {}), - ...(compareBaseRef ? { compareBaseRef } : {}), - setupDecision, - ...(pushTarget ? { pushTarget } : {}), - agent, - ...(branchNameOverride ? { branchNameOverride } : {}), - ...(backendStartup ? { startup: backendStartup } : {}), - ...(issueCommand ? { issueCommand } : {}), - startupPlan, - quickPrompt, - ...(launchDraftPrompt ? { launchDraftPrompt } : {}), - quickTelemetry - } - - deps.continueBackgroundCreate(creationId, request, { revealCreationSurface: false }) - return { kind: 'background-started' } - } catch (error) { - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - abandonStagedCreate(creationId, restoreView, deps) - deps.toastError(error instanceof Error ? error.message : 'Unable to prepare workspace.') - return { kind: 'error' } - } -} diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts deleted file mode 100644 index 60ebed0477a..00000000000 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { useAppStore } from '@/store' -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' -import { pickQuickWorkspaceAgent } from '@/lib/quick-workspace-agent-selection' -import type { - PendingWorktreeCreation, - WorktreeCreationRequest -} from '@/lib/pending-worktree-creation' -import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' -import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { repoIsRemote } from '../../../shared/agent-launch-remote' -import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' -import { buildGitHubWorkspaceSource } from '../../../shared/new-workspace/workspace-source' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' -import { tuiAgentToAgentKind } from '@/lib/telemetry' -import type { GitHubWorkItem, GlobalSettings, Repo, TuiAgent } from '../../../shared/types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import type { AgentStartedTelemetry } from '@/lib/worktree-activation' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' -import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' - -export type GitHubWorkItemBackgroundStoreSnapshot = { - repos: readonly Repo[] - pendingWorktreeCreations: Record - sshConnectionStates: ReturnType['sshConnectionStates'] - runtimeStatusByEnvironmentId: ReturnType< - typeof useAppStore.getState - >['runtimeStatusByEnvironmentId'] - settings: - | Partial< - Pick< - GlobalSettings, - | 'activeRuntimeEnvironmentId' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentCmdOverrides' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'terminalWindowsShell' - > - > - | null - | undefined - ensureDetectedAgents: ReturnType['ensureDetectedAgents'] - ensureRemoteDetectedAgents: ReturnType['ensureRemoteDetectedAgents'] - ensureRuntimeDetectedAgents: ReturnType< - typeof useAppStore.getState - >['ensureRuntimeDetectedAgents'] -} - -export type BuildInitialGitHubWorkItemRequestArgs = { - item: GitHubWorkItem - repoId: string - taskSourceContext?: TaskSourceContext | null - workspaceRunContext?: WorkspaceRunContext | null - telemetrySource?: WorktreeCreationRequest['telemetrySource'] -} - -type QuickCreateLinkedWorkItemPromptResult = ReturnType< - typeof resolveQuickCreateLinkedWorkItemPrompt -> - -function resolveGitHubWorkItemPrompt(item: GitHubWorkItem): QuickCreateLinkedWorkItemPromptResult { - const resolver = resolveQuickCreateLinkedWorkItemPrompt as unknown as ( - linkedWorkItem: GitHubWorkItem, - note: string, - opts?: { cliAvailable: boolean } - ) => QuickCreateLinkedWorkItemPromptResult - return resolver(item, '', { cliAvailable: false }) -} - -export function buildGitHubWorkItemBackendStartup( - agent: TuiAgent | null, - startupPlan: AgentStartupPlan | null, - quickTelemetry: AgentStartedTelemetry | null -): WorktreeCreationRequest['startup'] { - // Why: draft/followup launches still need the renderer to finish terminal - // setup, so only self-contained startup plans can move into createWorktree. - if (!agent || !startupPlan || startupPlan.draftPrompt || startupPlan.followupPrompt) { - return undefined - } - return { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) - } -} - -function getWorkspaceRunContextForRepo( - repo: Repo, - provided: WorkspaceRunContext | null | undefined -): WorkspaceRunContext | null { - if (provided) { - return provided - } - const projection = projectHostSetupProjectionFromRepos([repo]) - const project = projection.projects[0] - const setup = projection.setups[0] - if (!project || !setup) { - return null - } - return { - kind: 'workspace-run', - projectId: project.id, - hostId: getRepoExecutionHostId(repo), - projectHostSetupId: setup.id, - repoId: repo.id, - path: repo.path - } -} - -export async function resolvePreferredQuickAgentForGitHubWorkItem( - store: GitHubWorkItemBackgroundStoreSnapshot, - repo: Repo -): Promise { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - const detectedAgents = - host?.kind === 'ssh' - ? await store.ensureRemoteDetectedAgents(host.targetId) - : host?.kind === 'runtime' - ? await store.ensureRuntimeDetectedAgents(host.environmentId) - : await store.ensureDetectedAgents() - return pickQuickWorkspaceAgent( - store.settings?.defaultTuiAgent, - detectedAgents, - store.settings?.disabledTuiAgents - ) -} - -function resolveGitHubWorkItemLaunchPlatform( - store: GitHubWorkItemBackgroundStoreSnapshot, - repo: Repo -): NodeJS.Platform { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (host?.kind === 'runtime') { - // Why: the background runtime path gates on hostPlatform before this point; - // POSIX is safer than client PowerShell if another caller violates that. - return ( - store.runtimeStatusByEnvironmentId.get(host.environmentId)?.status?.hostPlatform ?? 'linux' - ) - } - const projectRuntime = repo.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( - store as ReturnType, - repo.id, - CLIENT_PLATFORM - ) - return resolveSourceControlLaunchPlatform({ - connectionId: repo.connectionId, - worktreePath: repo.path, - projectRuntime - }) -} - -export function buildGitHubWorkItemStartupPlan(args: { - agent: TuiAgent | null - item: GitHubWorkItem - repo: Repo - store: GitHubWorkItemBackgroundStoreSnapshot -}): { - startupPlan: AgentStartupPlan | null - quickPrompt: string - /** Draft context (issue link) that reaches only the TUI input; callers thread - * it onto the creation request so completion can seed the chat composer. */ - launchDraftPrompt: string | null - quickTelemetry: AgentStartedTelemetry | null -} { - const { agent, item, repo, store } = args - if (!agent) { - return { startupPlan: null, quickPrompt: '', launchDraftPrompt: null, quickTelemetry: null } - } - const { prompt: quickPrompt, draftPrompt } = resolveGitHubWorkItemPrompt(item) - // Why: runtime-owned repos launch on their owner host, not on the client - // desktop, so startup shell quoting must use the runtime platform. - const platform = resolveGitHubWorkItemLaunchPlatform(store, repo) - // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - // `orca-ide` rename must not be applied for remote launches. - const isRemote = repoIsRemote(repo) - const shell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: store.settings?.terminalWindowsShell - }) - const draftLaunchPlan = draftPrompt - ? buildAgentDraftLaunchPlan({ - agent, - draft: draftPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), - platform, - shell, - isRemote - }) - : null - const startupPlan = draftLaunchPlan - ? { - agent: draftLaunchPlan.agent, - launchCommand: draftLaunchPlan.launchCommand, - expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - : buildAgentStartupPlan({ - agent, - prompt: quickPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), - platform, - shell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (startupPlan && draftPrompt && !draftLaunchPlan) { - startupPlan.draftPrompt = draftPrompt - } - return { - startupPlan, - quickPrompt, - launchDraftPrompt: draftPrompt || null, - quickTelemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: 'new_workspace_composer', - request_kind: 'new' - } - } -} - -function getGitHubWorkItemName(item: GitHubWorkItem): { seedName: string; displayName?: string } { - const identity = resolveGitHubWorkItemIdentity(item) - const intent = - identity.number !== null - ? getWorkspaceIntentName({ - sourceText: item.title, - workItem: { type: identity.type, number: identity.number, title: item.title } - }) - : null - return { - seedName: getWorkspaceSeedName({ - explicitName: intent?.seedName ?? '', - prompt: '', - linkedIssueNumber: identity.type === 'issue' ? identity.number : null, - linkedPR: identity.type === 'pr' ? identity.number : null - }), - ...(intent?.displayName ? { displayName: intent.displayName } : {}) - } -} - -export function buildInitialGitHubWorkItemRequest( - args: BuildInitialGitHubWorkItemRequestArgs, - repo: Repo -): WorktreeCreationRequest { - const { seedName, displayName } = getGitHubWorkItemName(args.item) - const workspaceRunContext = getWorkspaceRunContextForRepo(repo, args.workspaceRunContext) - const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo)) - const identity = resolveGitHubWorkItemIdentity(args.item) - const linkedWorkItem = - identity.number !== null - ? buildGitHubWorkspaceSource({ - type: identity.type, - number: identity.number, - title: args.item.title, - url: args.item.url, - repoId: args.repoId - }) - : null - return { - repoId: args.repoId, - worktreeCreateProgressMode: ownerHost?.kind === 'local' ? 'stepped' : 'indeterminate', - ...(args.taskSourceContext ? { taskSourceContext: args.taskSourceContext } : {}), - ...(linkedWorkItem - ? { - linkedWorkItem, - ...(args.taskSourceContext ? { linkedTaskSourceContext: args.taskSourceContext } : {}) - } - : {}), - ...(workspaceRunContext ? { workspaceRunContext } : {}), - name: seedName, - ...(displayName ? { displayName } : {}), - ...(identity.type === 'issue' && identity.number ? { linkedIssue: identity.number } : {}), - ...(identity.type === 'pr' && identity.number ? { linkedPR: identity.number } : {}), - ...(args.telemetrySource ? { telemetrySource: args.telemetrySource } : {}), - setupDecision: 'inherit', - agent: null, - pendingFirstAgentMessageRename: false, - note: '', - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - } -} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 70e7d20526d..aa97a9a1887 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' -import { safeFit, safeFitAndThen } from './pane-fit' +import { readProposedPaneFitDimensions, safeFit, safeFitAndThen } from './pane-fit' import { applyOrDeferPaneMetricOptions } from './pane-metric-options-deferral' import { paneFitClientSizeChanged } from './pane-reveal-fit' +import { setFitOverride } from './mobile-fit-overrides' vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() @@ -502,4 +503,64 @@ describe('deferred metric flush inside safeFit', () => { expect(pane.terminal.options.fontSize).toBe(12) expect(pane.fitAddon.fit).toHaveBeenCalled() }) + + it('reports the post-metric grid used by the next safe fit', () => { + const terminal = { cols: 80, rows: 24, options: {} as Record } + const pane = { + id: 12, + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 500, height: 300 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: vi.fn(() => + Number(terminal.options.fontSize ?? 10) >= 18 + ? { cols: 20, rows: 10 } + : { cols: 40, rows: 20 } + ) + } + } as unknown as ManagedPane + applyOrDeferPaneMetricOptions(pane, { fontSize: 18 }, false) + + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 20, rows: 10 }) + expect(pane.terminal.options.fontSize).toBe(18) + }) + + it('reports an owner override even while the pane is unmeasurable', () => { + const resize = vi.fn() + const pane = { + terminal: { cols: 80, rows: 24, options: {}, resize }, + container: { + dataset: { ptyId: 'pty-override' }, + getBoundingClientRect: () => ({ width: 0, height: 0 }) + }, + fitAddon: { proposeDimensions: vi.fn() } + } as unknown as ManagedPane + setFitOverride('pty-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.fitAddon.proposeDimensions).not.toHaveBeenCalled() + expect(safeFit(pane)).toBe(false) + expect(resize).not.toHaveBeenCalled() + } finally { + setFitOverride('pty-override', 'desktop-fit', 0, 0) + } + }) + + it('flushes deferred metrics before reporting a measurable owner override', () => { + const pane = createMetricPane() + pane.container.dataset.ptyId = 'pty-metric-override' + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, false) + setFitOverride('pty-metric-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.terminal.options.fontSize).toBe(12) + } finally { + setFitOverride('pty-metric-override', 'desktop-fit', 0, 0) + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index c9807dbb10a..f7165c3aad6 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -55,6 +55,28 @@ export type SafeFitContinuationHandle = { export { readFitClientSize } from './pane-fit-client-size' +/** Returns the exact dimensions the next safe fit will use after any applicable + * deferred metrics. An owner override remains measurable while its pane is hidden. */ +export function readProposedPaneFitDimensions( + pane: ManagedPane +): { cols: number; rows: number } | null { + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + const measurable = canMeasurePaneForFit(pane) + if (!measurable && !override) { + return null + } + // Re-check after the metric flush: larger cells can push a narrow pane below + // the fit floor even though it was measurable with the old metrics. + if (measurable && flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { + return null + } + if (override) { + return { cols: override.cols, rows: override.rows } + } + return getProposedPaneDimensions(pane) +} + function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { // Why: split reparent has its own delayed restore; restoring here can fight that timer. return !( @@ -70,10 +92,7 @@ function performSafeFit(pane: ManagedPane): boolean { return false } // Why here: metric options deferred while the pane was unmeasurable must land - // before this fit reads dimensions, or the fit pins cols/rows to stale metrics. - // Why re-check: the gate above measured with the old cell size — a large font - // jump on a narrow pane can drop it under the floor, and fit() would then pin - // the PTY at the tiny grid that floor exists to prevent. + // before this fit reads dimensions, then the fit floor must be checked again. if (flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index c3f0c4df330..2a9d7d023ac 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1721,4 +1721,168 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(100) expect(throwing.write).toHaveBeenCalledTimes(1) }) + + describe('queue memory retention (STA-3567)', () => { + // Why 2 MB: comfortably above BACKGROUND_CHUNK_CHARS (16 K), so every drain leaves a residual slice. + const CHUNK_CHARS = 2 * 1024 * 1024 + const TERMINALS = 8 + + function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed + } + + it('releases chunks it has already drained past', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why many medium chunks: compactConsumedChunks only splices once chunkIndex reaches 64, + // so below that the drained slots stay in the array and must be cleared individually. + const CHUNKS_PER_TERMINAL = 40 + const CHUNK = 48 * 1024 + const SINKS = 4 + + let writtenChars = 0 + const terminals = Array.from({ length: SINKS }, () => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + })) + + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + for (let chunk = 0; chunk < CHUNKS_PER_TERMINAL; chunk += 1) { + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + + String.fromCharCode(48 + (chunk % 10)) + + 'q'.repeat(CHUNK - 2), + { foreground: false, latencySensitive: false } + ) + } + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 40000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: the queues really drained down, and none hit the backlog cap. + expect(writtenChars).toBeGreaterThan(SINKS * CHUNKS_PER_TERMINAL * CHUNK * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: ~39 drained-past slots per terminal kept their strings alive uncharged. + expect(retainedBytes).toBeLessThan(2 * 1024 * 1024) + }) + + it('does not pin the parent when a producer enqueues a slice', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a slice: agent-status-osc.ts and the restore-overlap trims hand the scheduler + // strings cut from a much larger buffer. The queue must own its copy rather than + // trusting every producer to flatten first (STA-3567 review round 2). + const KEEP_CHARS = 64 * 1024 + const sinks = Array.from({ length: TERMINALS }, () => ({ + write: (_data: string, callback?: () => void) => callback?.() + })) + + const baseline = collect() + + for (const [index, sink] of sinks.entries()) { + const parent = String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1) + writeTerminalOutput(sink, parent.slice(parent.length - KEEP_CHARS), { + foreground: false, + latencySensitive: false + }) + } + + const retainedBytes = collect() - baseline + + // 8 x 64 KB of real payload must not keep 8 x 2 MB of parents alive. + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + + it('drops the parent chunk once only a small tail is still queued', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a hand-rolled write: vi.fn() retains every argument in mock.calls, which would + // dominate the measurement with the very bytes the queue is supposed to have released. + let writtenChars = 0 + const makeSink = (): { write: (data: string, callback?: () => void) => void } => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + }) + const terminals = Array.from({ length: TERMINALS }, makeSink) + + // Why baseline BEFORE enqueueing: the queue owns a copy of every chunk, so a baseline + // taken after enqueue already contains the parents this test must prove get released, + // and the assertion would hold even with residual flattening disabled. + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + // Built and dropped inline so only the queue's own copy stays reachable. + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1), + { foreground: false, latencySensitive: false } + ) + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + // Why stop early: the leak is what a PARTIALLY drained queue pins. Draining to empty + // frees the chunks either way, so a full drain cannot observe the defect. + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 20000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: nearly everything drained, but a real tail is still queued - otherwise + // there is no residual slice to hold a parent chunk and the measurement is meaningless. + expect(writtenChars).toBeGreaterThan(TERMINALS * CHUNK_CHARS * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: those few queued KB pinned 8 x 2 MB of chunks (~16 MB). + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 8acb768e635..db826781812 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,6 +7,7 @@ import { } from './pane-terminal-foreground-render-settle' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { flattenRetainedSlice } from '@/lib/flatten-retained-slice' import { discardInFlightTerminalOutputAckCredits, registerTerminalOutputAckCredits @@ -49,6 +50,8 @@ type WriteTerminalOutputOptions = { type QueueChunk = { data: string + // Tracks the backing data still reachable through this queue slot. + retainedChars: number foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean @@ -631,6 +634,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { data += chunk.data remaining -= chunk.data.length entry.queuedChars -= chunk.data.length + // Clear drained slots before the 64-chunk compaction can release them. + entry.chunks[entry.chunkIndex] = { + data: '', + retainedChars: 0, + foreground: chunk.foreground, + forceForegroundRefresh: false, + followupForegroundRefresh: false, + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false + } entry.chunkIndex += 1 if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) @@ -642,9 +655,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { } data += chunk.data.slice(0, remaining) + const residual = chunk.data.slice(remaining) + // Geometric flattening bounds retained parents while keeping total copy work linear. + const flatten = residual.length * 2 <= chunk.retainedChars entry.chunks[entry.chunkIndex] = { ...chunk, - data: chunk.data.slice(remaining) + data: flatten ? flattenRetainedSlice(residual) : residual, + retainedChars: flatten ? residual.length : chunk.retainedChars } entry.queuedChars -= remaining remaining = 0 @@ -719,8 +736,11 @@ function enqueueChunk( ackCredit?: () => void } ): void { + // Own queued data so producer slices cannot pin larger PTY or restore buffers. + const owned = flattenRetainedSlice(data) entry.chunks.push({ - data, + data: owned, + retainedChars: owned.length, foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, @@ -783,6 +803,7 @@ function replaceBacklogWithWarning( entry.chunks = [ { data: warning, + retainedChars: warning.length, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, diff --git a/src/renderer/src/lib/paste-payload-metadata.ts b/src/renderer/src/lib/paste-payload-metadata.ts index be63b7bc558..f28e49b24f2 100644 --- a/src/renderer/src/lib/paste-payload-metadata.ts +++ b/src/renderer/src/lib/paste-payload-metadata.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' export type PastePayloadMetadata = { byteLength: number @@ -138,16 +139,3 @@ function isPasteControlSequenceCodePoint(codePoint: number): boolean { codePoint === 0x7f ) } - -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/renderer/src/lib/pr-bot-author-overrides.ts b/src/renderer/src/lib/pr-bot-author-overrides.ts index cf8ade2fea7..714dd0b6c01 100644 --- a/src/renderer/src/lib/pr-bot-author-overrides.ts +++ b/src/renderer/src/lib/pr-bot-author-overrides.ts @@ -2,8 +2,11 @@ import { useMemo } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } from './pr-comment-audience' -import { MAX_PR_BOT_AUTHOR_OVERRIDES } from '../../../shared/pr-bot-author-overrides' +import { + createBotAuthorOverrideSet, + MAX_PR_BOT_AUTHOR_OVERRIDES, + normalizePRCommentAuthorLogin +} from '../../../shared/pr-bot-author-overrides' let overrideUpdateQueue = Promise.resolve() diff --git a/src/renderer/src/lib/pr-comment-action-state.test.ts b/src/renderer/src/lib/pr-comment-action-state.test.ts index 628d8c5ba2a..48f99ab1e8c 100644 --- a/src/renderer/src/lib/pr-comment-action-state.test.ts +++ b/src/renderer/src/lib/pr-comment-action-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { PRComment } from '../../../shared/types' -import { groupPRComments } from './pr-comment-groups' +import { groupPRComments } from '../../../shared/pr-comment-groups' import { getPRCommentGroupActionState, isPRCommentGroupQueueableForAI, diff --git a/src/renderer/src/lib/pr-comment-action-state.ts b/src/renderer/src/lib/pr-comment-action-state.ts index e75d7c09cba..9073c82a86f 100644 --- a/src/renderer/src/lib/pr-comment-action-state.ts +++ b/src/renderer/src/lib/pr-comment-action-state.ts @@ -1,5 +1,8 @@ -import type { PRCommentGroup } from './pr-comment-groups' -import { getPRCommentGroupId, getPRCommentGroupRoot } from './pr-comment-groups' +import { + getPRCommentGroupId, + getPRCommentGroupRoot, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' /** How a comment group should read in the PR sidebar triage UI. */ export type PRCommentGroupActionState = 'open' | 'conversation' | 'resolved' diff --git a/src/renderer/src/lib/pr-comment-audience-labels.ts b/src/renderer/src/lib/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..50e307cc1c7 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-audience-labels.ts @@ -0,0 +1,22 @@ +import type { PRCommentAudienceFilter } from '../../../shared/pr-comment-audience' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' + +export const getPrCommentAudienceFilters = createLocalizedCatalog( + (): { value: PRCommentAudienceFilter; label: string }[] => [ + { value: 'all', label: translate('auto.lib.pr.comment.audience.27ce73211c', 'All') }, + { value: 'human', label: translate('auto.lib.pr.comment.audience.a7150a17bc', 'Humans') }, + { value: 'bot', label: translate('auto.lib.pr.comment.audience.64deee36a9', 'Bots') } + ] +) + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return translate('auto.lib.pr.comment.audience.empty.bot', 'No bot comments.') + case 'human': + return translate('auto.lib.pr.comment.audience.empty.human', 'No human comments.') + case 'all': + return translate('auto.lib.pr.comment.audience.empty.all', 'No comments yet.') + } +} diff --git a/src/renderer/src/lib/pr-comment-audience.ts b/src/renderer/src/lib/pr-comment-audience.ts deleted file mode 100644 index dc731095277..00000000000 --- a/src/renderer/src/lib/pr-comment-audience.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { PRComment } from '../../../shared/types' -import { - createBotAuthorOverrideSet, - normalizePRCommentAuthorLogin -} from '../../../shared/pr-bot-author-overrides' -import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { translate } from '@/i18n/i18n' - -export { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } - -export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' - -export const getPrCommentAudienceFilters = createLocalizedCatalog( - (): { value: PRCommentAudienceFilter; label: string }[] => [ - { value: 'all', label: translate('auto.lib.pr.comment.audience.27ce73211c', 'All') }, - { value: 'human', label: translate('auto.lib.pr.comment.audience.a7150a17bc', 'Humans') }, - { value: 'bot', label: translate('auto.lib.pr.comment.audience.64deee36a9', 'Bots') } - ] -) - -const BOT_LOGIN_SUFFIX = '[bot]' -const AUTOMATION_LOGIN_PATTERNS = [ - /bot$/i, - /-bot$/i, - /\bbot\b/i, - /automation/i, - /actions/i, - /renovate/i, - /dependabot/i -] -// Why: some AI/code-review services use regular GitHub user accounts rather -// than GitHub Apps, so GitHub metadata can report them as users. Keep this -// central so the Tasks dialog and PR sidebar classify comments identically. -const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ - 'chatgpt-codex-connector', - 'codex-connector', - 'qodo', - 'coderabbit', - 'codium', - 'sonarcloud', - 'sonarqube', - 'sourcery-ai', - 'deepsource', - 'snyk', - 'codecov', - 'greptile', - 'ellipsis', - 'graphite-app', - 'reviewer-gpt', - '-reviewer' -] - -export function isBotPRComment( - comment: PRComment, - botAuthorOverrides?: ReadonlySet -): boolean { - const author = comment.author.trim() - const normalized = normalizePRCommentAuthorLogin(author) - if (botAuthorOverrides?.has(normalized)) { - return true - } - if (comment.isBot === true) { - return true - } - if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { - return true - } - if (KNOWN_AUTOMATION_LOGIN_SUBSTRINGS.some((needle) => normalized.includes(needle))) { - return true - } - return AUTOMATION_LOGIN_PATTERNS.some((pattern) => pattern.test(author)) -} - -export function getPRCommentAudienceCounts( - comments: PRComment[], - botAuthorOverrides?: ReadonlySet -): Record { - const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length - return { - all: comments.length, - human: comments.length - bot, - bot - } -} - -export function filterPRCommentsByAudience( - comments: PRComment[], - filter: PRCommentAudienceFilter, - botAuthorOverrides?: ReadonlySet -): PRComment[] { - if (filter === 'bot') { - return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)) - } - if (filter === 'human') { - return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides)) - } - return comments -} - -export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { - switch (filter) { - case 'bot': - return translate('auto.lib.pr.comment.audience.empty.bot', 'No bot comments.') - case 'human': - return translate('auto.lib.pr.comment.audience.empty.human', 'No human comments.') - case 'all': - return translate('auto.lib.pr.comment.audience.empty.all', 'No comments yet.') - } -} diff --git a/src/renderer/src/lib/pr-comment-groups.ts b/src/renderer/src/lib/pr-comment-groups.ts deleted file mode 100644 index d9fcfddf2b3..00000000000 --- a/src/renderer/src/lib/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../shared/types' - -export const PR_COMMENT_RESOLVED_CONTAINER_CLASS = 'opacity-50' -export const PR_COMMENT_RESOLVED_AUTHOR_CLASS = 'text-muted-foreground' -export const PR_COMMENT_OPEN_AUTHOR_CLASS = 'text-foreground' - -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/src/renderer/src/lib/pr-comment-resolution-classes.ts b/src/renderer/src/lib/pr-comment-resolution-classes.ts new file mode 100644 index 00000000000..2caa89efd1a --- /dev/null +++ b/src/renderer/src/lib/pr-comment-resolution-classes.ts @@ -0,0 +1,3 @@ +export const PR_COMMENT_RESOLVED_CONTAINER_CLASS = 'opacity-50' +export const PR_COMMENT_RESOLVED_AUTHOR_CLASS = 'text-muted-foreground' +export const PR_COMMENT_OPEN_AUTHOR_CLASS = 'text-foreground' diff --git a/src/renderer/src/lib/preserved-branch-cleanup.ts b/src/renderer/src/lib/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..b13830391aa --- /dev/null +++ b/src/renderer/src/lib/preserved-branch-cleanup.ts @@ -0,0 +1,5 @@ +export { + preservedBranchCleanupKey, + preservedBranchCleanupScopeKey, + type PreservedBranchCleanup +} from '../../../shared/preserved-branch-cleanup' diff --git a/src/renderer/src/lib/primary-selection-paste.ts b/src/renderer/src/lib/primary-selection-paste.ts index 93e237378d0..9dcf2c73097 100644 --- a/src/renderer/src/lib/primary-selection-paste.ts +++ b/src/renderer/src/lib/primary-selection-paste.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../shared/utf8-byte-limits' import { isPrimarySelectionTextControl } from './primary-selection-capture' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, @@ -101,39 +102,6 @@ function isContentEditablePasteTargetAvailable( return target.isConnected && target.isContentEditable && (canContinue?.(target) ?? true) } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function getContentEditableInsertionRange(target: HTMLElement): Range | null { const selection = target.ownerDocument.getSelection() if (!selection || selection.rangeCount === 0) { @@ -180,7 +148,7 @@ async function pasteLargeTextIntoContentEditable( } return false } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) range = insertContentEditableChunk(target, range, text.slice(textIndex, nextIndex)) textIndex = nextIndex if (textIndex < text.length) { diff --git a/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts b/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts new file mode 100644 index 00000000000..bfde748ee85 --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { ProjectHostSetup, Repo, TerminalQuickCommand } from '../../../shared/types' +import { terminalQuickCommandMatchesWorkspaceProject } from './terminal-quick-command-project-scope' + +type ScopeSetup = Pick + +const LOCAL_REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WINDOWS_REPO_ID = 'a0a2b4a4-1bff-494c-b005-d77918abc6a7' + +function command(repoId: string): TerminalQuickCommand { + return { + id: 'test-orca', + label: 'Test Orca', + action: 'terminal-command', + command: 'pnpm test', + appendEnter: true, + scope: { type: 'repo', repoId } + } +} + +function setup( + hostId: ExecutionHostId, + repoId: string, + projectId = 'github:stablyai/orca' +): ScopeSetup { + return { hostId, projectId, repoId } +} + +describe('terminalQuickCommandMatchesWorkspaceProject', () => { + it.each(['runtime:windows-2', 'ssh:windows-2'])( + 'shows a local repo command in the same project on %s', + (targetHostId) => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup(targetHostId, WINDOWS_REPO_ID) + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId, + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + } + ) + + it('matches the user topology from host-fetched repo catalogs', () => { + const repo = (id: string, path: string, executionHostId?: ExecutionHostId): Repo => ({ + id, + path, + displayName: 'orca', + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' + }, + ...(executionHostId ? { executionHostId } : {}) + }) + const projectHostSetups = projectHostSetupProjectionFromRepos([ + repo(LOCAL_REPO_ID, '/Users/alice/orca'), + repo(WINDOWS_REPO_ID, 'C:\\Users\\alice\\orca', 'runtime:windows-2') + ]).setups + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('does not show a repo command for a different known project', () => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup('runtime:windows-2', WINDOWS_REPO_ID, 'github:stablyai/other') + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(false) + }) + + it('does not trust colliding cross-host repo ids when both projects are known', () => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup('runtime:windows-2', LOCAL_REPO_ID, 'github:stablyai/other') + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: LOCAL_REPO_ID + }) + ).toBe(false) + }) + + it('does not infer a missing target setup from another host repo id', () => { + const projectHostSetups = [setup('local', LOCAL_REPO_ID), setup('ssh:builder', WINDOWS_REPO_ID)] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(false) + }) + + it('preserves exact repo-id matching when legacy project metadata is unavailable', () => { + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups: [], + targetHostId: 'runtime:legacy', + targetRepoId: LOCAL_REPO_ID + }) + ).toBe(true) + }) + + it('resolves a settings-owned command whose repo executes through SSH', () => { + const projectHostSetups = [ + setup('ssh:builder', LOCAL_REPO_ID), + setup('runtime:windows-2', WINDOWS_REPO_ID) + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('keeps a remote-host repo command visible on its own repo', () => { + expect( + terminalQuickCommandMatchesWorkspaceProject(command(WINDOWS_REPO_ID), { + commandHostId: 'runtime:windows-2', + projectHostSetups: [], + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('keeps global commands visible without a repo and repo commands hidden', () => { + const globalCommand: TerminalQuickCommand = { + ...command(LOCAL_REPO_ID), + scope: { type: 'global' } + } + const context = { + commandHostId: 'local' as const, + projectHostSetups: [], + targetHostId: 'runtime:folder' as const, + targetRepoId: null + } + + expect(terminalQuickCommandMatchesWorkspaceProject(globalCommand, context)).toBe(true) + expect(terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), context)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/terminal-quick-command-project-scope.ts b/src/renderer/src/lib/terminal-quick-command-project-scope.ts new file mode 100644 index 00000000000..7b48a7b3ada --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-project-scope.ts @@ -0,0 +1,73 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getTerminalQuickCommandScope } from '../../../shared/terminal-quick-commands' +import type { ProjectHostSetup, TerminalQuickCommand } from '../../../shared/types' + +type TerminalQuickCommandProjectContext = { + commandHostId: ExecutionHostId + projectHostSetups: readonly Pick[] + targetHostId: ExecutionHostId + targetRepoId: string | null +} + +type ProjectResolution = + | { kind: 'resolved'; projectId: string } + | { kind: 'ambiguous' } + | { kind: 'unknown' } + +function resolveProject( + setups: TerminalQuickCommandProjectContext['projectHostSetups'], + hostId: ExecutionHostId, + repoId: string, + allowAnyHostFallback: boolean +): ProjectResolution { + const hostMatches = setups.filter((setup) => setup.hostId === hostId && setup.repoId === repoId) + // Why: commands belong to a settings host, while its repo can execute through that host's SSH. + const candidates = + hostMatches.length > 0 || !allowAnyHostFallback + ? hostMatches + : setups.filter((s) => s.repoId === repoId) + const projectIds = new Set(candidates.map((setup) => setup.projectId)) + if (projectIds.size === 0) { + return { kind: 'unknown' } + } + if (projectIds.size > 1) { + return { kind: 'ambiguous' } + } + return { kind: 'resolved', projectId: [...projectIds][0] } +} + +export function terminalQuickCommandMatchesWorkspaceProject( + command: TerminalQuickCommand, + context: TerminalQuickCommandProjectContext +): boolean { + const scope = getTerminalQuickCommandScope(command) + if (scope.type === 'global') { + return true + } + if (context.targetRepoId === null) { + return false + } + if (context.commandHostId === context.targetHostId && scope.repoId === context.targetRepoId) { + return true + } + + const commandProject = resolveProject( + context.projectHostSetups, + context.commandHostId, + scope.repoId, + true + ) + const targetProject = resolveProject( + context.projectHostSetups, + context.targetHostId, + context.targetRepoId, + false + ) + if (commandProject.kind === 'ambiguous' || targetProject.kind === 'ambiguous') { + return false + } + if (commandProject.kind === 'resolved' && targetProject.kind === 'resolved') { + return commandProject.projectId === targetProject.projectId + } + return scope.repoId === context.targetRepoId +} diff --git a/src/renderer/src/lib/text-control-paste.test.ts b/src/renderer/src/lib/text-control-paste.test.ts index f5001465e36..942bd92c69b 100644 --- a/src/renderer/src/lib/text-control-paste.test.ts +++ b/src/renderer/src/lib/text-control-paste.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PASTE_PAYLOAD_CORPUS } from './paste-payload-corpus' import { - getTextControlPasteByteLength, measureTextControlPasteByteLength, pasteTextIntoTextControl, shouldHandleTextControlPaste @@ -251,7 +250,7 @@ describe('text control paste', () => { }) it('uses utf-8 byte thresholds for non-ascii text', () => { - expect(getTextControlPasteByteLength('a😀é')).toBe(7) + expect(measureTextControlPasteByteLength('a😀é').byteLength).toBe(7) expect(shouldHandleTextControlPaste('😀😀', { directMaxBytes: 7 })).toBe(true) expect(shouldHandleTextControlPaste('abc', { directMaxBytes: 7 })).toBe(false) expect(shouldHandleTextControlPaste('', { directMaxBytes: 0 })).toBe(false) @@ -263,6 +262,8 @@ describe('text control paste', () => { }) expect(measurement).toEqual({ byteLength: 8, exceededLimit: true }) - expect(measurement.byteLength).toBeLessThan(getTextControlPasteByteLength('😀'.repeat(100))) + expect(measurement.byteLength).toBeLessThan( + measureTextControlPasteByteLength('😀'.repeat(100)).byteLength + ) }) }) diff --git a/src/renderer/src/lib/text-control-paste.ts b/src/renderer/src/lib/text-control-paste.ts index a1733044a9f..a3a19028953 100644 --- a/src/renderer/src/lib/text-control-paste.ts +++ b/src/renderer/src/lib/text-control-paste.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../shared/utf8-byte-limits' import { createTextControlCancelledResult, createTextControlPastedResult, @@ -34,19 +35,6 @@ export type { TextControlPasteSource } from './text-control-paste-model' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function measureTextControlPasteByteLength( text: string, options: { stopAfterBytes?: number } = {} @@ -55,10 +43,6 @@ export function measureTextControlPasteByteLength( return { byteLength, exceededLimit } } -export function getTextControlPasteByteLength(text: string): number { - return measureTextControlPasteByteLength(text).byteLength -} - export async function measureTextControlPasteByteLengthWithYield( text: string, options: { @@ -129,26 +113,6 @@ export function shouldHandleTextControlPaste( return measurement.exceededLimit || measurement.byteLength > directMaxBytes } -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function dispatchTextControlInputEvent( target: HTMLInputElement | HTMLTextAreaElement, data: string | null, @@ -283,7 +247,7 @@ export async function pasteTextIntoTextControl( }) } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) const caret = target.selectionStart ?? target.value.length target.setRangeText(chunk, caret, caret, 'end') diff --git a/src/renderer/src/runtime/runtime-file-search-bounds.ts b/src/renderer/src/runtime/runtime-file-search-bounds.ts index 1301fbe83e4..b7823f783fd 100644 --- a/src/renderer/src/runtime/runtime-file-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-file-search-bounds.ts @@ -1,4 +1,5 @@ import type { SearchOptions, SearchResult } from '../../../shared/types' +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' export const RUNTIME_FILE_SEARCH_TEXT_MAX_BYTES = 8 * 1024 @@ -8,35 +9,11 @@ export function createEmptyRuntimeFileSearchResult(): SearchResult { return { files: [], totalMatches: 0, truncated: false } } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function isRuntimeFileSearchTextWithinLimit( text: string, maxBytes = RUNTIME_FILE_SEARCH_TEXT_MAX_BYTES ): boolean { - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(text, maxBytes) } export function getRuntimeFileSearchRejectedField( diff --git a/src/renderer/src/runtime/runtime-provider-search-bounds.ts b/src/renderer/src/runtime/runtime-provider-search-bounds.ts index d41441344c1..b231cca356d 100644 --- a/src/renderer/src/runtime/runtime-provider-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-provider-search-bounds.ts @@ -1,17 +1,6 @@ -export const RUNTIME_PROVIDER_SEARCH_QUERY_MAX_BYTES = 8 * 1024 +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} +export const RUNTIME_PROVIDER_SEARCH_QUERY_MAX_BYTES = 8 * 1024 export function isRuntimeProviderSearchQueryWithinLimit( query: string | null | undefined, @@ -20,16 +9,5 @@ export function isRuntimeProviderSearchQueryWithinLimit( if (query === null || query === undefined) { return true } - let byteLength = 0 - for (let index = 0; index < query.length; index += 1) { - const codePoint = query.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(query, maxBytes) } diff --git a/src/renderer/src/runtime/runtime-repo-search-bounds.ts b/src/renderer/src/runtime/runtime-repo-search-bounds.ts index 36480657568..9717f3fe906 100644 --- a/src/renderer/src/runtime/runtime-repo-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-repo-search-bounds.ts @@ -1,32 +1,10 @@ -export const RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES = 2 * 1024 +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} +export const RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES = 2 * 1024 export function isRuntimeRepoRefSearchQueryWithinLimit( query: string, maxBytes = RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES ): boolean { - let byteLength = 0 - for (let index = 0; index < query.length; index += 1) { - const codePoint = query.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(query, maxBytes) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts new file mode 100644 index 00000000000..317065d7c81 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts @@ -0,0 +1,389 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createStore } from 'zustand/vanilla' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { BrowserCertificateFailure } from '../../../shared/types' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots, + applyFreshWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' + +const ENVIRONMENT_ID = 'web-env-1' +const NOW = 1_700_000_000_000 +const WORKTREE_A = 'repo::/worktree-a' +const WORKTREE_B = 'repo::/worktree-b' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const HOST_TAB_ID = 'host-tab-1' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) + +const CERTIFICATE_FAILURE: BrowserCertificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'host-browser-page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +function makeState(overrides: Partial = {}): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0, + ...overrides + } +} + +function makeSnapshot( + worktree: string, + tabs: RuntimeMobileSessionTabsResult['tabs'], + activeTabType: RuntimeMobileSessionTabsResult['activeTabType'] = 'terminal' +): RuntimeMobileSessionTabsResult { + return { + worktree, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: `group:${worktree}`, + activeTabId: tabs.find((tab) => tab.isActive)?.id ?? null, + activeTabType, + tabs + } +} + +function makeTerminalSnapshot( + options: { + worktree?: string + title?: string + terminal?: string | null + } = {} +): RuntimeMobileSessionTabsResult { + const worktree = options.worktree ?? WORKTREE_A + const terminal = options.terminal === undefined ? 'terminal-1' : options.terminal + return makeSnapshot(worktree, [ + { + type: 'terminal', + id: `${HOST_TAB_ID}::${LEAF_ID}`, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: options.title ?? 'host shell', + isActive: true, + ...(terminal === null + ? { status: 'pending-handle' as const, terminal: null } + : { status: 'ready' as const, terminal }) + } + ]) +} + +function makeBrowserSnapshot( + options: { + worktree?: string + pageId?: string + tabId?: string + workspaceId?: string + title?: string + url?: string + loading?: boolean + certificateFailure?: BrowserCertificateFailure | null + } = {} +): RuntimeMobileSessionTabsResult { + const worktree = options.worktree ?? WORKTREE_A + const pageId = options.pageId ?? 'host-browser-page-1' + const certificateFailure = + options.certificateFailure === undefined + ? { ...CERTIFICATE_FAILURE, browserPageId: pageId } + : options.certificateFailure + ? { ...options.certificateFailure } + : null + return makeSnapshot( + worktree, + [ + { + type: 'browser', + id: options.tabId ?? 'host-browser-tab', + browserWorkspaceId: options.workspaceId ?? 'host-browser-workspace', + browserPageId: pageId, + title: options.title ?? 'Example Domain', + url: options.url ?? 'https://example.com/', + loading: options.loading ?? false, + canGoBack: false, + canGoForward: false, + certificateFailure, + isActive: true + } + ], + 'browser' + ) +} + +function applySnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + now = NOW +): WebSessionTabsSyncState { + const patch = applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, now) + return patch === state ? state : { ...state, ...patch } +} + +describe('remote mirror resource identity', () => { + beforeEach(() => { + resetWebSessionTabsSnapshotFreshnessForTests() + }) + + it('keeps PTY arrays and layouts stable on an exact terminal replay', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const ptyIds = state.ptyIdsByTabId + const layouts = state.terminalLayoutsByTabId + + const replayed = applyWebSessionTabsSnapshot( + state, + makeTerminalSnapshot(), + ENVIRONMENT_ID, + NOW + 1 + ) + + expect(replayed).toBe(state) + expect(state.ptyIdsByTabId).toBe(ptyIds) + expect(state.terminalLayoutsByTabId).toBe(layouts) + }) + + it('updates terminal metadata without replacing PTY arrays or layouts', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const patch = applyWebSessionTabsSnapshot( + state, + makeTerminalSnapshot({ title: 'renamed shell' }), + ENVIRONMENT_ID, + NOW + 1 + ) + const next = { ...state, ...patch } + + expect(next.tabsByWorktree[WORKTREE_A]?.[0]?.title).toBe('renamed shell') + expect(next.ptyIdsByTabId).toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).toBe(state.terminalLayoutsByTabId) + }) + + it('clears unread state on replay without replacing PTY arrays or layouts', () => { + const hydrated = applySnapshot(makeState(), makeTerminalSnapshot()) + const state = { + ...hydrated, + unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } + } + const next = applySnapshot(state, makeTerminalSnapshot(), NOW + 1) + + expect(next.unreadTerminalTabs).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.ptyIdsByTabId).toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).toBe(state.terminalLayoutsByTabId) + }) + + it('replaces PTY arrays and layouts when the host rotates the handle', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const next = applySnapshot(state, makeTerminalSnapshot({ terminal: 'terminal-2' }), NOW + 1) + + expect(next.ptyIdsByTabId).not.toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).not.toBe(state.terminalLayoutsByTabId) + expect(next.ptyIdsByTabId[MIRRORED_TAB_ID]).toEqual(['remote:web-env-1@@terminal-2']) + expect(next.terminalLayoutsByTabId[MIRRORED_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [LEAF_ID]: 'remote:web-env-1@@terminal-2' + }) + }) + + it('removes the PTY key when a ready terminal becomes pending', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const next = applySnapshot(state, makeTerminalSnapshot({ terminal: null }), NOW + 1) + + expect(next.ptyIdsByTabId).not.toBe(state.ptyIdsByTabId) + expect(next.ptyIdsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.terminalLayoutsByTabId[MIRRORED_TAB_ID]?.ptyIdsByLeafId).toEqual({}) + }) + + it('cleans up terminal resources and unread state when the host omits the tab', () => { + const hydrated = applySnapshot(makeState(), makeTerminalSnapshot()) + const state = { + ...hydrated, + unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } + } + const next = applySnapshot(state, makeSnapshot(WORKTREE_A, [], null), NOW + 1) + + expect(next.ptyIdsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.terminalLayoutsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.unreadTerminalTabs).not.toHaveProperty(MIRRORED_TAB_ID) + }) + + it('keeps browser pages, handles, and certificate failures stable on exact replay', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + + const replayed = applyWebSessionTabsSnapshot( + state, + makeBrowserSnapshot(), + ENVIRONMENT_ID, + NOW + 1 + ) + + expect(replayed).toBe(state) + }) + + it('updates browser metadata without replacing handles or certificate failures', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot( + state, + makeBrowserSnapshot({ + title: 'Changed title', + url: 'https://example.com/changed', + loading: true + }), + NOW + 1 + ) + + expect(next.browserPagesByWorkspace).not.toBe(state.browserPagesByWorkspace) + expect(next.remoteBrowserPageHandlesByPageId).toBe(state.remoteBrowserPageHandlesByPageId) + expect(next.browserCertificateFailuresByPageId).toBe(state.browserCertificateFailuresByPageId) + }) + + it('clears a same-page certificate failure without replacing its page or handle', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot(state, makeBrowserSnapshot({ certificateFailure: null }), NOW + 1) + + expect(next.browserPagesByWorkspace).toBe(state.browserPagesByWorkspace) + expect(next.remoteBrowserPageHandlesByPageId).toBe(state.remoteBrowserPageHandlesByPageId) + expect(next.browserCertificateFailuresByPageId).not.toBe( + state.browserCertificateFailuresByPageId + ) + expect(next.browserCertificateFailuresByPageId).not.toHaveProperty('host-browser-page-1') + }) + + it('cleans up replaced browser page handles and certificate failures', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot( + state, + makeBrowserSnapshot({ + pageId: 'host-browser-page-2', + certificateFailure: null + }), + NOW + 1 + ) + + expect(next.browserPagesByWorkspace['host-browser-workspace']?.map((page) => page.id)).toEqual([ + 'host-browser-page-2' + ]) + expect(next.remoteBrowserPageHandlesByPageId).not.toHaveProperty('host-browser-page-1') + expect(next.remoteBrowserPageHandlesByPageId['host-browser-page-2']).toEqual({ + environmentId: ENVIRONMENT_ID, + remotePageId: 'host-browser-page-2' + }) + expect(next.browserCertificateFailuresByPageId).not.toHaveProperty('host-browser-page-1') + }) + + it('keeps resource maps stable across an unchanged multi-worktree browser batch', () => { + const snapshots = [ + makeBrowserSnapshot({ worktree: WORKTREE_A }), + makeBrowserSnapshot({ + worktree: WORKTREE_B, + tabId: 'host-browser-tab-2', + workspaceId: 'host-browser-workspace-2', + pageId: 'host-browser-page-2' + }) + ] + const initial = applyWebSessionTabsSnapshots(makeState(), snapshots, ENVIRONMENT_ID, NOW) + const state = { ...makeState(), ...initial } + + const replayed = applyWebSessionTabsSnapshots(state, snapshots, ENVIRONMENT_ID, NOW + 1) + + expect(replayed).toBe(state) + }) + + it('preserves unchanged browser resources when a sibling worktree replaces its page', () => { + const worktreeBSnapshot = makeBrowserSnapshot({ + worktree: WORKTREE_B, + tabId: 'host-browser-tab-2', + workspaceId: 'host-browser-workspace-2', + pageId: 'host-browser-page-2' + }) + const initial = applyWebSessionTabsSnapshots( + makeState(), + [makeBrowserSnapshot(), worktreeBSnapshot], + ENVIRONMENT_ID, + NOW + ) + const state = { ...makeState(), ...initial } + const pages = state.browserPagesByWorkspace['host-browser-workspace-2'] + const page = pages?.[0] + const handle = state.remoteBrowserPageHandlesByPageId['host-browser-page-2'] + const certificate = state.browserCertificateFailuresByPageId['host-browser-page-2'] + + const patch = applyWebSessionTabsSnapshots( + state, + [ + makeBrowserSnapshot({ pageId: 'host-browser-page-3', certificateFailure: null }), + worktreeBSnapshot + ], + ENVIRONMENT_ID, + NOW + 1 + ) + const next = { ...state, ...patch } + + expect(next.browserPagesByWorkspace['host-browser-workspace-2']).toBe(pages) + expect(next.browserPagesByWorkspace['host-browser-workspace-2']?.[0]).toBe(page) + expect(next.remoteBrowserPageHandlesByPageId['host-browser-page-2']).toBe(handle) + expect(next.browserCertificateFailuresByPageId['host-browser-page-2']).toBe(certificate) + }) + + it('emits no store updates for 128 accepted identical resource frames', () => { + const terminal = makeTerminalSnapshot() + const browser = makeBrowserSnapshot() + const snapshot = { + ...browser, + tabs: [...terminal.tabs.map((tab) => ({ ...tab, isActive: false })), ...browser.tabs] + } + const store = createStore(() => makeState()) + let notifications = 0 + store.subscribe(() => { + notifications += 1 + }) + store.setState((state) => + applyFreshWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW) + ) + notifications = 0 + const initial = store.getState() + + for (let snapshotVersion = 2; snapshotVersion <= 129; snapshotVersion += 1) { + store.setState((state) => + applyFreshWebSessionTabsSnapshot( + state, + { ...snapshot, snapshotVersion }, + ENVIRONMENT_ID, + NOW + snapshotVersion + ) + ) + } + + expect(notifications).toBe(0) + expect(store.getState()).toBe(initial) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 412911617df..d33c6f80b5d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -59,8 +59,9 @@ import { resetRendererOwnedAgentStatusPanesForTests } from '../components/terminal-pane/renderer-owned-agent-status-registry' import { - applyFreshWebSessionTabsSnapshot, - resetWebSessionTabsSnapshotFreshnessForTests + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + shouldApplyWebSessionTabsSnapshot } from './web-session-tabs-sync' // Why: web-session-tabs-sync imports the app-level store singleton; this @@ -148,9 +149,9 @@ function applyHostSnapshot( now: number ): void { const state = store.getState() - const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now) // The freshness gate must accept every republished frame (monotonic version). - expect(patch).not.toBe(state) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + const patch = applyWebSessionTabsSnapshot(state, snapshot, ENV, now) store.setState(patch as Partial) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 01836b7955a..89e0b6fda2b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -643,7 +643,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) applyFreshWebSessionTabsSnapshot( @@ -664,7 +665,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 0, - hostMappings: 0 + hostMappings: 0, + hostMappingWorktrees: 0 }) }) @@ -720,17 +722,89 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 2, - hostMappings: 2 + hostMappings: 2, + hostMappingWorktrees: 2 }) clearWebSessionTabsTrackingForEnvironment(ENV) expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) }) + it('clears one worktree mapping without dropping a sibling in the same environment', () => { + // Why: POSIX paths may contain ':', so this sibling's worktree id is prefixed by WT's — the case a prefix scan wiped. + const secondWorktree = `${WT}:2` + const terminalSnapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'host shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]) + const secondSnapshot = makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-2::${SECOND_LEAF_ID}`, + title: 'second shell', + parentTabId: 'host-tab-2', + leafId: SECOND_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ], + { worktree: secondWorktree } + ) + applyFreshWebSessionTabsSnapshot(makeState(), terminalSnapshot, ENV, NOW) + applyFreshWebSessionTabsSnapshot(makeState(), secondSnapshot, ENV, NOW) + + applyFreshWebSessionTabsSnapshot( + makeState(), + { + ...makeSnapshot([], { + publicationEpoch: 'removed-epoch', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + removed: true + } as RuntimeMobileSessionTabsResult, + ENV, + NOW + 1 + ) + + expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ + freshness: 1, + hostMappings: 1, + hostMappingWorktrees: 1 + }) + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: WT, + tabId: toWebTerminalSurfaceTabId('host-tab-1') + }) + ).toBeNull() + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: secondWorktree, + tabId: toWebTerminalSurfaceTabId('host-tab-2') + }) + ).toBe('host-tab-2') + }) + it('keeps a provisional Claude tab when the host Claude surface is unrelated', () => { const staleLocalAgentTab: TerminalTab = { id: 'local-agent-tab', @@ -2273,6 +2347,157 @@ describe('applyWebSessionTabsSnapshot', () => { ) }) + it('keeps an empty snapshot batch as an identity no-op', () => { + const state = makeState() + expect(applyWebSessionTabsSnapshots(state, [], ENV, NOW)).toBe(state) + }) + + it('matches sequential reconciliation across duplicate-worktree mixed snapshots', () => { + const secondWorktree = 'repo::/other-worktree' + const snapshots: RuntimeMobileSessionTabsResult[] = [ + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'first agent', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'first task', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'codex', + paneKey: HOST_SURFACE_ID, + stateHistory: [] + } + } + ]), + makeSnapshot( + [ + { + type: 'browser', + id: 'host-browser-unified', + title: 'Example Domain', + browserWorkspaceId: 'host-browser-workspace', + browserPageId: 'host-browser-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ], + { + worktree: secondWorktree, + activeGroupId: 'host-group-2', + activeTabId: 'host-browser-unified', + activeTabType: 'browser' + } + ), + makeSnapshot([], { + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-3::${THIRD_LEAF_ID}`, + title: 'replacement agent', + parentTabId: 'host-tab-3', + leafId: THIRD_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-3', + agentStatus: { + state: 'waiting', + prompt: 'replacement question', + updatedAt: NOW + 1, + stateStartedAt: NOW + 1, + agentType: 'codex', + paneKey: `host-tab-3::${THIRD_LEAF_ID}`, + stateHistory: [] + } + } + ], + { snapshotVersion: 3 } + ), + makeSnapshot( + [ + { + type: 'markdown', + id: 'host-readme-unified', + title: 'README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: '/repo/README.md', + sourceFilePath: '/repo/README.md', + sourceRelativePath: 'README.md', + documentVersion: 'file:/repo/README.md' + } + ], + { + worktree: secondWorktree, + snapshotVersion: 2, + activeGroupId: 'host-group-2', + activeTabId: 'host-readme-unified', + activeTabType: 'markdown' + } + ) + ] + const provisionalTab: TerminalTab = { + id: 'host-tab-1', + ptyId: null, + worktreeId: WT, + title: 'Codex', + defaultTitle: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'codex' + } + const initial = makeState({ + activeWorktreeId: null, + tabsByWorktree: { [WT]: [provisionalTab] }, + pendingStartupByTabId: { + [provisionalTab.id]: { command: 'codex' } + }, + automaticAgentResumeClaimsByTabId: { + [provisionalTab.id]: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-a' } + } + } + }) + const initialCopy = structuredClone(initial) + let sequential = initial + for (const snapshot of snapshots) { + const patch = applyWebSessionTabsSnapshot(sequential, snapshot, ENV, NOW) + if (patch !== sequential) { + sequential = { ...sequential, ...patch } + } + } + + resetWebSessionTabsSnapshotFreshnessForTests() + const batchPatch = applyWebSessionTabsSnapshots(initial, snapshots, ENV, NOW) + const batched = { ...initial, ...batchPatch } + + expect(batched).toEqual(sequential) + expect(initial).toEqual(initialCopy) + }) + it('replaces temporary web-created tabs once the host publishes the same PTY', () => { const localTab: TerminalTab = { id: 'local-web-tab', @@ -2877,28 +3102,30 @@ describe('applyWebSessionTabsSnapshot', () => { } } + const state = makeState({ + activeTabId: mirroredTabId, + activeTabIdByWorktree: { [WT]: mirroredTabId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { + [WT]: [ + { + id: mirroredTabId, + ptyId: 'remote:web-env-1@@terminal-2', + worktreeId: WT, + title: 'right pane', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + terminalLayoutsByTabId: { [mirroredTabId]: currentLayout } + }) + const patch = applyWebSessionTabsSnapshot( - makeState({ - activeTabId: mirroredTabId, - activeTabIdByWorktree: { [WT]: mirroredTabId }, - activeTabType: 'terminal', - activeTabTypeByWorktree: { [WT]: 'terminal' }, - tabsByWorktree: { - [WT]: [ - { - id: mirroredTabId, - ptyId: 'remote:web-env-1@@terminal-2', - worktreeId: WT, - title: 'right pane', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - } - ] - }, - terminalLayoutsByTabId: { [mirroredTabId]: currentLayout } - }), + state, makeSnapshot([ { type: 'terminal', @@ -2938,7 +3165,11 @@ describe('applyWebSessionTabsSnapshot', () => { ptyId: 'remote:web-env-1@@terminal-2', title: 'right pane' }) - expect(patch.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(SECOND_LEAF_ID) + // No layout patch at all, and the effective layout keeps the local active leaf. + expect(patch.terminalLayoutsByTabId).toBeUndefined() + expect({ ...state, ...patch }.terminalLayoutsByTabId[mirroredTabId]?.activeLeafId).toBe( + SECOND_LEAF_ID + ) }) it('removes a null-pty pending activation tab when the host publishes the initial terminal', () => { @@ -3315,10 +3546,8 @@ describe('applyWebSessionTabsSnapshot', () => { title: 'Example Domain' } ]) - expect(patch.remoteBrowserPageHandlesByPageId?.[page.id]).toEqual({ - environmentId: ENV, - remotePageId: 'host-browser-page' - }) + // Absent key, not a missing handle: the seeded { ENV, 'host-browser-page' } handle matched. + expect(patch.remoteBrowserPageHandlesByPageId).toBeUndefined() expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([ 'local-browser-unified' ]) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 37ab1feedd2..aa102eb11a5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -105,6 +105,10 @@ const latestSessionTabsSnapshotByWorktree = new Map() const replayableSessionTabsSnapshotByWorktree = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() +const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< + string, + Map> +>() type TerminalSurface = RuntimeMobileSessionTerminalClientTab type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } @@ -165,6 +169,33 @@ export type WebSessionTabsSyncState = Pick< > & Partial> +type WebSessionTabsBatchRecordKey = + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'agentStatusByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'browserCertificateFailuresByPageId' + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'pendingStartupByTabId' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + +type WebSessionTabsBatchContext = { + agentPaneKeysByTabId: Map> | null + changedRecords: Set +} + function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( Boolean(value) && @@ -331,15 +362,23 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { replayableSessionTabsSnapshotByWorktree.clear() lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() + hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() } export function _getWebSessionTabsTrackingCountsForTest(): { freshness: number hostMappings: number + hostMappingWorktrees: number } { + let hostMappingWorktrees = 0 + for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { + hostMappingWorktrees += mappingKeysByWorktree.size + } return { freshness: latestSessionTabsSnapshotByWorktree.size, - hostMappings: hostSessionTabIdByLocalKey.size + hostMappings: hostSessionTabIdByLocalKey.size, + // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. + hostMappingWorktrees } } @@ -352,12 +391,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) - const keyPrefix = `${environmentId}:${worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(environmentId, worktreeId) } export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { @@ -381,10 +415,15 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) lastHostTerminalTabCountByWorktree.delete(key) } } - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) + if (mappingKeysByWorktree) { + for (const mappingKeys of mappingKeysByWorktree.values()) { + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } } + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) } clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() @@ -398,6 +437,35 @@ function hostSessionTabMappingKey(args: { return `${args.environmentId}:${args.worktreeId}:${args.tabId}` } +function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { + const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) + const mappingKeys = mappingKeysByWorktree?.get(worktreeId) + if (!mappingKeys) { + return + } + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + mappingKeysByWorktree?.delete(worktreeId) + if (mappingKeysByWorktree?.size === 0) { + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) + } +} + +function setHostSessionTabIdMapping( + args: { environmentId: string; worktreeId: string; tabId: string }, + hostTabId: string +): void { + const mappingKey = hostSessionTabMappingKey(args) + hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() + const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() + mappingKeys.add(mappingKey) + mappingKeysByWorktree.set(args.worktreeId, mappingKeys) + hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) +} + export function resolveHostSessionTabIdForWebSessionTab( _state: WebSessionTabsSyncState, args: { @@ -735,13 +803,60 @@ function isFencedClientAgentStatus( return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) } +function batchAgentPaneKeysForTabs( + state: WebSessionTabsSyncState, + tabIds: ReadonlySet, + batchContext?: WebSessionTabsBatchContext +): string[] { + if (!batchContext) { + return Object.keys(state.agentStatusByPaneKey) + } + if (!batchContext.agentPaneKeysByTabId) { + batchContext.agentPaneKeysByTabId = new Map() + for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() + paneKeys.add(paneKey) + batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) + } + } + return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) +} + +function updateBatchAgentPaneKey( + paneKey: string, + present: boolean, + batchContext?: WebSessionTabsBatchContext +): void { + const tabId = parsePaneKey(paneKey)?.tabId + const index = batchContext?.agentPaneKeysByTabId + if (!tabId || !index) { + return + } + if (present) { + const paneKeys = index.get(tabId) ?? new Set() + paneKeys.add(paneKey) + index.set(tabId, paneKeys) + return + } + const paneKeys = index.get(tabId) + paneKeys?.delete(paneKey) + if (paneKeys?.size === 0) { + index.delete(tabId) + } +} + /** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */ function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], terminalSurfaceTabs: readonly TerminalSurface[], mirroredTerminalTabs: readonly MirroredTerminalTab[], - now: number + now: number, + batchContext?: WebSessionTabsBatchContext ): Pick | null { const mirroredTabIds = new Set() for (const tab of currentTerminalTabs) { @@ -812,7 +927,7 @@ function buildMirroredAgentStatusPatch( let aggregateRelevantChange = false let sortRelevantChange = false - for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { continue } @@ -830,9 +945,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } delete nextAgentStatusByPaneKey[paneKey] + updateBatchAgentPaneKey(paneKey, false, batchContext) changed = true aggregateRelevantChange = true sortRelevantChange = true @@ -844,9 +964,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } nextAgentStatusByPaneKey[paneKey] = entry + updateBatchAgentPaneKey(paneKey, true, batchContext) changed = true const entryAttributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId @@ -1075,7 +1200,7 @@ function buildMirroredBrowserTabs( const createdAt = existing?.page.createdAt ?? now + sortOffset + index const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId const title = tab.title.trim() || 'Browser' - const page: BrowserPage = { + const nextPage: BrowserPage = { id: pageId, workspaceId, worktreeId: snapshot.worktree, @@ -1090,6 +1215,9 @@ function buildMirroredBrowserTabs( browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null } + // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace + // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. + const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage const workspace: BrowserWorkspace = { id: workspaceId, worktreeId: snapshot.worktree, @@ -1285,34 +1413,20 @@ function updateHostSessionTabIdMappings(args: { browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] }): void { - const keyPrefix = `${args.environmentId}:${args.worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) for (const surface of args.terminalSurfaces) { const localId = toWebTerminalSurfaceTabId(surface.parentTabId) if (mirroredTerminalIds.has(localId)) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: localId }), - surface.parentTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) } } for (const entry of args.browserTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } for (const entry of args.editorTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } } @@ -1500,20 +1614,47 @@ function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] return [...base.filter((id) => id !== tabId), tabId] } +function writableWebSessionTabsRecord( + state: WebSessionTabsSyncState, + recordKey: K, + batchContext?: WebSessionTabsBatchContext +): NonNullable { + const record = (state[recordKey] ?? {}) as NonNullable + if (!batchContext) { + return { ...record } as NonNullable + } + // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. + if (batchContext.changedRecords.has(recordKey)) { + return record + } + const next = { ...record } as NonNullable + const mutableState = state as unknown as Record< + WebSessionTabsBatchRecordKey, + Record + > + mutableState[recordKey] = next as Record + batchContext.changedRecords.add(recordKey) + return next +} + function withWorktreeEntry( - record: Record, + state: WebSessionTabsSyncState, + recordKey: WebSessionTabsBatchRecordKey, key: string, value: T | null, - equal: (a: T | undefined, b: T | null) => boolean + equal: (a: T | undefined, b: T | null) => boolean, + batchContext?: WebSessionTabsBatchContext, + deleteNull = true ): Record { + const record = (state[recordKey] ?? {}) as Record if (equal(record[key], value)) { return record } - const next = { ...record } - if (value === null) { + const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record + if (value === null && deleteNull) { delete next[key] } else { - next[key] = value + next[key] = value as T } return next } @@ -1763,11 +1904,12 @@ function findCurrentVisibleUnifiedTabId(args: { return null } -export function applyWebSessionTabsSnapshot( +function applyWebSessionTabsSnapshotWithContext( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + batchContext?: WebSessionTabsBatchContext ): WebSessionTabsSyncState | Partial { const worktreeId = rawSnapshot.worktree if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { @@ -1892,6 +2034,9 @@ export function applyWebSessionTabsSnapshot( const removedTerminalIds = new Set( currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) ) + const removedTerminalResourceIds = [...removedTerminalIds].filter( + (tabId) => !mirroredTerminalIds.has(tabId) + ) for (const provisionalTabId of exactProvisionalHandoffs) { clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) } @@ -2259,28 +2404,42 @@ export function applyWebSessionTabsSnapshot( })() let nextPtyIdsByTabId = state.ptyIdsByTabId - for (const removedId of removedTerminalIds) { + for (const removedId of removedTerminalResourceIds) { if (nextPtyIdsByTabId[removedId]) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId delete nextPtyIdsByTabId[removedId] } } for (const { tab, ptyIds } of mirroredTerminalTabs) { + if (ptyIds.length === 0) { + if (nextPtyIdsByTabId[tab.id]) { + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[tab.id] + } + continue + } const current = nextPtyIdsByTabId[tab.id] ?? [] if (!sameStringArray(current, ptyIds)) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId nextPtyIdsByTabId[tab.id] = ptyIds } } let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId - for (const removedId of removedTerminalIds) { + for (const removedId of removedTerminalResourceIds) { if (nextTerminalLayoutsByTabId[removedId]) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId delete nextTerminalLayoutsByTabId[removedId] } @@ -2289,7 +2448,7 @@ export function applyWebSessionTabsSnapshot( if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId nextTerminalLayoutsByTabId[tab.id] = layout } @@ -2300,7 +2459,7 @@ export function applyWebSessionTabsSnapshot( if (nextUnreadTerminalTabs[removedId]) { nextUnreadTerminalTabs = nextUnreadTerminalTabs === state.unreadTerminalTabs - ? { ...state.unreadTerminalTabs } + ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) : nextUnreadTerminalTabs delete nextUnreadTerminalTabs[removedId] } @@ -2314,14 +2473,14 @@ export function applyWebSessionTabsSnapshot( if (nextPendingStartupByTabId[removedId]) { nextPendingStartupByTabId = nextPendingStartupByTabId === pendingStartupByTabId - ? { ...pendingStartupByTabId } + ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) : nextPendingStartupByTabId delete nextPendingStartupByTabId[removedId] } if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { nextAutomaticAgentResumeClaimsByTabId = nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId - ? { ...automaticAgentResumeClaimsByTabId } + ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) : nextAutomaticAgentResumeClaimsByTabId delete nextAutomaticAgentResumeClaimsByTabId[removedId] } @@ -2330,29 +2489,52 @@ export function applyWebSessionTabsSnapshot( let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId - for (const removedWorkspaceId of removedBrowserWorkspaceIds) { - const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] - if (nextBrowserPagesByWorkspace[removedWorkspaceId]) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } - : nextBrowserPagesByWorkspace - delete nextBrowserPagesByWorkspace[removedWorkspaceId] - } - for (const page of pages) { - if (nextBrowserCertificateFailuresByPageId[page.id]) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } - : nextBrowserCertificateFailuresByPageId - delete nextBrowserCertificateFailuresByPageId[page.id] + if (removedBrowserWorkspaceIds.size > 0) { + const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) + const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) + for (const workspace of retainedBrowserTabs) { + for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { + nextBrowserPageIds.add(page.id) } - if (nextRemoteBrowserPageHandlesByPageId[page.id]) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } - : nextRemoteBrowserPageHandlesByPageId - delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + for (const removedWorkspaceId of removedBrowserWorkspaceIds) { + const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] + if ( + !nextBrowserWorkspaceIds.has(removedWorkspaceId) && + nextBrowserPagesByWorkspace[removedWorkspaceId] + ) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + delete nextBrowserPagesByWorkspace[removedWorkspaceId] + } + for (const page of pages) { + if (nextBrowserPageIds.has(page.id)) { + continue + } + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord( + state, + 'browserCertificateFailuresByPageId', + batchContext + ) + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } + if (nextRemoteBrowserPageHandlesByPageId[page.id]) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord( + state, + 'remoteBrowserPageHandlesByPageId', + batchContext + ) + : nextRemoteBrowserPageHandlesByPageId + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } } } } @@ -2361,7 +2543,7 @@ export function applyWebSessionTabsSnapshot( if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) : nextBrowserPagesByWorkspace nextBrowserPagesByWorkspace[page.workspaceId] = [page] } @@ -2372,7 +2554,7 @@ export function applyWebSessionTabsSnapshot( ) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) : nextRemoteBrowserPageHandlesByPageId nextRemoteBrowserPageHandlesByPageId[page.id] = { environmentId, @@ -2387,7 +2569,7 @@ export function applyWebSessionTabsSnapshot( ) { nextBrowserCertificateFailuresByPageId = nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } + ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) : nextBrowserCertificateFailuresByPageId if (certificateFailure) { nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure @@ -2398,28 +2580,36 @@ export function applyWebSessionTabsSnapshot( } const nextTabsByWorktree = withWorktreeEntry( - state.tabsByWorktree, + state, + 'tabsByWorktree', worktreeId, nextTerminalTabs, - sameTerminalTabs + sameTerminalTabs, + batchContext ) const nextBrowserTabsByWorktree = withWorktreeEntry( - state.browserTabsByWorktree, + state, + 'browserTabsByWorktree', worktreeId, nextBrowserTabs, - sameBrowserTabs + sameBrowserTabs, + batchContext ) const nextUnifiedTabsByWorktree = withWorktreeEntry( - state.unifiedTabsByWorktree, + state, + 'unifiedTabsByWorktree', worktreeId, nextUnifiedTabs, - sameUnifiedTabs + sameUnifiedTabs, + batchContext ) const nextGroupsByWorktree = withWorktreeEntry( - state.groupsByWorktree, + state, + 'groupsByWorktree', worktreeId, nextGroups, - sameGroups + sameGroups, + batchContext ) const nextActiveGroupId = // Why: status/title snapshots carry the host's last active tab; a client that already switched panes keeps its local group focus. @@ -2429,7 +2619,14 @@ export function applyWebSessionTabsSnapshot( null const nextActiveGroupIdByWorktree = nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId - ? { ...state.activeGroupIdByWorktree, [worktreeId]: nextActiveGroupId ?? targetGroupId } + ? withWorktreeEntry( + state, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId ?? targetGroupId, + (current, next) => current === next, + batchContext + ) : state.activeGroupIdByWorktree const nextLayoutByWorktree = (() => { if (!nextGroups) { @@ -2467,28 +2664,58 @@ export function applyWebSessionTabsSnapshot( if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) { return state.layoutByWorktree } - return { - ...state.layoutByWorktree, - [worktreeId]: fallbackLayout - } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + fallbackLayout, + (current, next) => current === next, + batchContext + ) })() const nextTabBarOrderByWorktree = withWorktreeEntry( - state.tabBarOrderByWorktree, + state, + 'tabBarOrderByWorktree', worktreeId, nextTabBarOrder.length > 0 ? nextTabBarOrder : null, - (a, b) => sameStringArray(a ?? [], b ?? []) + (a, b) => sameStringArray(a ?? [], b ?? []), + batchContext ) const nextActiveTabIdByWorktree = (state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId - ? { ...state.activeTabIdByWorktree, [worktreeId]: nextActiveTerminalId } + ? withWorktreeEntry( + state, + 'activeTabIdByWorktree', + worktreeId, + nextActiveTerminalId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeTabIdByWorktree const nextActiveBrowserTabIdByWorktree = (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId - ? { ...state.activeBrowserTabIdByWorktree, [worktreeId]: nextActiveBrowserWorkspaceId } + ? withWorktreeEntry( + state, + 'activeBrowserTabIdByWorktree', + worktreeId, + nextActiveBrowserWorkspaceId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeBrowserTabIdByWorktree const nextActiveFileIdByWorktree = (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId - ? { ...state.activeFileIdByWorktree, [worktreeId]: nextActiveEditorFileId } + ? withWorktreeEntry( + state, + 'activeFileIdByWorktree', + worktreeId, + nextActiveEditorFileId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId const focusIntentVisibleTabType = @@ -2564,14 +2791,22 @@ export function applyWebSessionTabsSnapshot( const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType const nextActiveTabTypeByWorktree = state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType - ? { ...state.activeTabTypeByWorktree, [worktreeId]: nextVisibleTabType } + ? withWorktreeEntry( + state, + 'activeTabTypeByWorktree', + worktreeId, + nextVisibleTabType, + (current, next) => current === next, + batchContext + ) : state.activeTabTypeByWorktree const agentStatusPatch = buildMirroredAgentStatusPatch( state, currentTerminalTabs, terminalSurfaceTabs, mirroredTerminalTabs, - now + now, + batchContext ) const patch: Partial = { @@ -2641,21 +2876,45 @@ export function applyWebSessionTabsSnapshot( return Object.keys(patch).length === 0 ? state : patch } +export function applyWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) +} + export function applyWebSessionTabsSnapshots( state: WebSessionTabsSyncState, snapshots: readonly RuntimeMobileSessionTabsResult[], environmentId: string, now = Date.now() ): WebSessionTabsSyncState | Partial { - let nextState = state + const nextState = { ...state } + const batchContext: WebSessionTabsBatchContext = { + agentPaneKeysByTabId: null, + changedRecords: new Set() + } let mergedPatch: Partial = {} for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId, now) + const patch = applyWebSessionTabsSnapshotWithContext( + nextState, + snapshot, + environmentId, + now, + batchContext + ) if (patch === nextState) { continue } mergedPatch = { ...mergedPatch, ...patch } - nextState = { ...nextState, ...patch } + Object.assign(nextState, patch) + } + const mutableMergedPatch = mergedPatch as Record + const mutableNextState = nextState as unknown as Record + for (const recordKey of batchContext.changedRecords) { + mutableMergedPatch[recordKey] = mutableNextState[recordKey] } return Object.keys(mergedPatch).length === 0 ? state : mergedPatch } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 5687dfc9776..e0e2a5e88a6 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -17,9 +17,11 @@ import { createEditorSlice } from './slices/editor' import { createStatsSlice } from './slices/stats' import { createMemorySlice } from './slices/memory' import { createWorkspaceSpaceSlice } from './slices/workspace-space' -import { createClaudeUsageSlice } from './slices/claude-usage' -import { createCodexUsageSlice } from './slices/codex-usage' -import { createOpenCodeUsageSlice } from './slices/opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './slices/usage-provider-slices' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' diff --git a/src/renderer/src/store/slices/claude-usage.ts b/src/renderer/src/store/slices/claude-usage.ts deleted file mode 100644 index b9e6d0c74fc..00000000000 --- a/src/renderer/src/store/slices/claude-usage.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../../../../shared/claude-usage-types' -import type { AppState } from '../types' - -export type ClaudeUsageSlice = { - claudeUsageScope: ClaudeUsageScope - claudeUsageRange: ClaudeUsageRange - claudeUsageScanState: ClaudeUsageScanState | null - claudeUsageSummary: ClaudeUsageSummary | null - claudeUsageDaily: ClaudeUsageDailyPoint[] - claudeUsageModelBreakdown: ClaudeUsageBreakdownRow[] - claudeUsageProjectBreakdown: ClaudeUsageBreakdownRow[] - claudeUsageRecentSessions: ClaudeUsageSessionRow[] - setClaudeUsageEnabled: (enabled: boolean) => Promise - setClaudeUsageScope: (scope: ClaudeUsageScope) => Promise - setClaudeUsageRange: (range: ClaudeUsageRange) => Promise - fetchClaudeUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableClaudeUsage: () => Promise - refreshClaudeUsage: () => Promise -} - -export const createClaudeUsageSlice: StateCreator = ( - set, - get -) => ({ - claudeUsageScope: 'orca', - claudeUsageRange: '30d', - claudeUsageScanState: null, - claudeUsageSummary: null, - claudeUsageDaily: [], - claudeUsageModelBreakdown: [], - claudeUsageProjectBreakdown: [], - claudeUsageRecentSessions: [], - - setClaudeUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.claudeUsage.setEnabled({ - enabled - })) as ClaudeUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - // Why: every enable should look like a fresh scan cycle in the UI. - // Reusing the last completed timestamp makes repeated toggles skip the - // loading skeleton and briefly render an empty analytics pane. - claudeUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - claudeUsageSummary: null, - claudeUsageDaily: [], - claudeUsageModelBreakdown: [], - claudeUsageProjectBreakdown: [], - claudeUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchClaudeUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update Claude usage setting:', error) - } - }, - - setClaudeUsageScope: async (scope) => { - set({ claudeUsageScope: scope }) - await get().fetchClaudeUsage() - }, - - setClaudeUsageRange: async (range) => { - set({ claudeUsageRange: range }) - await get().fetchClaudeUsage() - }, - - fetchClaudeUsage: async (opts) => { - try { - const scanState = (await window.api.claudeUsage.getScanState()) as - | ClaudeUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().claudeUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().claudeUsageSummary === null - set({ - claudeUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { claudeUsageScope, claudeUsageRange } = get() - const snapshot = (await window.api.claudeUsage.getSnapshot({ - scope: claudeUsageScope, - range: claudeUsageRange, - limit: 10 - })) as ClaudeUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyClaudeData - - if (hasCachedSnapshot) { - set({ - claudeUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - claudeUsageSummary: snapshot.summary, - claudeUsageDaily: snapshot.daily, - claudeUsageModelBreakdown: snapshot.modelBreakdown, - claudeUsageProjectBreakdown: snapshot.projectBreakdown, - claudeUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - claudeUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.claudeUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { claudeUsageScope: refreshedScope, claudeUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.claudeUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as ClaudeUsageSnapshot - - set({ - claudeUsageScanState: refreshedSnapshot.scanState, - claudeUsageSummary: refreshedSnapshot.summary, - claudeUsageDaily: refreshedSnapshot.daily, - claudeUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - claudeUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - claudeUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch Claude usage:', error) - } - }, - - enableClaudeUsage: async () => { - await get().setClaudeUsageEnabled(true) - }, - - refreshClaudeUsage: async () => { - await get().fetchClaudeUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/codex-usage.ts b/src/renderer/src/store/slices/codex-usage.ts deleted file mode 100644 index fa90bc0eab1..00000000000 --- a/src/renderer/src/store/slices/codex-usage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../../../../shared/codex-usage-types' -import type { AppState } from '../types' - -export type CodexUsageSlice = { - codexUsageScope: CodexUsageScope - codexUsageRange: CodexUsageRange - codexUsageScanState: CodexUsageScanState | null - codexUsageSummary: CodexUsageSummary | null - codexUsageDaily: CodexUsageDailyPoint[] - codexUsageModelBreakdown: CodexUsageBreakdownRow[] - codexUsageProjectBreakdown: CodexUsageBreakdownRow[] - codexUsageRecentSessions: CodexUsageSessionRow[] - setCodexUsageEnabled: (enabled: boolean) => Promise - setCodexUsageScope: (scope: CodexUsageScope) => Promise - setCodexUsageRange: (range: CodexUsageRange) => Promise - fetchCodexUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableCodexUsage: () => Promise - refreshCodexUsage: () => Promise -} - -export const createCodexUsageSlice: StateCreator = ( - set, - get -) => ({ - codexUsageScope: 'orca', - codexUsageRange: '30d', - codexUsageScanState: null, - codexUsageSummary: null, - codexUsageDaily: [], - codexUsageModelBreakdown: [], - codexUsageProjectBreakdown: [], - codexUsageRecentSessions: [], - - setCodexUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.codexUsage.setEnabled({ - enabled - })) as CodexUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - codexUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - codexUsageSummary: null, - codexUsageDaily: [], - codexUsageModelBreakdown: [], - codexUsageProjectBreakdown: [], - codexUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchCodexUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update Codex usage setting:', error) - } - }, - - setCodexUsageScope: async (scope) => { - set({ codexUsageScope: scope }) - await get().fetchCodexUsage() - }, - - setCodexUsageRange: async (range) => { - set({ codexUsageRange: range }) - await get().fetchCodexUsage() - }, - - fetchCodexUsage: async (opts) => { - try { - const scanState = (await window.api.codexUsage.getScanState()) as - | CodexUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().codexUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().codexUsageSummary === null - set({ - codexUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { codexUsageScope, codexUsageRange } = get() - const snapshot = (await window.api.codexUsage.getSnapshot({ - scope: codexUsageScope, - range: codexUsageRange, - limit: 10 - })) as CodexUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyCodexData - - if (hasCachedSnapshot) { - set({ - codexUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - codexUsageSummary: snapshot.summary, - codexUsageDaily: snapshot.daily, - codexUsageModelBreakdown: snapshot.modelBreakdown, - codexUsageProjectBreakdown: snapshot.projectBreakdown, - codexUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - codexUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.codexUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { codexUsageScope: refreshedScope, codexUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.codexUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as CodexUsageSnapshot - - set({ - codexUsageScanState: refreshedSnapshot.scanState, - codexUsageSummary: refreshedSnapshot.summary, - codexUsageDaily: refreshedSnapshot.daily, - codexUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - codexUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - codexUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch Codex usage:', error) - } - }, - - enableCodexUsage: async () => { - await get().setCodexUsageEnabled(true) - }, - - refreshCodexUsage: async () => { - await get().fetchCodexUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 49c0ae51b65..c6908d4ad6a 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -122,9 +122,11 @@ import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.test.ts b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts new file mode 100644 index 00000000000..41918b28c07 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' + +function makeRepo(id: string, path: string): Repo { + return { id, path, displayName: id, badgeColor: 'blue', addedAt: 1, kind: 'git' } +} + +describe('getGitHubRepoLookupIndex', () => { + it('retains the first duplicate after indexing later rows', () => { + const first = makeRepo('duplicate', '/first') + const second = makeRepo('duplicate', '/second') + const later = makeRepo('later', '/later') + const lookup = getGitHubRepoLookupIndex([first, second, later]) + + expect(lookup.findById('duplicate')).toBe(first) + expect(lookup.findById('later')).toBe(later) + expect(lookup.findById('duplicate')).toBe(first) + }) + + it('matches Array.find ordering for combined ID and path lookups', () => { + const pathMatch = makeRepo('other', '/target') + const idMatch = makeRepo('target', '/other') + const lookup = getGitHubRepoLookupIndex([pathMatch, idMatch]) + + expect(lookup.findByIdOrPath('target', '/target')).toBe(pathMatch) + }) + + it('scans each repo once across repeated misses', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + const lookup = getGitHubRepoLookupIndex(repos) + + expect(lookup.findById('missing-first')).toBeUndefined() + expect(lookup.findById('missing-second')).toBeUndefined() + expect(idReads).toBe(repos.length) + }) + + it('stops after a sparse first-row match', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + + expect(getGitHubRepoLookupIndex(repos).findById('repo-0')).toBe(repos[0]) + expect(idReads).toBe(1) + }) +}) diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.ts b/src/renderer/src/store/slices/github-repo-lookup-index.ts new file mode 100644 index 00000000000..0c21a2fbd50 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.ts @@ -0,0 +1,72 @@ +import type { Repo } from '../../../../shared/types' + +type IndexedRepo = { + index: number + repo: Repo +} + +export type GitHubRepoLookupIndex = { + findById: (repoId: string) => Repo | undefined + findByPath: (repoPath: string) => Repo | undefined + findByIdOrPath: (repoId: string | undefined, repoPath: string) => Repo | undefined +} + +// Why: repo identity/path updates replace this array, while weak ownership avoids retaining superseded snapshots. +const lookupByRepos = new WeakMap() +const EMPTY_REPOS: readonly Repo[] = [] + +export function getGitHubRepoLookupIndex( + repos: readonly Repo[] | undefined +): GitHubRepoLookupIndex { + const repoRows = repos ?? EMPTY_REPOS + const cached = lookupByRepos.get(repoRows) + if (cached) { + return cached + } + + const byId = new Map() + const byPath = new Map() + let indexedCount = 0 + + const scanUntil = (matches: (repoId: string, repoPath: string) => boolean): Repo | undefined => { + while (indexedCount < repoRows.length) { + const index = indexedCount + const repo = repoRows[index] + indexedCount += 1 + const repoId = repo.id + const repoPath = repo.path + if (!byId.has(repoId)) { + byId.set(repoId, { index, repo }) + } + if (!byPath.has(repoPath)) { + byPath.set(repoPath, { index, repo }) + } + if (matches(repoId, repoPath)) { + return repo + } + } + return undefined + } + + const lookup: GitHubRepoLookupIndex = { + findById: (repoId) => byId.get(repoId)?.repo ?? scanUntil((id) => id === repoId), + findByPath: (repoPath) => + byPath.get(repoPath)?.repo ?? scanUntil((_id, path) => path === repoPath), + findByIdOrPath: (repoId, repoPath) => { + if (!repoId) { + return lookup.findByPath(repoPath) + } + const idMatch = byId.get(repoId) + const pathMatch = byPath.get(repoPath) + if (idMatch || pathMatch) { + if (!pathMatch || (idMatch && idMatch.index < pathMatch.index)) { + return idMatch?.repo + } + return pathMatch.repo + } + return scanUntil((id, path) => id === repoId || path === repoPath) + } + } + lookupByRepos.set(repoRows, lookup) + return lookup +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index cadbc5acb8f..4b7d19ad284 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -5425,6 +5425,447 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { describe('createGitHubSlice.refreshAllGitHub', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('publishes no store update when the cache sweep changes nothing', () => { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + worktreesByRepo: { 'repo-1': [makePRRefreshWorktree()] } + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(publications).toBe(0) + }) + + it('still clears populated comments when no workspace refresh is needed', () => { + const store = createTestStore() + store.setState({ + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(store.getState().commentsCache).toEqual({}) + expect(publications).toBe(1) + }) + + it('skips repo and worktree identity reads when no GitHub decoration is visible', () => { + const store = createTestStore() + const repoId = 'repo-idle' + let repoIdentityReads = 0 + let worktreeRepoIdentityReads = 0 + const repo = { id: repoId, path: '/idle', name: 'idle', kind: 'git' as const } + const worktree = makePRRefreshWorktree({ + id: 'wt-idle', + repoId, + path: '/idle/worktrees/idle', + branch: 'feature/idle' + }) + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + Object.defineProperty(worktree, 'repoId', { + configurable: true, + enumerable: true, + get: () => { + worktreeRepoIdentityReads += 1 + return repoId + } + }) + store.setState({ + repos: [repo], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + worktreesByRepo: { [repoId]: [worktree] } + } as unknown as Partial) + repoIdentityReads = 0 + worktreeRepoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(store.getState().commentsCache).toEqual({}) + expect(repoIdentityReads).toBe(0) + expect(worktreeRepoIdentityReads).toBe(0) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('bounds stale PR repo identity reads to a constant amount per repo and worktree', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repoIds = Array.from({ length: repoCount }, (_, index) => `repo-${index}`) + const repos = repoIds.map((repoId) => { + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repoIds.map((repoId, index) => [ + repoId, + [ + makePRRefreshWorktree({ + id: `wt-${index}`, + repoId, + path: `/${repoId}/worktrees/feature`, + branch: `feature/${index}`, + lastActivityAt: index + }) + ] + ]) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(5) + }) + + it('stops repo indexing after a sparse enabled match', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-0': [ + makePRRefreshWorktree({ + id: 'wt-sparse', + repoId: 'repo-0', + path: '/repo-0/worktrees/feature', + branch: 'feature/sparse' + }) + ] + } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledOnce() + }) + + it('does not restart repo indexing for repeated missing IDs', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const missingWorktrees = Array.from({ length: repoCount }, (_, index) => + makePRRefreshWorktree({ + id: `wt-missing-${index}`, + repoId: `missing-${index}`, + branch: `feature/missing-${index}` + }) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { missing: missingWorktrees } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('keeps runtime PR dispatch identity reads linear in PR-status grouping', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-pr', + ok: true, + result: makePR({ number: 12 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + let worktreeIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `runtime-repo-${index}` + const repoPath = `/runtime/repo-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => { + const worktreeId = `runtime-wt-${index}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + branch: `feature/${index}`, + linkedPR: 12, + lastActivityAt: index + }) + Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + worktreeIdentityReads += 1 + return worktreeId + } + }) + return [repo.id, [worktree]] + }) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'pr-status', + worktreeCardProperties: ['comment'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + worktreeIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().prCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 20) + expect(worktreeIdentityReads).toBeLessThanOrEqual(repoCount * 5) + }) + + it('keeps runtime issue dispatch repo identity reads linear', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-issue', + ok: true, + result: null, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `issue-repo-${index}` + const repoPath = `/runtime/issues-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => [ + repo.id, + [ + makePRRefreshWorktree({ + id: `issue-wt-${index}`, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + linkedIssue: index + 1 + }) + ] + ]) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'repo', + worktreeCardProperties: ['issue'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().issueCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 15) + }) + + it('keeps the first repo owner when duplicate IDs span hosts', () => { + const store = createTestStore() + const repoId = 'duplicate-repo' + const firstRepo = { + id: repoId, + path: '/first', + name: 'first', + kind: 'git' as const, + connectionId: 'first', + executionHostId: 'ssh:first' + } + const secondRepo = { + id: repoId, + path: '/second', + name: 'second', + kind: 'git' as const, + connectionId: 'second', + executionHostId: 'ssh:second' + } + const laterRepo = { + id: 'later-repo', + path: '/later', + name: 'later', + kind: 'git' as const + } + store.setState({ + repos: [firstRepo, secondRepo, laterRepo], + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + sshConnectionStates: new Map([ + ['first', { status: 'connected' }], + ['second', { status: 'connected' }] + ]), + worktreesByRepo: { + first: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-first', + repoId, + path: '/first/worktrees/feature', + branch: 'feature/duplicate-first' + }) + ], + middle: [ + makePRRefreshWorktree({ + id: 'wt-later', + repoId: laterRepo.id, + path: '/later/worktrees/feature', + branch: 'feature/later' + }) + ], + last: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-last', + repoId, + path: '/first/worktrees/last', + branch: 'feature/duplicate-last' + }) + ] + } + } as unknown as Partial) + + store.getState().refreshAllGitHub() + + const duplicateCandidates = mockApi.gh.enqueuePRRefresh.mock.calls + .map(([call]) => call.candidate) + .filter((candidate) => candidate.repoId === repoId) + expect(duplicateCandidates).toHaveLength(2) + expect(duplicateCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }), + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }) + ]) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) it('refreshes stale PR data when source control is the visible PR surface', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 21b66f2e9bd..5b16b72796c 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -76,6 +76,7 @@ import { } from '../../../../shared/task-source-context' import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -125,7 +126,7 @@ function getRuntimeRepoTarget( if (target.kind !== 'environment') { return null } - const repo = state.repos.find((candidate) => candidate.path === repoPath) + const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) return repo ? { target, repo } : null } @@ -249,9 +250,9 @@ function findRepoForGitHubOwner( repoId: string | undefined, repoPath: string ): Repo | undefined { - return (state.repos ?? []).find((candidate) => - repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath - ) + return state.repos + ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) + : undefined } function getGitHubFocusedRepoOwnerHostId( @@ -962,13 +963,7 @@ function getPRChecksCacheTtl(entry: CacheEntry | undefined): nu } function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - return worktree - } - } - return null + return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null } type WorktreeLookupEntry = { @@ -981,9 +976,12 @@ type WorktreeLookupIndex = { repoHostIdsByRepoId: Map> } +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] + function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { for (const worktree of worktrees) { const worktreeId = worktree.id const existing = byId.get(worktreeId) @@ -1007,11 +1005,29 @@ function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { return { byId, repoHostIdsByRepoId } } +// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. +const worktreeLookupIndexes = new WeakMap< + AppState['worktreesByRepo'], + { repos: AppState['repos']; index: WorktreeLookupIndex } +>() + +function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const repos = state.repos ?? EMPTY_WORKTREE_REPOS + const cached = worktreeLookupIndexes.get(worktreesByRepo) + if (cached && cached.repos === repos) { + return cached.index + } + const index = buildWorktreeLookupIndex(state) + worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) + return index +} + function findUniqueWorktreeById( state: AppState, worktreeId: string, executionHostId?: string, - lookupIndex = buildWorktreeLookupIndex(state) + lookupIndex = getWorktreeLookupIndex(state) ): Worktree | null { const match = lookupIndex.byId.get(worktreeId)?.unique ?? null // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. @@ -1130,9 +1146,10 @@ function shouldApplyBranchMismatchedLinkedPRClear(args: { function buildPRRefreshCandidate( state: AppState, worktree: Worktree, - repoPath?: string + repoPath?: string, + repoOverride?: Repo ): GitHubPRRefreshCandidate | null { - const repo = state.repos.find((r) => r.id === worktree.repoId) + const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) if (!repo) { return null } @@ -2989,9 +3006,10 @@ export const createGitHubSlice: StateCreator = (s }, fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) + const repoLookup = getGitHubRepoLookupIndex(get().repos) + const repo = options?.repoId + ? repoLookup.findById(options.repoId) + : repoLookup.findByPath(repoPath) const repoId = options?.repoId ?? repo?.id const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) const cacheKey = prCacheKey( @@ -4415,22 +4433,33 @@ export const createGitHubSlice: StateCreator = (s refreshAllGitHub: () => { // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. - set((s) => ({ - commentsCache: {}, - prCache: evictStaleEntries(s.prCache), - issueCache: evictStaleEntries(s.issueCache), - checksCache: evictStaleEntries(s.checksCache), - workItemsCache: evictStaleEntries(s.workItemsCache), - projectViewCache: evictStaleEntries(s.projectViewCache), - prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) - })) + set((s) => { + const next = { + commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, + prCache: evictStaleEntries(s.prCache), + issueCache: evictStaleEntries(s.issueCache), + checksCache: evictStaleEntries(s.checksCache), + workItemsCache: evictStaleEntries(s.workItemsCache), + projectViewCache: evictStaleEntries(s.projectViewCache), + prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) + } + // Why: each eviction helper returns its input untouched when nothing changed, so an + // unchanged sweep can return `s` and avoid waking every subscriber on window resume. + return next.commentsCache === s.commentsCache && + next.prCache === s.prCache && + next.issueCache === s.issueCache && + next.checksCache === s.checksCache && + next.workItemsCache === s.workItemsCache && + next.projectViewCache === s.projectViewCache && + next.prRefreshStates === s.prRefreshStates + ? s + : next + }) // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. // Only re-fetch PR/issue entries that are already stale — skip fresh ones const state = get() - const now = Date.now() - const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] const cardProps = state.worktreeCardProperties ?? [] const rawCardProps = cardProps as readonly string[] const shouldRefreshIssues = shouldRefreshIssueDecorations(state) @@ -4442,10 +4471,17 @@ export const createGitHubSlice: StateCreator = (s (state.settings?.experimentalNewWorktreeCardStyle === true ? cardProps.includes('status') : cardProps.includes('pr') || rawCardProps.includes('ci')) + if (!shouldRefreshPRs && !shouldRefreshIssues) { + return + } + + const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const repoLookup = getGitHubRepoLookupIndex(state.repos) for (const worktrees of Object.values(state.worktreesByRepo)) { for (const wt of worktrees) { - const repo = state.repos.find((r) => r.id === wt.repoId) + const repo = repoLookup.findById(wt.repoId) if (!repo) { continue } @@ -4463,7 +4499,7 @@ export const createGitHubSlice: StateCreator = (s ) const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - const candidate = buildPRRefreshCandidate(state, wt) + const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) if (candidate) { stalePRCandidates.push({ candidate, diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts deleted file mode 100644 index 87c28e7822d..00000000000 --- a/src/renderer/src/store/slices/opencode-usage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary -} from '../../../../shared/opencode-usage-types' -import type { AppState } from '../types' - -export type OpenCodeUsageSlice = { - openCodeUsageScope: OpenCodeUsageScope - openCodeUsageRange: OpenCodeUsageRange - openCodeUsageScanState: OpenCodeUsageScanState | null - openCodeUsageSummary: OpenCodeUsageSummary | null - openCodeUsageDaily: OpenCodeUsageDailyPoint[] - openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[] - openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[] - openCodeUsageRecentSessions: OpenCodeUsageSessionRow[] - setOpenCodeUsageEnabled: (enabled: boolean) => Promise - setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise - setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise - fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableOpenCodeUsage: () => Promise - refreshOpenCodeUsage: () => Promise -} - -export const createOpenCodeUsageSlice: StateCreator = ( - set, - get -) => ({ - openCodeUsageScope: 'orca', - openCodeUsageRange: '30d', - openCodeUsageScanState: null, - openCodeUsageSummary: null, - openCodeUsageDaily: [], - openCodeUsageModelBreakdown: [], - openCodeUsageProjectBreakdown: [], - openCodeUsageRecentSessions: [], - - setOpenCodeUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.openCodeUsage.setEnabled({ - enabled - })) as OpenCodeUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - openCodeUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - openCodeUsageSummary: null, - openCodeUsageDaily: [], - openCodeUsageModelBreakdown: [], - openCodeUsageProjectBreakdown: [], - openCodeUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchOpenCodeUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update OpenCode usage setting:', error) - } - }, - - setOpenCodeUsageScope: async (scope) => { - set({ openCodeUsageScope: scope }) - await get().fetchOpenCodeUsage() - }, - - setOpenCodeUsageRange: async (range) => { - set({ openCodeUsageRange: range }) - await get().fetchOpenCodeUsage() - }, - - fetchOpenCodeUsage: async (opts) => { - try { - const scanState = (await window.api.openCodeUsage.getScanState()) as - | OpenCodeUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().openCodeUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().openCodeUsageSummary === null - set({ - openCodeUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { openCodeUsageScope, openCodeUsageRange } = get() - const snapshot = (await window.api.openCodeUsage.getSnapshot({ - scope: openCodeUsageScope, - range: openCodeUsageRange, - limit: 10 - })) as OpenCodeUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyOpenCodeData - - if (hasCachedSnapshot) { - set({ - openCodeUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - openCodeUsageSummary: snapshot.summary, - openCodeUsageDaily: snapshot.daily, - openCodeUsageModelBreakdown: snapshot.modelBreakdown, - openCodeUsageProjectBreakdown: snapshot.projectBreakdown, - openCodeUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - openCodeUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.openCodeUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { openCodeUsageScope: refreshedScope, openCodeUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.openCodeUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as OpenCodeUsageSnapshot - - set({ - openCodeUsageScanState: refreshedSnapshot.scanState, - openCodeUsageSummary: refreshedSnapshot.summary, - openCodeUsageDaily: refreshedSnapshot.daily, - openCodeUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - openCodeUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - openCodeUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch OpenCode usage:', error) - } - }, - - enableOpenCodeUsage: async () => { - await get().setOpenCodeUsageEnabled(true) - }, - - refreshOpenCodeUsage: async () => { - await get().fetchOpenCodeUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 1380c311579..06de2c2b9ae 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -218,7 +218,7 @@ describe('removeWorktree cascade', () => { expect(result).toEqual({ ok: true, - preservedBranch: { branchName: 'feature/test', head: 'def456' } + preservedBranch: { branchName: 'feature/test', head: 'def456', hostId: 'local' } }) expect(toast.warning).toHaveBeenCalledWith('Worktree deleted, branch kept', { id: 'preserved-branch:feature/test:def456', @@ -254,7 +254,7 @@ describe('removeWorktree cascade', () => { expect(result).toEqual({ ok: true, - preservedBranch: { branchName: 'feature/test', head: 'def456' } + preservedBranch: { branchName: 'feature/test', head: 'def456', hostId: 'local' } }) expect(toast.warning).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 6a342f8c5e2..a4117e25260 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -25,9 +25,11 @@ import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e1b9c89e6ab..125db58b7d4 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -792,6 +792,7 @@ export type UISlice = { | 'create-worktree' | 'edit-meta' | 'delete-worktree' + | 'preserved-branch-review' | 'forget-ssh-workspace' | 'confirm-add-project-from-folder' | 'confirm-non-git-folder' diff --git a/src/renderer/src/store/slices/usage-provider-slices.ts b/src/renderer/src/store/slices/usage-provider-slices.ts new file mode 100644 index 00000000000..16855b08a48 --- /dev/null +++ b/src/renderer/src/store/slices/usage-provider-slices.ts @@ -0,0 +1,299 @@ +import type { StateCreator } from 'zustand' +import type { + ClaudeUsageRange, + ClaudeUsageScope, + ClaudeUsageSnapshot +} from '../../../../shared/claude-usage-types' +import type { + CodexUsageRange, + CodexUsageScope, + CodexUsageSnapshot +} from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageRange, + OpenCodeUsageScope, + OpenCodeUsageSnapshot +} from '../../../../shared/opencode-usage-types' +import type { AppState } from '../types' + +type UsageSnapshot = { + scanState: { + enabled: boolean + isScanning: boolean + lastScanCompletedAt: number | null + lastScanError: string | null + } + summary: object + daily: object[] + modelBreakdown: object[] + projectBreakdown: object[] + recentSessions: object[] +} + +type UsageShape = { + scope: Scope + range: Range + snapshot: Snapshot +} + +type UsageData> = { + scope: T['scope'] + range: T['range'] + scanState: T['snapshot']['scanState'] | null + summary: T['snapshot']['summary'] | null + daily: T['snapshot']['daily'] + modelBreakdown: T['snapshot']['modelBreakdown'] + projectBreakdown: T['snapshot']['projectBreakdown'] + recentSessions: T['snapshot']['recentSessions'] +} + +type UsageApi> = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: { + scope: T['scope'] + range: T['range'] + limit?: number + }) => Promise +} + +type ProviderUsageSlice< + Prefix extends string, + Name extends string, + T extends UsageShape +> = { + [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K] +} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> & + Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> & + Record<`set${Name}UsageRange`, (range: T['range']) => Promise> & + Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> & + Record<`enable${Name}Usage`, () => Promise> & + Record<`refresh${Name}Usage`, () => Promise> + +type UsageProviderConfig< + Prefix extends string, + Name extends string, + T extends UsageShape +> = { + prefix: Prefix + name: Name + initialScope: T['scope'] + initialRange: T['range'] + getApi: () => UsageApi + hasCachedData: (scanState: T['snapshot']['scanState']) => boolean +} + +const usageDataFields = [ + 'scope', + 'range', + 'scanState', + 'summary', + 'daily', + 'modelBreakdown', + 'projectBreakdown', + 'recentSessions' +] as const satisfies readonly (keyof UsageData>)[] + +function usageDataKey(prefix: string, field: string): string { + return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}` +} + +function readUsageData>( + state: AppState, + prefix: string +): UsageData { + const values = state as unknown as Record + return Object.fromEntries( + usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]]) + ) as UsageData +} + +function createUsagePatch>( + prefix: string, + patch: Partial> +): Partial { + return Object.fromEntries( + usageDataFields + .filter((field) => field in patch) + .map((field) => [usageDataKey(prefix, field), patch[field]]) + ) as Partial +} + +function createUsageProviderSlice< + Prefix extends string, + Name extends string, + T extends UsageShape +>( + config: UsageProviderConfig +): StateCreator> { + return (set, get) => { + const update = (patch: Partial>): void => + set(createUsagePatch(config.prefix, patch)) + const read = (): UsageData => readUsageData(get(), config.prefix) + + const fetchUsage = async (opts?: { forceRefresh?: boolean }): Promise => { + try { + const api = config.getApi() + const scanState = (await api.getScanState()) as T['snapshot']['scanState'] | undefined + // Desktop-only usage APIs resolve undefined in paired web clients. + if (!scanState) { + return + } + + const current = read() + const preserveLoading = + opts?.forceRefresh === true && + current.scanState?.enabled === true && + current.summary === null + update({ + scanState: preserveLoading + ? { + ...scanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : scanState + }) + if (!scanState.enabled) { + return + } + + const selection = read() + const snapshot = await api.getSnapshot({ + scope: selection.scope, + range: selection.range, + limit: 10 + }) + if ( + snapshot.scanState.lastScanCompletedAt !== null || + config.hasCachedData(snapshot.scanState) + ) { + update({ + ...snapshot, + scanState: + opts?.forceRefresh === true + ? { ...snapshot.scanState, isScanning: true } + : snapshot.scanState + }) + } else { + update({ scanState: { ...scanState, isScanning: true, lastScanError: null } }) + } + + await api.refresh({ force: opts?.forceRefresh ?? false }) + const refreshedSelection = read() + update( + await api.getSnapshot({ + scope: refreshedSelection.scope, + range: refreshedSelection.range, + limit: 10 + }) + ) + } catch (error) { + console.error(`Failed to fetch ${config.name} usage:`, error) + } + } + + const setEnabled = async (enabled: boolean): Promise => { + try { + const nextScanState = (await config.getApi().setEnabled({ enabled })) as + | T['snapshot']['scanState'] + | undefined + if (!nextScanState) { + return + } + update({ + scanState: enabled + ? { + ...nextScanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : nextScanState, + summary: null, + daily: [], + modelBreakdown: [], + projectBreakdown: [], + recentSessions: [] + }) + if (enabled) { + await fetchUsage({ forceRefresh: true }) + } + } catch (error) { + console.error(`Failed to update ${config.name} usage setting:`, error) + } + } + + const initialData: UsageData = { + scope: config.initialScope, + range: config.initialRange, + scanState: null, + summary: null, + daily: [], + modelBreakdown: [], + projectBreakdown: [], + recentSessions: [] + } + + return { + ...createUsagePatch(config.prefix, initialData), + [`set${config.name}UsageEnabled`]: setEnabled, + [`set${config.name}UsageScope`]: async (scope: T['scope']) => { + update({ scope }) + await fetchUsage() + }, + [`set${config.name}UsageRange`]: async (range: T['range']) => { + update({ range }) + await fetchUsage() + }, + [`fetch${config.name}Usage`]: fetchUsage, + [`enable${config.name}Usage`]: () => setEnabled(true), + [`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true }) + } as ProviderUsageSlice + } +} + +type ClaudeUsageShape = UsageShape +type CodexUsageShape = UsageShape +type OpenCodeUsageShape = UsageShape + +export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageShape> +export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageShape> +export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageShape> + +export const createClaudeUsageSlice = createUsageProviderSlice< + 'claude', + 'Claude', + ClaudeUsageShape +>({ + prefix: 'claude', + name: 'Claude', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.claudeUsage, + hasCachedData: (state) => state.hasAnyClaudeData +}) + +export const createCodexUsageSlice = createUsageProviderSlice<'codex', 'Codex', CodexUsageShape>({ + prefix: 'codex', + name: 'Codex', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.codexUsage, + hasCachedData: (state) => state.hasAnyCodexData +}) + +export const createOpenCodeUsageSlice = createUsageProviderSlice< + 'openCode', + 'OpenCode', + OpenCodeUsageShape +>({ + prefix: 'openCode', + name: 'OpenCode', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.openCodeUsage, + hasCachedData: (state) => state.hasAnyOpenCodeData +}) diff --git a/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts b/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts index 1e4c7638cd1..7e2bea1f2ed 100644 --- a/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts +++ b/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts @@ -7,7 +7,7 @@ import type { CodexUsageSnapshot, CodexUsageSummary } from '../../../../shared/codex-usage-types' -import { createCodexUsageSlice } from './codex-usage' +import { createCodexUsageSlice } from './usage-provider-slices' type Deferred = { promise: Promise diff --git a/src/renderer/src/store/slices/usage-web-client-fallback.test.ts b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts index 6dd6f6470c7..4b282d15b47 100644 --- a/src/renderer/src/store/slices/usage-web-client-fallback.test.ts +++ b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts @@ -1,22 +1,15 @@ import { create } from 'zustand' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' -// Regression: in the web client (paired `orca serve` runtime) the desktop-only -// usage IPC is not bridged, so the preload fallback proxy resolves every -// `window.api.Usage.*` call to `undefined`. Before the guards, the -// slices read `scanState.enabled` off that `undefined` and threw -// `TypeError: Cannot read properties of undefined (reading 'enabled')` when a -// user opened Settings -> Stats & Usage and pressed "enable" for an agent. -// -// These tests stub the web-client fallback (every call -> undefined) and assert -// the slices degrade to a no-op instead of throwing. +// Paired web clients resolve unbridged desktop usage calls to undefined. function stubWebClientFallback(): void { - // Mirrors web-preload-api's createFallbackProxy: any method resolves to undefined. const undefinedAsync = vi.fn(() => Promise.resolve(undefined)) const provider = { getScanState: undefinedAsync, diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index cae7d7c8fce..d47b0f9ff8a 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -89,6 +89,38 @@ describe('workspace cleanup removal and protection', () => { expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) }) + it('returns preserved branches for the cleanup batch summary', async () => { + const candidate = makeCandidate() + installWorkspaceCleanupApi( + vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ + ok: true, + preservedBranch: { branchName: 'feature/cleanup', head: 'saved-head' } + }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW, candidates: [candidate], errors: [] } + } as Partial) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + ).resolves.toEqual({ + removedIds: [candidate.worktreeId], + failures: [], + preservedBranches: [ + { + worktreeId: candidate.worktreeId, + branchName: 'feature/cleanup', + expectedHead: 'saved-head' + } + ] + }) + expect(removeWorktree).toHaveBeenCalledWith(candidate.worktreeId, false, { + suppressPreservedBranchToast: true + }) + }) + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { const [candidate] = await enrichWorkspaceCleanupCandidates( [makeCandidate()], diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index bffe5fb93d4..c9ee2805594 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -24,6 +24,7 @@ import { import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorkspaceCleanupFailure = { worktreeId: string @@ -34,6 +35,7 @@ export type WorkspaceCleanupFailure = { export type WorkspaceCleanupRemoveResult = { removedIds: string[] failures: WorkspaceCleanupFailure[] + preservedBranches?: PreservedBranchCleanup[] } export type WorkspaceCleanupRemoveOptions = { @@ -300,6 +302,7 @@ export const createWorkspaceCleanupSlice: StateCreator { const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: PreservedBranchCleanup[] = [] const approvedCandidatesByWorktreeId = new Map( (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) ) @@ -336,6 +339,13 @@ export const createWorkspaceCleanupSlice: StateCreator 0 ? { preservedBranches } : {}) + } } }) diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 499a4865cfe..731ae7273ca 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -47,6 +47,13 @@ export type WorktreeDeleteState = { lockReason?: string | null } +type RendererRemoveWorktreeResult = Omit & { + preservedBranch?: NonNullable & { + hostId?: ExecutionHostId + runtimeEnvironmentId?: string + } +} + export type WorktreeFetchOptions = { requireAuthoritative?: boolean executionHostId?: ExecutionHostId @@ -236,13 +243,18 @@ export type WorktreeSlice = { // PTY stopped; `force` alone is set by the ordinary delete confirmation. allowUnverifiedPtyStop?: boolean } - ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> + ) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + options?: { + suppressToast?: boolean + hostId?: ExecutionHostId + runtimeEnvironmentId?: string + } ) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }> clearWorktreeDeleteState: (worktreeId: string) => void /** Never rejects — most callers fire-and-forget. Callers that own a surface diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 4e5c2e9f041..37c88180477 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -6183,11 +6183,13 @@ describe('worktree remote runtime mutations', () => { method: 'worktree.rm', params: { worktree: `id:${wt.id}`, + hostId: 'runtime:env-1', force: undefined, allowUnverifiedPtyStop: false, runHooks: true }, - timeoutMs: 60_000 + timeoutMs: 60_000, + expectedEnvironmentPairingRevision: undefined }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() expect(store.getState().shutdownWorktreeTerminals).toHaveBeenCalledWith(wt.id, { @@ -6206,12 +6208,21 @@ describe('worktree remote runtime mutations', () => { hostId: 'ssh:hub-private-target', runtimeOwnerEnvironmentId: 'owner-hub' }) - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-rm-nested', - ok: true, - result: { removed: true }, - _meta: { runtimeId: 'runtime-owner-hub' } - }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-rm-nested', + ok: true, + result: { + preservedBranch: { branchName: 'feature/nested', head: 'saved-head' } + }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) + .mockResolvedValueOnce({ + id: 'rpc-force-delete-branch', + ok: true, + result: { deleted: true }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) store.setState({ settings: { activeRuntimeEnvironmentId: 'different-hub' } as never, worktreesByRepo: { 'repo-ssh': [wt] } @@ -6219,12 +6230,21 @@ describe('worktree remote runtime mutations', () => { const result = await store.getState().removeWorktree(wt.id) - expect(result).toEqual({ ok: true }) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + expect(result).toEqual({ + ok: true, + preservedBranch: { + branchName: 'feature/nested', + head: 'saved-head', + hostId: 'ssh:hub-private-target', + runtimeEnvironmentId: 'owner-hub' + } + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'owner-hub', method: 'worktree.rm', params: { worktree: `id:${wt.id}`, + hostId: 'ssh:hub-private-target', force: undefined, allowUnverifiedPtyStop: false, runHooks: true @@ -6232,6 +6252,96 @@ describe('worktree remote runtime mutations', () => { timeoutMs: 60_000 }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) + + const forceResult = await store + .getState() + .forceDeletePreservedBranch(wt.id, 'feature/nested', 'saved-head') + + expect(forceResult).toEqual({ ok: true, deleted: true }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'owner-hub', + method: 'worktree.forceDeleteBranch', + params: { + worktree: `id:${wt.id}`, + branchName: 'feature/nested', + expectedHead: 'saved-head', + hostId: 'ssh:hub-private-target' + }, + timeoutMs: 15_000 + }) + }) + + it('retains separate preserved-branch cleanup routes for sequential same-id hosts', async () => { + const store = createTestStore() + const worktreeId = 'repo-shared::/same/path' + const localWorktree = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + path: '/same/path', + hostId: 'local' + }) + const remoteWorktree = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + path: '/same/path', + hostId: 'ssh:shared-target', + runtimeOwnerEnvironmentId: 'shared-hub' + }) + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/local', head: 'local-head' } + }) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => + Promise.resolve({ + id: `rpc-${method}`, + ok: true, + result: + method === 'repo.hooksCheck' + ? { hasHooks: false, hooks: null, mayNeedUpdate: false } + : method === 'worktree.rm' + ? { + preservedBranch: { branchName: 'feature/remote', head: 'remote-head' } + } + : { deleted: true }, + _meta: { runtimeId: 'runtime-shared-hub' } + }) + ) + store.setState({ + worktreesByRepo: { 'repo-shared': [localWorktree] } + } as Partial) + + await store.getState().removeWorktree(worktreeId) + store.setState({ + worktreesByRepo: { 'repo-shared': [remoteWorktree] } + } as Partial) + await store.getState().removeWorktree(worktreeId) + + await store + .getState() + .forceDeletePreservedBranch(worktreeId, 'feature/local', 'local-head', { hostId: 'local' }) + await store.getState().forceDeletePreservedBranch(worktreeId, 'feature/remote', 'remote-head', { + hostId: 'ssh:shared-target', + runtimeEnvironmentId: 'shared-hub' + }) + + expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({ + worktreeId, + branchName: 'feature/local', + expectedHead: 'local-head', + hostId: 'local' + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'shared-hub', + method: 'worktree.forceDeleteBranch', + params: { + worktree: `id:${worktreeId}`, + branchName: 'feature/remote', + expectedHead: 'remote-head', + hostId: 'ssh:shared-target' + } + }) + ) }) it('fails HUB-owned SSH removal closed when the exact id has two HUB owners', async () => { @@ -6525,6 +6635,22 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.forceDeletePreservedBranch).not.toHaveBeenCalled() }) + it('suppresses per-branch feedback for an aggregate delete', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + store.setState({ worktreesByRepo: { repo1: [wt] } } as Partial) + vi.mocked(toast.success).mockClear() + vi.mocked(toast.error).mockClear() + + const result = await store + .getState() + .forceDeletePreservedBranch(wt.id, 'feature/test', 'abc123', { suppressToast: true }) + + expect(result).toEqual({ ok: true, deleted: true }) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + it('fails preserved branch deletion closed for two HUB owners', async () => { const store = createTestStore() const worktreeId = 'repo-ssh::/srv/same-wt' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index de576b772e0..beb0395f7f4 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -118,6 +118,10 @@ import { type DetectedWorktreeRefreshLease } from './detected-worktree-refresh-leases' import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import { + preservedBranchCleanupKey, + type PreservedBranchCleanup +} from '../../../../shared/preserved-branch-cleanup' import type { HostQualifiedDetectedWorktreeResult, HostQualifiedKnownWorktreeResult, @@ -142,6 +146,10 @@ const FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS = 1_000 export const WORKTREE_REFRESH_CONCURRENCY = 8 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() +const preservedBranchRuntimeTargetByCleanupKey = new Map< + string, + { cleanup: PreservedBranchCleanup; target: ReturnType } +>() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() const runtimeDetectedWorktreeRefreshesInFlight = new Map< @@ -4246,6 +4254,7 @@ export const createWorktreeSlice: StateCreator 'worktree.rm', { worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), force, allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, runHooks: !skipArchive @@ -4593,13 +4602,46 @@ export const createWorktreeSlice: StateCreator // Why: Source Control may be unmounted during deletion, so it can't be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch + const cleanup = preservedBranch + ? { + worktreeId, + branchName: preservedBranch.branchName, + expectedHead: preservedBranch.head, + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + } + : null + if (preservedBranch) { + preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup!), { + cleanup: cleanup!, + target + }) + } if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { - void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) + void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, { + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + }) }) } pruneHostedReviewLinkMutationGenerations([worktreeId]) - return preservedBranch ? { ok: true as const, preservedBranch } : { ok: true as const } + return preservedBranch && cleanup + ? { + ok: true as const, + preservedBranch: { + ...preservedBranch, + ...(cleanup.hostId ? { hostId: cleanup.hostId } : {}), + ...(cleanup.runtimeEnvironmentId + ? { runtimeEnvironmentId: cleanup.runtimeEnvironmentId } + : {}) + } + } + : { ok: true as const } } catch (err) { // Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error. console.warn('Failed to remove worktree:', err) @@ -4676,32 +4718,81 @@ export const createWorktreeSlice: StateCreator }) }, - forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead) => { + forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead, options) => { try { - const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + const requestedCleanup: PreservedBranchCleanup = { + worktreeId, + branchName, + expectedHead, + ...(options?.hostId ? { hostId: options.hostId } : {}), + ...(options?.runtimeEnvironmentId + ? { runtimeEnvironmentId: options.runtimeEnvironmentId } + : {}) + } + const requestedCleanupKey = preservedBranchCleanupKey(requestedCleanup) + const exactRetainedTarget = preservedBranchRuntimeTargetByCleanupKey.get(requestedCleanupKey) + const matchingRetainedTargets = exactRetainedTarget + ? [exactRetainedTarget] + : [...preservedBranchRuntimeTargetByCleanupKey.values()].filter( + ({ cleanup }) => + cleanup.worktreeId === worktreeId && + cleanup.branchName === branchName && + cleanup.expectedHead === expectedHead + ) + const retainedTarget = + exactRetainedTarget ?? + (options?.hostId || options?.runtimeEnvironmentId || matchingRetainedTargets.length !== 1 + ? undefined + : matchingRetainedTargets[0]) + if ((options?.hostId || options?.runtimeEnvironmentId) && !retainedTarget) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId + // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. + const target = + retainedTarget?.target ?? + getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const result = await (target.kind === 'local' ? window.api.worktrees.forceDeletePreservedBranch({ worktreeId, branchName, - expectedHead + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) }) : callRuntimeRpc( target, 'worktree.forceDeleteBranch', - { worktree: toRuntimeWorktreeSelector(worktreeId), branchName, expectedHead }, + { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + }, { timeoutMs: 15_000 } )) - toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { - description: translate('auto.store.slices.worktrees.5a58e03a26', 'Deleted "{{value0}}".', { - value0: branchName + if (options?.suppressToast !== true) { + toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { + description: translate( + 'auto.store.slices.worktrees.5a58e03a26', + 'Deleted "{{value0}}".', + { value0: branchName } + ) }) - }) + } + preservedBranchRuntimeTargetByCleanupKey.delete( + retainedTarget ? preservedBranchCleanupKey(retainedTarget.cleanup) : requestedCleanupKey + ) return { ok: true as const, ...result } } catch (err) { const error = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), { - description: error - }) + if (options?.suppressToast !== true) { + toast.error( + translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), + { + description: error + } + ) + } return { ok: false as const, error } } }, diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index f42325c0b21..bed37f06851 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -15,9 +15,11 @@ import type { EditorSlice } from './slices/editor' import type { StatsSlice } from './slices/stats' import type { MemorySlice } from './slices/memory' import type { WorkspaceSpaceSlice } from './slices/workspace-space' -import type { ClaudeUsageSlice } from './slices/claude-usage' -import type { CodexUsageSlice } from './slices/codex-usage' -import type { OpenCodeUsageSlice } from './slices/opencode-usage' +import type { + ClaudeUsageSlice, + CodexUsageSlice, + OpenCodeUsageSlice +} from './slices/usage-provider-slices' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 77f9a40dfdb..97375f0087c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1831,10 +1831,11 @@ function createWorktreesApi(): NonNullable['worktrees']> { targetBranch, isCrossRepository }), - remove: async ({ worktreeId, force, allowUnverifiedPtyStop, skipArchive }) => { + remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { invalidateRuntimeWorktreeCaches() return callRuntimeResult('worktree.rm', { worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), force, // Why (#11960): the web client renders the same Force Delete affordances, so // dropping this field here would leave paired clients permanently wedged. @@ -1846,11 +1847,12 @@ function createWorktreesApi(): NonNullable['worktrees']> { forgetLocal: () => { throw new Error('Forgetting a workspace is unavailable in paired web clients.') }, - forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead }) => + forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => callRuntimeResult('worktree.forceDeleteBranch', { worktree: toRuntimeWorktreeSelector(worktreeId), branchName, - expectedHead + expectedHead, + ...(hostId ? { hostId } : {}) }), updateMeta: async ({ worktreeId, updates }) => { const rpcUpdates = diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a03435d0e1f..298e7a55c79 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -391,6 +391,90 @@ describe('shared agent-hook-listener', () => { expect(next?.payload.interactivePrompt).toBeUndefined() }) + it('keeps AskUserQuestion visible through a late parallel sibling completion', () => { + const questions = { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: questions + } + }, + 'production' + ) + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'AskUserQuestion', + tool_input: questions + } + }, + 'production' + ) + const siblingCompletion = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_use_id: 'tool-sibling', + tool_input: { command: 'sleep 5' } + } + }, + 'production' + ) + + expect(siblingCompletion?.payload).toMatchObject({ + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: question?.payload.interactivePrompt + }) + }) + + it('clears AskUserQuestion when its own PostToolUse arrives', () => { + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question' + } + }, + 'production' + ) + + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => { // The question was answered, so PostToolUse must clear the live card instead // of re-deriving the `{questions}` prompt from the carried tool input. @@ -3594,6 +3678,28 @@ describe('shared agent-hook-listener', () => { expect(childTool?.payload.toolName).toBe('AskUserQuestion') }) + it('keeps a child AskUserQuestion visible through its parallel sibling completion', () => { + const question = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'a1', + tool_use_id: 'question-1', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + }) + + const siblingCompletion = claudeEvent({ + hook_event_name: 'PostToolUse', + agent_id: 'a1', + tool_use_id: 'sibling-1', + tool_name: 'Bash', + tool_input: { command: 'sleep 5' } + }) + + expect(siblingCompletion?.payload.state).toBe('waiting') + expect(siblingCompletion?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt) + expect(siblingCompletion?.payload.toolName).toBe('AskUserQuestion') + }) + it('preserves the interrupted flag across a gated working window', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c41bcb7354d..44032c03b24 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -150,6 +150,8 @@ export type ClaudeLeadTurnState = { interrupted?: true /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick } @@ -2620,7 +2622,7 @@ function normalizeClaudeSubagentLifecycleEvent( clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2712,8 +2714,8 @@ export function clearClaudeAnsweredQuestionWait( return effectiveState === restored.state ? restored : { state: effectiveState } } -/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */ -function buildClaudeChildDrivenStatusPayload( +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +function buildClaudeCachedLeadStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, @@ -2794,10 +2796,13 @@ function normalizeClaudeEvent( return null } - // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. - // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const isAskUserQuestion = - eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = @@ -2836,6 +2841,20 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = reportedStateName === 'waiting' @@ -2857,7 +2876,15 @@ function normalizeClaudeEvent( if (subagentOriginId) { const lead = state.claudeLeadStateByPaneKey.get(paneKey) if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. @@ -2872,7 +2899,7 @@ function normalizeClaudeEvent( // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. if (eventAgentId && !isWaitingInducing) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } if (isTurnBoundary && eventAgentId === undefined) { @@ -2897,10 +2924,12 @@ function normalizeClaudeEvent( ...(previousLead.interrupted ? { interrupted: true as const } : {}) } : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId state.claudeLeadStateByPaneKey.set(paneKey, { state: reportedStateName, ...(interrupted ? { interrupted } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}) }) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 3e2f0f57921..4e9396632c7 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, removeAgentArgs: removeCodexEffortOverride, - midSession: { kind: 'agent-picker', command: '/model' } + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } } @@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), - // Codex accepts a model argument in its live /model command. - midSession: { - kind: 'command', - build: (value) => `/model ${String(value)}`, - pickerCommand: '/model' - } + // Codex classifies multi-character writes as pasted prose; type the bare + // command and let its own picker apply the account-supported model. + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index bb08339e07e..ae2af3b450f 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -6,6 +6,7 @@ import type { } from './native-chat-session-options' export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' +export type CatalogCommandDelivery = 'type' export type CatalogMidSessionApply = | { @@ -15,7 +16,7 @@ export type CatalogMidSessionApply = detectAgentInteraction?: CatalogAgentInteractionDetection } | { kind: 'toggle-command'; command: string } - | { kind: 'agent-picker'; command: string } + | { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery } | { kind: 'unsupported' } export type CatalogOptionApply = { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index e5e254c45de..b242b6b27c9 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options' export type { AgentSessionOptionCatalog, CatalogAgentInteractionDetection, + CatalogCommandDelivery, CatalogMidSessionApply, CatalogModel, CatalogOption, diff --git a/src/shared/agent-tui-command-typing.ts b/src/shared/agent-tui-command-typing.ts new file mode 100644 index 00000000000..389d7e03141 --- /dev/null +++ b/src/shared/agent-tui-command-typing.ts @@ -0,0 +1,43 @@ +import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear' + +export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown' + +export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16 + +function waitForNextKey(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS) + const finish = (completed: boolean): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */ +export async function typeAgentTuiCommand(args: { + command: string + write: (key: string) => Promise + signal?: AbortSignal +}): Promise { + const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r'] + for (let index = 0; index < keys.length; index += 1) { + if (args.signal?.aborted) { + return 'rejected' + } + const outcome = await args.write(keys[index]!) + if (outcome !== 'accepted') { + return outcome + } + if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) { + return 'rejected' + } + } + return 'accepted' +} diff --git a/src/shared/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts index a5a9f483b1f..2c4e0a61e20 100644 --- a/src/shared/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -1,3 +1,5 @@ +import { clampUtf8TextTail, getUtf8ByteLength } from './utf8-byte-limits' + export const PR_CHECK_LOG_TAIL_LINES = 200 export const PR_CHECK_LOG_TAIL_RECENT_LINES = 100 export const PR_CHECK_LOG_TAIL_BYTES = 16 * 1024 @@ -10,47 +12,23 @@ export const PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR = '… earlier errors …' const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i -// Why: this slicer runs in main (GitHub/GitLab log fetches) and in the renderer -// (defensive re-slice of traces from older remote runtimes), so no Buffer. -function utf8ByteLength(text: string): number { - let bytes = 0 - for (const character of text) { - const codePoint = character.codePointAt(0) ?? 0 - bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4 - } - return bytes -} - function applyLogTailByteCap(text: string): string { - if (utf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { + if (getUtf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { return text } - return sliceTrailingTextByUtf8Bytes(text, PR_CHECK_LOG_TAIL_BYTES) -} - -function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { - let byteLength = 0 - const characters = Array.from(text) - for (let index = characters.length - 1; index >= 0; index -= 1) { - const characterByteLength = utf8ByteLength(characters[index] ?? '') - if (byteLength + characterByteLength > byteLimit) { - return characters.slice(index + 1).join('') - } - byteLength += characterByteLength - } - return text + return clampUtf8TextTail(text, PR_CHECK_LOG_TAIL_BYTES).text } function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = utf8ByteLength(prefix) + const prefixByteLength = getUtf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { - return sliceTrailingTextByUtf8Bytes(prefix, PR_CHECK_LOG_TAIL_BYTES) + return clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text } const separator = prefix.length > 0 && recentLines.length > 0 ? '\n' : '' - const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - utf8ByteLength(separator) - const recentTail = sliceTrailingTextByUtf8Bytes(recentLines.join('\n'), recentBudget) + const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - getUtf8ByteLength(separator) + const recentTail = clampUtf8TextTail(recentLines.join('\n'), recentBudget).text return `${prefix}${separator}${recentTail}` } diff --git a/src/shared/clipboard-text.ts b/src/shared/clipboard-text.ts index 18425efe4cd..e8170af606b 100644 --- a/src/shared/clipboard-text.ts +++ b/src/shared/clipboard-text.ts @@ -1,4 +1,9 @@ import { yieldToEventLoop } from './event-loop-yield' +import { + getUtf8ByteLengthForCodePoint, + measureUtf8ByteLength, + type Utf8ByteLengthMeasurement +} from './utf8-byte-limits' export const CLIPBOARD_TEXT_READ_MAX_BYTES = 16 * 1024 * 1024 export const CLIPBOARD_TEXT_WRITE_MAX_BYTES = 16 * 1024 * 1024 @@ -14,28 +19,13 @@ export type WriteClipboardTextOptions = { maxBytes?: number } -export type ClipboardTextByteLengthMeasurement = { - byteLength: number - exceededLimit: boolean -} +export type ClipboardTextByteLengthMeasurement = Utf8ByteLengthMeasurement export function measureClipboardTextByteLength( text: string, options: { stopAfterBytes?: number } = {} ): ClipboardTextByteLengthMeasurement { - const stopAfterBytes = options.stopAfterBytes - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getUtf8ByteLengthForCodePoint(codePoint) - if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) { - return { byteLength, exceededLimit: true } - } - if (codePoint > 0xffff) { - index += 1 - } - } - return { byteLength, exceededLimit: false } + return measureUtf8ByteLength(text, options) } export function getClipboardTextByteLength(text: string): number { @@ -172,16 +162,3 @@ export function isClipboardTextTooLargeError(error: unknown): boolean { export function isClipboardTextWriteTooLargeError(error: unknown): boolean { return error instanceof Error && error.message.includes(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) } - -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/shared/github-project-group-sort.ts b/src/shared/github-project-group-sort.ts index fe91c78051e..923b8aacf49 100644 --- a/src/shared/github-project-group-sort.ts +++ b/src/shared/github-project-group-sort.ts @@ -29,6 +29,7 @@ const EMPTY_GROUP_KEY = '__empty__' // Array.sort's behavior implementation-defined and skips later tie-breaks. const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER +// Preserve the mobile mirror's fallback for partial ordering metadata. function getFieldValueForGrouping( row: GitHubProjectRow, field: GitHubProjectField @@ -43,8 +44,9 @@ function getFieldValueForGrouping( } } if (field.kind === 'iteration' && value.kind === 'iteration') { - const idx = field.iterations.findIndex((it) => it.id === value.iterationId) - const meta = field.iterations.find((it) => it.id === value.iterationId) + const iterations = field.iterations ?? [] + const idx = iterations.findIndex((iteration) => iteration.id === value.iterationId) + const meta = iterations.find((iteration) => iteration.id === value.iterationId) return { key: value.iterationId, label: value.title || meta?.title || 'Iteration', @@ -55,7 +57,7 @@ function getFieldValueForGrouping( } } if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = field.options.findIndex((o) => o.id === value.optionId) + const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) return { key: value.optionId, label: value.name, @@ -158,8 +160,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje aValue.kind === 'single-select' && bValue.kind === 'single-select' ) { - const aIdx = field.options.findIndex((o) => o.id === aValue.optionId) - const bIdx = field.options.findIndex((o) => o.id === bValue.optionId) + const options = field.options ?? [] + const aIdx = options.findIndex((option) => option.id === aValue.optionId) + const bIdx = options.findIndex((option) => option.id === bValue.optionId) cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) } else if ( @@ -167,8 +170,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje aValue.kind === 'iteration' && bValue.kind === 'iteration' ) { - const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId) - const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId) + const iterations = field.iterations ?? [] + const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) + const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) } else if (aValue.kind === 'number' && bValue.kind === 'number') { diff --git a/src/shared/hermes-run-output-limits.test.ts b/src/shared/hermes-run-output-limits.test.ts deleted file mode 100644 index 1f583a83ca9..00000000000 --- a/src/shared/hermes-run-output-limits.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { DatabaseSync } from 'node:sqlite' -import { - formatHermesSessionMessagesWithinLimits, - HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR, - HERMES_SESSION_RUN_METADATA_MAX_BYTES, - HERMES_SESSION_RUN_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, - hydrateHermesRunPageWithinLimits -} from './hermes-run-output-limits' - -describe('HERMES_SESSION_RUN_SELECT_SQL', () => { - it('preserves exact-limit text metadata and rejects oversized non-numeric fields in SQL', () => { - const database = new DatabaseSync(':memory:') - database.exec(`CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - title TEXT, - started_at, - ended_at, - model TEXT, - message_count, - input_tokens, - output_tokens - )`) - const exactTitle = 't'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - const exactModel = 'm'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - database - .prepare( - `INSERT INTO sessions ( - id, title, started_at, ended_at, model, message_count, input_tokens, output_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run('run-1', exactTitle, 1, 2, exactModel, 3, 4, 5) - - const exact = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - expect(exact).toEqual({ - title: exactTitle, - started_at: 1, - ended_at: 2, - model: exactModel, - message_count: 3, - input_tokens: 4, - output_tokens: 5 - }) - - database.exec(`UPDATE sessions SET - title = title || 'overflow', - model = model || 'overflow', - started_at = zeroblob(1048576), - ended_at = zeroblob(1048576), - message_count = zeroblob(1048576), - input_tokens = zeroblob(1048576), - output_tokens = zeroblob(1048576) - WHERE id = 'run-1'`) - const bounded = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - - expect(Buffer.byteLength(String(bounded?.title))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(Buffer.byteLength(String(bounded?.model))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(bounded).toMatchObject({ - started_at: null, - ended_at: null, - message_count: null, - input_tokens: null, - output_tokens: null - }) - database.close() - }) -}) - -describe('formatHermesSessionMessagesWithinLimits', () => { - it('preserves transcript formatting below the limits', () => { - const result = formatHermesSessionMessagesWithinLimits([ - { - role: 'assistant', - tool_name: ' terminal ', - reasoning_content: ' because ', - content: ' done ' - }, - { role: null, content: ' ' } - ]) - - expect(result).toEqual({ - content: [ - '## assistant / terminal', - '', - '### Reasoning', - '', - 'because', - '', - 'done', - '', - '---', - '', - '## message', - '', - '(empty)' - ].join('\n'), - truncated: false - }) - }) - - it('keeps UTF-8 output within the byte budget and explains truncation', () => { - const result = formatHermesSessionMessagesWithinLimits( - [{ role: 'assistant', content: '🐋'.repeat(100) }], - { maxBytes: 160 } - ) - - expect(result.truncated).toBe(true) - expect(Buffer.byteLength(result.content ?? '')).toBeLessThanOrEqual(160) - expect(result.content).toContain(HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR) - expect(result.content).not.toContain('\ufffd') - }) - - it('stops pulling rows after the message limit', () => { - let rowsPulled = 0 - function* messages(): Generator> { - for (let index = 0; index < 100; index += 1) { - rowsPulled += 1 - yield { role: 'user', content: `message ${index}` } - } - } - - const result = formatHermesSessionMessagesWithinLimits(messages(), { - maxBytes: 1024, - maxMessages: 2 - }) - - expect(rowsPulled).toBe(3) - expect(result.truncated).toBe(true) - expect(result.content).toContain('message 0') - expect(result.content).toContain('message 1') - expect(result.content).not.toContain('message 2') - }) -}) - -describe('hydrateHermesRunPageWithinLimits', () => { - it('bounds concurrency, preserves order, and omits aggregate overflow', async () => { - let active = 0 - let maxActive = 0 - const refs = ['first', 'second', 'third', 'fourth'] - - const runs = (await hydrateHermesRunPageWithinLimits( - refs, - async (id) => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => setTimeout(resolve, 1)) - active -= 1 - return { id, output_content: id.slice(0, 3) } - }, - { maxConcurrent: 2, maxRetainedBytes: 5 } - )) as { id: string; output_content: string | null; error?: string }[] - - expect(maxActive).toBe(2) - expect(runs.map((run) => run.id)).toEqual(refs) - expect(runs[0]?.output_content).toBe('fir') - expect(runs.slice(1)).toEqual( - refs.slice(1).map((id) => ({ - id, - output_content: null, - error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - })) - ) - }) -}) diff --git a/src/shared/hermes-run-output-limits.ts b/src/shared/hermes-run-output-limits.ts deleted file mode 100644 index d456effe8a8..00000000000 --- a/src/shared/hermes-run-output-limits.ts +++ /dev/null @@ -1,233 +0,0 @@ -export const HERMES_SESSION_TRANSCRIPT_MAX_BYTES = 8 * 1024 * 1024 -export const HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES = 10_000 -export const HERMES_RUN_PAGE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 -export const HERMES_RUN_HYDRATION_CONCURRENCY = 2 -export const HERMES_SESSION_RUN_METADATA_MAX_BYTES = 16 * 1024 -export const HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR = - 'Run output omitted because this history page exceeds the memory limit' -export const HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR = - 'Session transcript truncated because it exceeds the 8 MiB or 10,000-message history limit' - -const HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE = `[${HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR}.]` -const HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN = 'orca_source_truncated' - -export const HERMES_SESSION_RUN_SELECT_SQL = `SELECT - CASE WHEN typeof(title) = 'text' - THEN CAST(substr(CAST(title AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS title, - CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at, - CASE WHEN typeof(ended_at) IN ('integer', 'real') THEN ended_at ELSE NULL END AS ended_at, - CASE WHEN typeof(model) = 'text' - THEN CAST(substr(CAST(model AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS model, - CASE WHEN typeof(message_count) IN ('integer', 'real') THEN message_count ELSE NULL END AS message_count, - CASE WHEN typeof(input_tokens) IN ('integer', 'real') THEN input_tokens ELSE NULL END AS input_tokens, - CASE WHEN typeof(output_tokens) IN ('integer', 'real') THEN output_tokens ELSE NULL END AS output_tokens -FROM sessions -WHERE id = ?` - -type HermesRunPageLimits = { - maxConcurrent?: number - maxRetainedBytes?: number -} - -type HermesSessionTranscriptLimits = { - maxBytes?: number - maxMessages?: number -} - -export type FormattedHermesSessionMessages = { - content: string | null - truncated: boolean -} - -export async function hydrateHermesRunPageWithinLimits( - refs: readonly T[], - hydrate: (ref: T) => Promise, - limits: HermesRunPageLimits = {} -): Promise { - const maxConcurrent = clampFiniteLimit(limits.maxConcurrent, 1, HERMES_RUN_HYDRATION_CONCURRENCY) - const maxRetainedBytes = clampFiniteLimit( - limits.maxRetainedBytes, - 0, - HERMES_RUN_PAGE_MAX_RETAINED_BYTES - ) - const results: unknown[] = [] - let retainedBytes = 0 - for (let start = 0; start < refs.length; start += maxConcurrent) { - const batch = await Promise.all(refs.slice(start, start + maxConcurrent).map(hydrate)) - for (const run of batch) { - const runBytes = hermesRunOutputByteLength(run) - if (retainedBytes + runBytes <= maxRetainedBytes) { - results.push(run) - retainedBytes += runBytes - } else { - results.push(omitHermesRunOutput(run)) - } - } - } - return results -} - -export function formatHermesSessionMessagesWithinLimits( - messages: Iterable>, - limits: HermesSessionTranscriptLimits = {} -): FormattedHermesSessionMessages { - const maxBytes = clampFiniteLimit(limits.maxBytes, 0, HERMES_SESSION_TRANSCRIPT_MAX_BYTES) - const maxMessages = clampFiniteLimit( - limits.maxMessages, - 0, - HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES - ) - const chunks: string[] = [] - let retainedBytes = 0 - let messageCount = 0 - let truncated = false - - transcript: for (const message of messages) { - if (messageCount >= maxMessages) { - truncated = true - break - } - for (const part of formatHermesSessionMessageParts(message, messageCount > 0)) { - const partBytes = Buffer.byteLength(part) - if (retainedBytes + partBytes <= maxBytes) { - chunks.push(part) - retainedBytes += partBytes - continue - } - const prefix = takeUtf8Prefix(part, maxBytes - retainedBytes) - if (prefix) { - chunks.push(prefix) - retainedBytes += Buffer.byteLength(prefix) - } - truncated = true - break transcript - } - messageCount += 1 - if (message[HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN] === 1) { - truncated = true - break - } - } - - if (truncated) { - appendTranscriptTruncationNotice(chunks, retainedBytes, maxBytes) - } - return { content: chunks.length > 0 ? chunks.join('') : null, truncated } -} - -function clampFiniteLimit(value: number | undefined, minimum: number, maximum: number): number { - if (value === undefined || !Number.isFinite(value)) { - return maximum - } - return Math.max(minimum, Math.min(maximum, Math.floor(value))) -} - -function hermesRunOutputByteLength(run: unknown): number { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return 0 - } - const content = (run as Record).output_content - return typeof content === 'string' ? Buffer.byteLength(content) : 0 -} - -function formatHermesSessionMessageParts( - message: Record, - includeSeparator: boolean -): string[] { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - includeSeparator ? '\n\n---\n\n' : '', - `## ${role}${toolName ? ` / ${toolName}` : ''}` - ] - if (reasoning) { - parts.push('\n\n### Reasoning\n\n', reasoning) - } - parts.push('\n\n', content || '(empty)') - return parts -} - -function appendTranscriptTruncationNotice( - chunks: string[], - retainedBytes: number, - maxBytes: number -): void { - const separator = chunks.length > 0 ? '\n\n---\n\n' : '' - const notice = `${separator}${HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE}` - const boundedNotice = takeUtf8Prefix(notice, maxBytes) - const noticeBytes = Buffer.byteLength(boundedNotice) - const targetBytes = maxBytes - noticeBytes - trimUtf8Chunks(chunks, retainedBytes, targetBytes) - if (boundedNotice) { - chunks.push(boundedNotice) - } -} - -function trimUtf8Chunks(chunks: string[], retainedBytes: number, targetBytes: number): void { - let bytes = retainedBytes - while (bytes > targetBytes && chunks.length > 0) { - const lastIndex = chunks.length - 1 - const last = chunks[lastIndex]! - const lastBytes = Buffer.byteLength(last) - const allowedBytes = Math.max(0, lastBytes - (bytes - targetBytes)) - if (allowedBytes === 0) { - chunks.pop() - bytes -= lastBytes - continue - } - const prefix = takeUtf8Prefix(last, allowedBytes) - chunks[lastIndex] = prefix - bytes = bytes - lastBytes + Buffer.byteLength(prefix) - } -} - -function takeUtf8Prefix(value: string, maxBytes: number): string { - if (maxBytes <= 0) { - return '' - } - if (Buffer.byteLength(value) <= maxBytes) { - return value - } - let low = 0 - let high = Math.min(value.length, maxBytes) - while (low < high) { - const middle = Math.ceil((low + high) / 2) - if (Buffer.byteLength(value.slice(0, middle)) <= maxBytes) { - low = middle - } else { - high = middle - 1 - } - } - if (low > 0 && isHighSurrogate(value.charCodeAt(low - 1))) { - low -= 1 - } - return value.slice(0, low) -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function omitHermesRunOutput(run: unknown): unknown { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return { output_content: null, error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR } - } - const record = run as Record - const existingError = typeof record.error === 'string' && record.error ? record.error : null - return { - ...record, - output_content: null, - error: existingError - ? `${existingError}; ${HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR}` - : HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - } -} diff --git a/src/shared/hosted-review-github.test.ts b/src/shared/hosted-review-github.test.ts index 640a87016b5..88cca4d51e5 100644 --- a/src/shared/hosted-review-github.test.ts +++ b/src/shared/hosted-review-github.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - hostedReviewInfoFromGitHubPRInfo, - hostedReviewSummaryFromGitHubPRInfo -} from './hosted-review-github' +import { hostedReviewInfoFromGitHubPRInfo } from './hosted-review-github' import type { PRInfo } from './types' const pr: PRInfo = { @@ -16,111 +13,7 @@ const pr: PRInfo = { headSha: 'abc123' } -describe('hostedReviewSummaryFromGitHubPRInfo', () => { - it('maps PRInfo into provider-neutral summary with host identity', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca', - host: 'github.acme.internal' - }) - - expect(summary.identity).toEqual({ - provider: 'github', - host: 'github.acme.internal', - owner: 'acme', - repo: 'orca', - number: 12 - }) - expect(summary.checksStatus).toBe('pending') - expect(summary.threadSummary).toBeUndefined() - }) - - it('derives unresolved thread count and failing status from enrichers', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - comments: [ - { - id: 1, - author: 'a', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 2, - author: 'b', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 3, - author: 'c', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't2', - isResolved: true - } - ], - checks: [{ name: 'ci', status: 'completed', conclusion: 'failure', url: null }] - }) - - expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' }) - expect(summary.checksStatus).toBe('failure') - }) - - it('treats cancelled checks as failed in hosted review summaries', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - checks: [{ name: 'ci', status: 'completed', conclusion: 'cancelled', url: null }] - }) - - expect(summary.checksStatus).toBe('failure') - }) - - it('treats action_required checks as failed so auto-merge sees the block', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - checks: [{ name: 'approval', status: 'completed', conclusion: 'action_required', url: null }] - }) - - expect(summary.checksStatus).toBe('failure') - }) - - it('distinguishes loaded empty comments from unknown comments', () => { - expect( - hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca' - }).threadSummary - ).toBeUndefined() - - expect( - hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca', - comments: [] - }).threadSummary - ).toEqual({ unresolvedCount: 0, dataCompleteness: 'partial' }) - }) - +describe('hostedReviewInfoFromGitHubPRInfo', () => { it('maps PRInfo into sidebar hosted review metadata', () => { const review = hostedReviewInfoFromGitHubPRInfo(pr) diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index 6fc90a39aaf..51633f849d5 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -1,86 +1,5 @@ -import type { PRCheckDetail, PRComment, PRInfo } from './types' -import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' -import { derivePRCheckStatus } from './pr-check-status' - -export type HostedReviewFromGitHubPRInfoArgs = { - pr: PRInfo - owner: string - repo: string - host?: string - authorLogin?: string | null - authorIsBot?: boolean - requestedReviewerLogins?: string[] | null - comments?: PRComment[] - checks?: PRCheckDetail[] - lastViewedAt?: number -} - -function unresolvedThreadCount(comments?: PRComment[]): number | null { - if (comments === undefined) { - return null - } - const unresolved = new Set() - for (const comment of comments) { - if (!comment.threadId || comment.isResolved !== false) { - continue - } - unresolved.add(comment.threadId) - } - return unresolved.size -} - -function deriveChecksStatus( - prChecksStatus: PRInfo['checksStatus'], - checks?: PRCheckDetail[] -): PRInfo['checksStatus'] { - if (!checks) { - return prChecksStatus - } - return derivePRCheckStatus(checks) -} - -export function hostedReviewSummaryFromGitHubPRInfo( - args: HostedReviewFromGitHubPRInfoArgs -): HostedReviewQueueSummary { - const unresolvedCount = unresolvedThreadCount(args.comments) - return { - identity: { - provider: 'github', - host: args.host ?? 'github.com', - owner: args.owner, - repo: args.repo, - number: args.pr.number - }, - title: args.pr.title, - url: args.pr.url, - state: args.pr.state, - author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null, - updatedAt: args.pr.updatedAt, - mergeable: args.pr.mergeable, - ...(args.pr.mergeStateStatus !== undefined - ? { mergeStateStatus: args.pr.mergeStateStatus } - : {}), - checksStatus: deriveChecksStatus(args.pr.checksStatus, args.checks), - reviewDecision: - args.pr.reviewDecision === 'APPROVED' - ? 'approved' - : args.pr.reviewDecision === 'CHANGES_REQUESTED' - ? 'changes_requested' - : args.pr.reviewDecision === 'REVIEW_REQUIRED' - ? 'review_required' - : undefined, - threadSummary: - unresolvedCount === null - ? undefined - : { - unresolvedCount, - dataCompleteness: 'partial' - }, - requestedReviewerLogins: args.requestedReviewerLogins, - lastViewedAt: args.lastViewedAt, - draft: args.pr.state === 'draft' - } -} +import type { HostedReviewInfo } from './hosted-review' +import type { PRInfo } from './types' export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo { return { diff --git a/src/shared/hosted-review-gitlab.test.ts b/src/shared/hosted-review-gitlab.test.ts deleted file mode 100644 index b3cc6ad9c59..00000000000 --- a/src/shared/hosted-review-gitlab.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hostedReviewSummaryFromGitLabInfo } from './hosted-review-gitlab' -import type { HostedReviewInfo } from './hosted-review' - -const review: HostedReviewInfo & { provider: 'gitlab' } = { - provider: 'gitlab', - number: 12, - title: 'Add queue badges', - state: 'open', - url: 'https://gitlab.acme.internal/group/subgroup/orca/-/merge_requests/12', - status: 'pending', - updatedAt: '2026-05-12T00:00:00.000Z', - mergeable: 'MERGEABLE' -} - -describe('hostedReviewSummaryFromGitLabInfo', () => { - it('maps nested GitLab project URLs into provider-neutral identity', () => { - const summary = hostedReviewSummaryFromGitLabInfo({ review }) - - expect(summary.identity).toEqual({ - provider: 'gitlab', - host: 'gitlab.acme.internal', - owner: 'group/subgroup', - repo: 'orca', - number: 12 - }) - expect(summary.checksStatus).toBe('pending') - expect(summary.threadSummary).toBeUndefined() - }) - - it('derives unresolved thread count and failing status from enrichers', () => { - const summary = hostedReviewSummaryFromGitLabInfo({ - review: { ...review, status: 'success' }, - comments: [ - { - id: 1, - author: 'a', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 2, - author: 'b', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 3, - author: 'c', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't2', - isResolved: true - } - ], - checks: [{ name: 'gitlab-ci', status: 'completed', conclusion: 'failure', url: null }] - }) - - expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' }) - expect(summary.checksStatus).toBe('failure') - }) -}) diff --git a/src/shared/hosted-review-gitlab.ts b/src/shared/hosted-review-gitlab.ts deleted file mode 100644 index 3253ee9ff16..00000000000 --- a/src/shared/hosted-review-gitlab.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' -import type { PRCheckDetail, PRComment } from './types' -import { derivePRCheckStatus } from './pr-check-status' - -export type HostedReviewFromGitLabInfoArgs = { - review: HostedReviewInfo & { provider: 'gitlab' } - authorLogin?: string | null - authorIsBot?: boolean - comments?: PRComment[] - checks?: PRCheckDetail[] - lastViewedAt?: number -} - -type GitLabIdentityParts = { - host: string - owner: string - repo: string -} - -function parseGitLabIdentity(url: string): GitLabIdentityParts { - try { - const parsed = new URL(url) - const segments = parsed.pathname.split('/').filter(Boolean) - const markerIndex = segments.indexOf('-') - const projectSegments = markerIndex >= 0 ? segments.slice(0, markerIndex) : segments - if (projectSegments.length >= 2) { - return { - host: parsed.host || 'gitlab.com', - owner: projectSegments.slice(0, -1).join('/'), - repo: projectSegments.at(-1) ?? 'unknown' - } - } - return { - host: parsed.host || 'gitlab.com', - owner: projectSegments[0] ?? 'unknown', - repo: projectSegments[1] ?? 'unknown' - } - } catch { - // Why: queue badges should degrade gracefully for hand-entered/self-hosted URLs. - return { host: 'gitlab.com', owner: 'unknown', repo: 'unknown' } - } -} - -function unresolvedThreadCount(comments?: PRComment[]): number | null { - if (comments === undefined) { - return null - } - const unresolved = new Set() - for (const comment of comments) { - if (!comment.threadId || comment.isResolved !== false) { - continue - } - unresolved.add(comment.threadId) - } - return unresolved.size -} - -function deriveChecksStatus( - reviewStatus: HostedReviewInfo['status'], - checks?: PRCheckDetail[] -): HostedReviewInfo['status'] { - if (!checks) { - return reviewStatus - } - return derivePRCheckStatus(checks) -} - -export function hostedReviewSummaryFromGitLabInfo( - args: HostedReviewFromGitLabInfoArgs -): HostedReviewQueueSummary { - const identity = parseGitLabIdentity(args.review.url) - const unresolvedCount = unresolvedThreadCount(args.comments) - return { - identity: { - provider: 'gitlab', - host: identity.host, - owner: identity.owner, - repo: identity.repo, - number: args.review.number - }, - title: args.review.title, - url: args.review.url, - state: args.review.state, - author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null, - updatedAt: args.review.updatedAt, - mergeable: args.review.mergeable, - ...(args.review.mergeStateStatus !== undefined - ? { mergeStateStatus: args.review.mergeStateStatus } - : {}), - checksStatus: deriveChecksStatus(args.review.status, args.checks), - reviewDecision: - args.review.reviewDecision === 'APPROVED' - ? 'approved' - : args.review.reviewDecision === 'CHANGES_REQUESTED' - ? 'changes_requested' - : args.review.reviewDecision === 'REVIEW_REQUIRED' - ? 'review_required' - : undefined, - threadSummary: - unresolvedCount === null - ? undefined - : { - unresolvedCount, - dataCompleteness: 'partial' - }, - lastViewedAt: args.lastViewedAt, - draft: args.review.state === 'draft' - } -} diff --git a/src/shared/hosted-review-queue.test.ts b/src/shared/hosted-review-queue.test.ts deleted file mode 100644 index fbf90768493..00000000000 --- a/src/shared/hosted-review-queue.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { HostedReviewQueueSummary } from './hosted-review' -import { - classifyHostedReview, - hostedReviewIdentityKey, - reviewNeedsResponse, - reviewReadyToMerge -} from './hosted-review-queue' - -function baseSummary(overrides: Partial = {}): HostedReviewQueueSummary { - return { - identity: { provider: 'github', host: 'github.com', owner: 'acme', repo: 'orca', number: 42 }, - title: 'Improve checks panel', - url: 'https://github.com/acme/orca/pull/42', - state: 'open', - author: { login: 'teammate' }, - updatedAt: '2026-05-10T00:00:00.000Z', - mergeable: 'MERGEABLE', - checksStatus: 'success', - threadSummary: { unresolvedCount: 0 }, - ...overrides - } -} - -describe('hostedReviewIdentityKey', () => { - it('includes provider and host for enterprise-safe keys', () => { - const dotcom = hostedReviewIdentityKey({ - provider: 'github', - host: 'github.com', - owner: 'acme', - repo: 'orca', - number: 7 - }) - const ghe = hostedReviewIdentityKey({ - provider: 'github', - host: 'github.acme.internal', - owner: 'acme', - repo: 'orca', - number: 7 - }) - expect(dotcom).not.toBe(ghe) - }) -}) - -describe('classifyHostedReview', () => { - it('classifies mine/requested/agent/teammate', () => { - expect( - classifyHostedReview(baseSummary({ author: { login: 'me' } }), { - viewer: { login: 'me' } - }).state - ).toBe('mine') - - expect( - classifyHostedReview(baseSummary({ requestedReviewerLogins: ['me'] }), { - viewer: { login: 'me' } - }).state - ).toBe('requested') - - expect( - classifyHostedReview(baseSummary({ author: { login: 'orca-ci' } }), { - agentAuthorLogins: ['orca-ci'] - }).state - ).toBe('agent') - - expect(classifyHostedReview(baseSummary()).state).toBe('teammate') - }) -}) - -describe('reviewNeedsResponse', () => { - it('returns true for unresolved threads, failed checks, conflicts, and newer remote updates', () => { - expect(reviewNeedsResponse(baseSummary({ threadSummary: { unresolvedCount: 1 } }))).toBe(true) - expect(reviewNeedsResponse(baseSummary({ checksStatus: 'failure' }))).toBe(true) - expect(reviewNeedsResponse(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(true) - expect( - reviewNeedsResponse( - baseSummary({ - updatedAt: '2026-05-11T00:00:00.000Z', - lastViewedAt: Date.parse('2026-05-10T00:00:00.000Z') - }) - ) - ).toBe(true) - }) - - it('does not mark needs-response from updatedAt alone when lastViewedAt is missing', () => { - expect(reviewNeedsResponse(baseSummary({ updatedAt: '2026-05-11T00:00:00.000Z' }))).toBe(false) - }) -}) - -describe('reviewReadyToMerge', () => { - it('rejects drafts, conflicts, failed/pending checks, unresolved threads, and unknown mergeability', () => { - expect(reviewReadyToMerge(baseSummary({ state: 'draft', draft: true }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'failure' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'pending' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ threadSummary: { unresolvedCount: 2 } }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ threadSummary: undefined }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeable: 'UNKNOWN' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'review_required' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'changes_requested' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BEHIND' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BLOCKED' }))).toBe(false) - }) - - it('accepts neutral checks when all other gates pass', () => { - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'neutral' }))).toBe(true) - }) - - it('scopes GitHub merge-state blockers to GitHub summaries', () => { - expect( - reviewReadyToMerge( - baseSummary({ - identity: { - provider: 'gitlab', - host: 'gitlab.com', - owner: 'acme', - repo: 'orca', - number: 42 - }, - mergeStateStatus: 'BLOCKED' - }) - ) - ).toBe(true) - }) -}) diff --git a/src/shared/hosted-review-queue.ts b/src/shared/hosted-review-queue.ts deleted file mode 100644 index 037765c341b..00000000000 --- a/src/shared/hosted-review-queue.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - HostedReviewIdentity, - HostedReviewQueueClassification, - HostedReviewQueueState, - HostedReviewQueueSummary, - HostedReviewUser -} from './hosted-review' - -export type HostedReviewClassificationOptions = { - viewer?: HostedReviewUser | null - agentAuthorLogins?: string[] -} - -export function hostedReviewIdentityKey(identity: HostedReviewIdentity): string { - return `${identity.provider}::${identity.host.toLowerCase()}::${identity.owner.toLowerCase()}::${identity.repo.toLowerCase()}::${identity.number}` -} - -function hasRequestedReviewerSignal( - summary: HostedReviewQueueSummary, - viewer?: HostedReviewUser | null -): boolean { - if (!viewer?.login) { - return false - } - const requested = summary.requestedReviewerLogins - if (!requested || requested.length === 0) { - return false - } - const viewerLogin = viewer.login.toLowerCase() - return requested.some((login) => login.toLowerCase() === viewerLogin) -} - -function isAgentAuthored( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): boolean { - if (summary.author?.isBot) { - return true - } - const author = summary.author?.login?.toLowerCase() - if (!author) { - return false - } - if (options?.agentAuthorLogins?.some((login) => login.toLowerCase() === author)) { - return true - } - return author.endsWith('[bot]') || author.includes('bot') -} - -function getQueueState( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): HostedReviewQueueState { - const viewerLogin = options?.viewer?.login?.toLowerCase() ?? null - const authorLogin = summary.author?.login?.toLowerCase() ?? null - if (viewerLogin && authorLogin && viewerLogin === authorLogin) { - return 'mine' - } - if (hasRequestedReviewerSignal(summary, options?.viewer)) { - return 'requested' - } - if (isAgentAuthored(summary, options)) { - return 'agent' - } - return 'teammate' -} - -export function reviewNeedsResponse( - summary: HostedReviewQueueSummary, - viewer?: HostedReviewUser | null -): boolean { - void viewer - if (summary.state !== 'open' && summary.state !== 'draft') { - return false - } - if ((summary.threadSummary?.unresolvedCount ?? 0) > 0) { - return true - } - if (summary.checksStatus === 'failure') { - return true - } - if (summary.mergeable === 'CONFLICTING') { - return true - } - if (summary.lastViewedAt === undefined) { - return false - } - const updatedAt = Date.parse(summary.updatedAt) - return Number.isFinite(updatedAt) && updatedAt > summary.lastViewedAt -} - -export function reviewReadyToMerge(summary: HostedReviewQueueSummary): boolean { - if (summary.state !== 'open') { - return false - } - if (summary.draft) { - return false - } - if (summary.mergeable !== 'MERGEABLE') { - return false - } - if ( - summary.identity.provider === 'github' && - (summary.mergeStateStatus === 'BEHIND' || summary.mergeStateStatus === 'BLOCKED') - ) { - return false - } - if ( - summary.reviewDecision === 'review_required' || - summary.reviewDecision === 'changes_requested' - ) { - return false - } - if (summary.checksStatus !== 'success' && summary.checksStatus !== 'neutral') { - return false - } - if (summary.threadSummary?.unresolvedCount !== 0) { - return false - } - return true -} - -export function classifyHostedReview( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): HostedReviewQueueClassification { - const state = getQueueState(summary, options) - return { - state, - requested: hasRequestedReviewerSignal(summary, options?.viewer), - needsResponse: reviewNeedsResponse(summary, options?.viewer), - readyToMerge: reviewReadyToMerge(summary) - } -} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 90a97d0afb9..7646cb97ceb 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -165,48 +165,4 @@ export type HostedReviewCreationEligibilityArgs = { linkedGiteaPR?: number | null } -export type HostedReviewIdentity = { - provider: HostedReviewProvider - host: string - owner: string - repo: string - number: number -} - -export type HostedReviewUser = { - login: string | null - isBot?: boolean -} - export type HostedReviewDecision = 'approved' | 'changes_requested' | 'review_required' | null - -export type HostedReviewThreadSummary = { - unresolvedCount: number | null - dataCompleteness?: 'full' | 'partial' -} - -export type HostedReviewQueueSummary = { - identity: HostedReviewIdentity - title: string - url: string - state: HostedReviewState - author: HostedReviewUser | null - updatedAt: string - lastViewedAt?: number - mergeable: PRMergeableState - mergeStateStatus?: string | null - checksStatus: CheckStatus - reviewDecision?: HostedReviewDecision - threadSummary?: HostedReviewThreadSummary - requestedReviewerLogins?: string[] | null - draft?: boolean -} - -export type HostedReviewQueueState = 'mine' | 'requested' | 'agent' | 'teammate' - -export type HostedReviewQueueClassification = { - state: HostedReviewQueueState - needsResponse: boolean - readyToMerge: boolean - requested: boolean -} diff --git a/src/shared/native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts index 2583192d34b..77205f6ba5c 100644 --- a/src/shared/native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => { ).toBe('/fast') }) - it('builds an absolute command for live Codex model changes', () => { + it('does not turn a Codex model pick into pasted slash-command prose', () => { expect( buildNativeChatSessionOptionCommand({ optionId: 'model', @@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => { models: CODEX_SESSION_OPTION_CATALOG.models, record: createNativeChatSessionOptionRecord('codex') }) - ).toBe('/model gpt-5.5') + ).toBeNull() + expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({ + kind: 'agent-picker', + command: '/model', + delivery: 'type' + }) }) }) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index c6436f9aad4..961701b7415 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { }) }) - it('exposes Codex model changes as native selectable values', () => { + it('routes Codex model changes through its typed TUI picker', () => { const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: CODEX_SESSION_OPTION_CATALOG, models: CODEX_SESSION_OPTION_CATALOG.models, @@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { modelLabel: 'Model' }) expect(snapshot[0]).toMatchObject({ settable: true }) - expect(snapshot[0]?.action).toBeUndefined() + expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' }) expect(snapshot[0]?.kind).toMatchObject({ type: 'select', choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }]) diff --git a/src/shared/owned-utf16-suffix.ts b/src/shared/owned-utf16-suffix.ts new file mode 100644 index 00000000000..19d623db60e --- /dev/null +++ b/src/shared/owned-utf16-suffix.ts @@ -0,0 +1,20 @@ +const COPY_BLOCK_CODE_UNITS = 4 * 1024 + +export function copyUtf16SuffixToOwnedString(value: string, suffixCodeUnits: number): string { + const suffixLength = Math.max(0, Math.min(value.length, suffixCodeUnits)) + const start = value.length - suffixLength + const blocks: string[] = [] + const codeUnits: number[] = [] + codeUnits.length = Math.min(COPY_BLOCK_CODE_UNITS, suffixLength) + + // Why: slice can retain an arbitrarily large V8 backing string. + for (let offset = start; offset < value.length; offset += COPY_BLOCK_CODE_UNITS) { + const blockLength = Math.min(COPY_BLOCK_CODE_UNITS, value.length - offset) + codeUnits.length = blockLength + for (let index = 0; index < blockLength; index += 1) { + codeUnits[index] = value.charCodeAt(offset + index) + } + blocks.push(String.fromCharCode(...codeUnits)) + } + return blocks.join('') +} diff --git a/src/shared/persisted-state-file-bounds.test.ts b/src/shared/persisted-state-file-bounds.test.ts deleted file mode 100644 index e6ee2de1878..00000000000 --- a/src/shared/persisted-state-file-bounds.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createHash } from 'node:crypto' -import { - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedStateSecretCapacityError, - assertPersistedStateSecretWithinLimit, - readPersistedStateJsonFileSync, - replacePersistedStateJsonWithinLimit, - restorePersistedStateBackupSync, - stringifyPrettyPersistedStateWithinLimit, - stringifyPersistedStateWithinLimit, - updatePersistedStateHashWithJsonRange -} from './persisted-state-file-bounds' - -describe('persisted state file bounds', () => { - let root = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-state-bounds-')) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('reads and parses a state file exactly at the byte limit', () => { - const path = join(root, 'state.json') - const json = `{"value":"${'x'.repeat(20)}"}` - writeFileSync(path, json) - - expect( - readPersistedStateJsonFileSync<{ value: string }>(path, Buffer.byteLength(json)) - ).toEqual({ - byteLength: Buffer.byteLength(json), - value: { value: 'x'.repeat(20) } - }) - }) - - it('rejects an oversized sparse state file before reading its body', () => { - const path = join(root, 'state.json') - writeFileSync(path, '') - truncateSync(path, 1025) - - expect(() => readPersistedStateJsonFileSync(path, 1024)).toThrow(NodeFileReadTooLargeError) - }) - - it('rejects structurally amplified state before parsing it', () => { - const path = join(root, 'state.json') - const json = '{"rows":[{},{}]}' - writeFileSync(path, json) - - expect(() => - readPersistedStateJsonFileSync(path, Buffer.byteLength(json), { - structuralTokens: 7, - nestingDepth: 3 - }) - ).toThrow('JSON structure') - }) - - it('matches native compact JSON exactly at the output boundary', () => { - const state = { quote: '"', unicode: '🐋', nested: [1, true, null] } - const native = JSON.stringify(state) - - expect(stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1)).toThrow( - JsonStringifyByteLimitError - ) - }) - - it('matches native pretty JSON and enforces its whitespace-inclusive boundary', () => { - const state = { nested: { value: 'x' }, list: [1, 2] } - const native = JSON.stringify(state, null, 2) - - expect(stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => - stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('bounds secret plaintext before encryption can expand it', () => { - assertPersistedStateSecretWithinLimit('🐋', 4) - - expect(() => assertPersistedStateSecretWithinLimit('🐋x', 4)).toThrow( - PersistedStateSecretCapacityError - ) - }) - - it('checks replacement growth before constructing the next payload', () => { - const serialized = '{"value":"slot"}' - const exactBytes = - Buffer.byteLength(serialized) - Buffer.byteLength('slot') + Buffer.byteLength('expanded') - - expect( - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - }) - ).toEqual({ byteLength: exactBytes, serialized: '{"value":"expanded"}' }) - expect(() => - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - 1 - }) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('hashes bounded string ranges without splitting UTF-16 surrogate pairs', () => { - const value = `prefix-${'x'.repeat(8)}🐋-${'y'.repeat(8)}-suffix` - const expected = createHash('sha1').update(value).digest('hex') - const actual = createHash('sha1') - - updatePersistedStateHashWithJsonRange(actual, value, 0, value.length, 2) - - expect(actual.digest('hex')).toBe(expected) - }) - - it('atomically restores only a valid in-limit backup', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'profile', 'orca-data.json') - writeFileSync(backupPath, '{"repos":[{"id":"recovered"}]}') - - restorePersistedStateBackupSync(backupPath, targetPath, 1024) - - expect(JSON.parse(readFileSync(targetPath, 'utf8'))).toEqual({ - repos: [{ id: 'recovered' }] - }) - const originalTarget = readFileSync(targetPath) - writeFileSync(backupPath, '{{invalid') - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow() - expect(readFileSync(targetPath)).toEqual(originalTarget) - expect( - readdirSync(join(root, 'profile')).filter((name) => name.endsWith('.recovery.tmp')) - ).toEqual([]) - }) - - it('leaves the target untouched when a backup exceeds the cap', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'orca-data.json') - writeFileSync(targetPath, '{"original":true}') - writeFileSync(backupPath, '') - truncateSync(backupPath, 1025) - - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow( - NodeFileReadTooLargeError - ) - expect(readFileSync(targetPath, 'utf8')).toBe('{"original":true}') - }) -}) diff --git a/src/shared/persisted-state-file-bounds.ts b/src/shared/persisted-state-file-bounds.ts deleted file mode 100644 index 8ad92c95435..00000000000 --- a/src/shared/persisted-state-file-bounds.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { randomUUID, type Hash } from 'node:crypto' -import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' -import { readNodeFileSyncWithinLimit, type BoundedNodeFileRead } from './node-bounded-file-reader' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from './node-bounded-json-stringify' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './json-text-structure-limit' - -export const ORCA_PERSISTED_STATE_MAX_BYTES = 64 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_SECRET_MAX_BYTES = 4 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS = 64 * 1024 -export const ORCA_PERSISTED_STATE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 4_000_000, - nestingDepth: 256 -} - -export type PersistedStateJsonRead = { - byteLength: number - value: T -} - -export class PersistedStateSecretCapacityError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES - ) { - super(`Persisted state secret exceeds ${maxBytes} bytes`) - this.name = 'PersistedStateSecretCapacityError' - } -} - -export function readPersistedStateJsonFileSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): PersistedStateJsonRead { - const { buffer } = readPersistedStateFileBytesSync(filePath, maxBytes) - return { - byteLength: buffer.byteLength, - value: parsePersistedStateJsonBuffer(buffer, structureLimits) - } -} - -export function readPersistedStateFileBytesSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): BoundedNodeFileRead { - return readNodeFileSyncWithinLimit(filePath, maxBytes) -} - -export function parsePersistedStateJsonBuffer( - buffer: Buffer, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): T { - const serialized = buffer.toString('utf8') - assertJsonTextStructureWithinLimits(serialized, structureLimits) - return JSON.parse(serialized) as T -} - -export function stringifyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes) -} - -export function stringifyPrettyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes, 2) -} - -export function assertPersistedStateSecretWithinLimit( - value: string, - maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES -): void { - const observedBytes = Buffer.byteLength(value, 'utf8') - if (observedBytes > maxBytes) { - throw new PersistedStateSecretCapacityError(observedBytes, maxBytes) - } -} - -export function replacedPersistedStateJsonByteLength(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string -}): number { - const maxBytes = options.maxBytes ?? ORCA_PERSISTED_STATE_MAX_BYTES - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Persisted state JSON byte limit must be a non-negative safe integer') - } - if (!Number.isSafeInteger(options.currentBytes) || options.currentBytes < 0) { - throw new RangeError('Persisted state JSON byte count must be a non-negative safe integer') - } - if (options.currentBytes > maxBytes) { - throw new JsonStringifyByteLimitError(options.currentBytes, maxBytes) - } - const nextBytes = - options.currentBytes - - Buffer.byteLength(options.search, 'utf8') + - Buffer.byteLength(options.replacement, 'utf8') - if (!Number.isSafeInteger(nextBytes) || nextBytes < 0 || nextBytes > maxBytes) { - throw new JsonStringifyByteLimitError(nextBytes, maxBytes) - } - return nextBytes -} - -export function replacePersistedStateJsonWithinLimit(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string - serialized: string -}): { byteLength: number; serialized: string } { - const byteLength = replacedPersistedStateJsonByteLength(options) - const searchIndex = options.serialized.indexOf(options.search) - if (searchIndex === -1) { - throw new Error('Persisted state JSON replacement slot is missing') - } - if (options.serialized.includes(options.search, searchIndex + options.search.length)) { - throw new Error('Persisted state JSON replacement slot is ambiguous') - } - return { - byteLength, - serialized: options.serialized.replace(options.search, () => options.replacement) - } -} - -export function updatePersistedStateHashWithJsonRange( - hash: Pick, - value: string, - start = 0, - end = value.length, - chunkCodeUnits = ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS -): void { - if ( - !Number.isSafeInteger(start) || - !Number.isSafeInteger(end) || - start < 0 || - end < start || - end > value.length - ) { - throw new RangeError('Persisted state hash range is invalid') - } - if (!Number.isSafeInteger(chunkCodeUnits) || chunkCodeUnits <= 0) { - throw new RangeError('Persisted state hash chunk size must be a positive safe integer') - } - - let offset = start - while (offset < end) { - let nextOffset = Math.min(end, offset + chunkCodeUnits) - if ( - nextOffset < end && - isHighSurrogate(value.charCodeAt(nextOffset - 1)) && - isLowSurrogate(value.charCodeAt(nextOffset)) - ) { - nextOffset += 1 - } - hash.update(value.slice(offset, nextOffset), 'utf8') - offset = nextOffset - } -} - -export function restorePersistedStateBackupSync( - sourcePath: string, - targetPath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): number { - const read = readValidatedPersistedStateBytesSync(sourcePath, maxBytes) - mkdirSync(dirname(targetPath), { recursive: true }) - const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.recovery.tmp` - try { - writeFileSync(temporaryPath, read.buffer) - renameSync(temporaryPath, targetPath) - } catch (error) { - rmSync(temporaryPath, { force: true }) - throw error - } - return read.buffer.byteLength -} - -function readValidatedPersistedStateBytesSync( - filePath: string, - maxBytes: number -): BoundedNodeFileRead { - const read = readPersistedStateFileBytesSync(filePath, maxBytes) - parsePersistedStateJsonBuffer(read.buffer) - return read -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc00 && code <= 0xdfff -} diff --git a/src/renderer/src/lib/pr-comment-audience.test.ts b/src/shared/pr-comment-audience.test.ts similarity index 70% rename from src/renderer/src/lib/pr-comment-audience.test.ts rename to src/shared/pr-comment-audience.test.ts index fb48a97e6f9..7499e791b90 100644 --- a/src/renderer/src/lib/pr-comment-audience.test.ts +++ b/src/shared/pr-comment-audience.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest' - +import type { PRComment } from './types' +import { createBotAuthorOverrideSet } from './pr-bot-author-overrides' import { - createBotAuthorOverrideSet, filterPRCommentsByAudience, getPRCommentAudienceCounts, isBotPRComment } from './pr-comment-audience' -import type { PRComment } from '../../../shared/types' function comment(overrides: Partial): PRComment { return { @@ -21,9 +20,17 @@ function comment(overrides: Partial): PRComment { } describe('pr comment audience filtering', () => { - it('uses GitHub bot metadata before falling back to login suffixes', () => { - expect(isBotPRComment(comment({ author: 'chatgpt-codex-connector', isBot: true }))).toBe(true) + it('classifies provider metadata and automation logins', () => { + expect(isBotPRComment(comment({ author: 'alice', isBot: true }))).toBe(true) expect(isBotPRComment(comment({ author: 'github-actions[bot]' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'renovate-bot' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'preview-automation' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'qodo-ai-reviewer', isBot: false }))).toBe(true) + expect(isBotPRComment(comment({ author: 'coderabbitai', isBot: false }))).toBe(true) + expect(isBotPRComment(comment({ author: 'sonarcloud' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'codium-ai-reviewer' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'octocat' }))).toBe(false) + expect(isBotPRComment(comment({ author: 'robotics-dev' }))).toBe(false) expect(isBotPRComment(comment({ author: 'human-botany' }))).toBe(false) }) @@ -35,6 +42,7 @@ describe('pr comment audience filtering', () => { ] expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 1, bot: 2 }) + expect(filterPRCommentsByAudience(comments, 'all')).toBe(comments) expect(filterPRCommentsByAudience(comments, 'human').map((item) => item.id)).toEqual([1]) expect(filterPRCommentsByAudience(comments, 'bot').map((item) => item.id)).toEqual([2, 3]) }) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience.ts b/src/shared/pr-comment-audience.ts similarity index 54% rename from mobile/src/components/pr-sidebar/pr-comment-audience.ts rename to src/shared/pr-comment-audience.ts index 71032a74f81..280d0590a7a 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-audience.ts +++ b/src/shared/pr-comment-audience.ts @@ -1,35 +1,19 @@ -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - normalizePRCommentAuthorLogin -} from '../../../../src/shared/pr-bot-author-overrides' +import type { PRComment } from './types' +import { normalizePRCommentAuthorLogin } from './pr-bot-author-overrides' -export { createBotAuthorOverrideSet } - -// Audience filtering for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays -// pure + unit-testable under the node Vitest config. Classification must match the -// desktop so the same comment reads as human/bot on both surfaces. export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' -export const PR_COMMENT_AUDIENCE_FILTERS: { value: PRCommentAudienceFilter; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'human', label: 'Humans' }, - { value: 'bot', label: 'Bots' } -] - const BOT_LOGIN_SUFFIX = '[bot]' const AUTOMATION_LOGIN_PATTERNS = [ /bot$/i, - /-bot$/i, /\bbot\b/i, /automation/i, /actions/i, /renovate/i, /dependabot/i ] -// Some AI/code-review services use regular user accounts, so GitHub metadata can -// report them as users — keep this list in sync with the desktop helper. + +// GitHub can report regular-account review services as users. const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ 'chatgpt-codex-connector', 'codex-connector', @@ -55,10 +39,7 @@ export function isBotPRComment( ): boolean { const author = comment.author.trim() const normalized = normalizePRCommentAuthorLogin(author) - if (botAuthorOverrides?.has(normalized)) { - return true - } - if (comment.isBot === true) { + if (botAuthorOverrides?.has(normalized) || comment.isBot === true) { return true } if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { @@ -71,15 +52,11 @@ export function isBotPRComment( } export function getPRCommentAudienceCounts( - comments: PRComment[], + comments: readonly PRComment[], botAuthorOverrides?: ReadonlySet ): Record { const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length - return { - all: comments.length, - human: comments.length - bot, - bot - } + return { all: comments.length, human: comments.length - bot, bot } } export function filterPRCommentsByAudience( @@ -95,14 +72,3 @@ export function filterPRCommentsByAudience( } return comments } - -export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { - switch (filter) { - case 'bot': - return 'No bot comments.' - case 'human': - return 'No human comments.' - case 'all': - return 'No comments yet.' - } -} diff --git a/src/renderer/src/lib/pr-comment-groups.test.ts b/src/shared/pr-comment-groups.test.ts similarity index 72% rename from src/renderer/src/lib/pr-comment-groups.test.ts rename to src/shared/pr-comment-groups.test.ts index 1bdacc7710a..b2f8de72980 100644 --- a/src/renderer/src/lib/pr-comment-groups.test.ts +++ b/src/shared/pr-comment-groups.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest' - +import type { PRComment } from './types' import { getPRCommentGroupCount, getPRCommentGroupId, groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import type { PRComment } from '../../../shared/types' function comment(overrides: Partial): PRComment { return { @@ -22,14 +21,17 @@ function comment(overrides: Partial): PRComment { describe('pr comment groups', () => { it('groups review thread replies while preserving first-comment order', () => { - const groups = groupPRComments([ + const comments = [ comment({ id: 1 }), comment({ id: 2, threadId: 'thread-a', isResolved: true }), - comment({ id: 3, threadId: 'thread-a', isResolved: true }), + comment({ id: 3, threadId: 'thread-a' }), comment({ id: 4 }) - ]) + ] + const groups = groupPRComments(comments) expect(groups.map(getPRCommentGroupId)).toEqual(['comment:1', 'thread:thread-a', 'comment:4']) + expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) + expect(groups[1]).toMatchObject({ root: { id: 2 }, replies: [{ id: 3 }] }) expect(getPRCommentGroupCount(groups[1])).toBe(2) expect(isResolvedPRCommentGroup(groups[1])).toBe(true) }) diff --git a/src/shared/pr-comment-groups.ts b/src/shared/pr-comment-groups.ts new file mode 100644 index 00000000000..8d43b9e2b77 --- /dev/null +++ b/src/shared/pr-comment-groups.ts @@ -0,0 +1,47 @@ +import type { PRComment } from './types' + +export type PRCommentGroup = + | { kind: 'standalone'; comment: PRComment } + | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } + +export function groupPRComments(comments: readonly PRComment[]): PRCommentGroup[] { + const groups: PRCommentGroup[] = [] + const threads = new Map>() + + for (const comment of comments) { + if (!comment.threadId) { + groups.push({ kind: 'standalone', comment }) + continue + } + const existing = threads.get(comment.threadId) + if (existing) { + existing.replies.push(comment) + continue + } + const group: Extract = { + kind: 'thread', + threadId: comment.threadId, + root: comment, + replies: [] + } + threads.set(comment.threadId, group) + groups.push(group) + } + return groups +} + +export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { + return group.kind === 'thread' ? group.root : group.comment +} + +export function getPRCommentGroupCount(group: PRCommentGroup): number { + return group.kind === 'thread' ? group.replies.length + 1 : 1 +} + +export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { + return getPRCommentGroupRoot(group).isResolved === true +} + +export function getPRCommentGroupId(group: PRCommentGroup): string { + return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` +} diff --git a/src/renderer/src/lib/pr-comment-time.test.ts b/src/shared/pr-comment-time.test.ts similarity index 100% rename from src/renderer/src/lib/pr-comment-time.test.ts rename to src/shared/pr-comment-time.test.ts diff --git a/src/renderer/src/lib/pr-comment-time.ts b/src/shared/pr-comment-time.ts similarity index 81% rename from src/renderer/src/lib/pr-comment-time.ts rename to src/shared/pr-comment-time.ts index d5935df2c6d..32f8ec2bc09 100644 --- a/src/renderer/src/lib/pr-comment-time.ts +++ b/src/shared/pr-comment-time.ts @@ -1,5 +1,3 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd" out). Pure and -// unit-testable; nowMs is passed in so tests stay deterministic. export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { const ts = Date.parse(iso) if (Number.isNaN(ts)) { diff --git a/src/shared/preserved-branch-cleanup.ts b/src/shared/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..6199ce52d5e --- /dev/null +++ b/src/shared/preserved-branch-cleanup.ts @@ -0,0 +1,23 @@ +import type { ExecutionHostId } from './execution-host' + +export type PreservedBranchCleanup = { + worktreeId: string + branchName: string + expectedHead?: string + hostId?: ExecutionHostId + runtimeEnvironmentId?: string +} + +export function preservedBranchCleanupScopeKey( + cleanup: Pick +): string { + return [cleanup.hostId ?? '', cleanup.runtimeEnvironmentId ?? '', cleanup.worktreeId].join('\0') +} + +export function preservedBranchCleanupKey(cleanup: PreservedBranchCleanup): string { + return [ + preservedBranchCleanupScopeKey(cleanup), + cleanup.branchName, + cleanup.expectedHead ?? '' + ].join('\0') +} diff --git a/src/shared/ripgrep-process-availability.test.ts b/src/shared/ripgrep-process-availability.test.ts new file mode 100644 index 00000000000..033df296666 --- /dev/null +++ b/src/shared/ripgrep-process-availability.test.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + isRipgrepSpawnCwdUsable, + killSpawnedRipgrepProcess +} from './ripgrep-process-availability' + +function createChild(pid: number | undefined): ChildProcess { + const child = new EventEmitter() as ChildProcess + Object.defineProperty(child, 'pid', { value: pid }) + return child +} + +describe('ripgrep process availability', () => { + it('recognizes native pre-spawn failures before either event wins the race', () => { + expect(isRipgrepUnavailableExit(createChild(undefined), null, null)).toBe(true) + expect(isRipgrepUnavailableExit(createChild(undefined), -2, null)).toBe(true) + }) + + it('keeps all post-spawn and signal exits on their existing paths', () => { + for (const code of [0, 1, 2, 127]) { + expect(isRipgrepUnavailableExit(createChild(1), code, null)).toBe(false) + } + expect(isRipgrepUnavailableExit(createChild(1), null, 'SIGTERM')).toBe(false) + }) + + it('tags only unsupported native launcher exits after spawn', () => { + for (const code of [0, 1, 2]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(false) + } + for (const code of [3, 126, 127, 9009]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(true) + } + }) + + it('does not signal a real failed-spawn handle', async () => { + const child = spawn('orca-definitely-missing-rg-admission-test', []) + const error = new Promise((resolve) => child.once('error', () => resolve())) + const close = new Promise((resolve) => child.once('close', () => resolve())) + + expect(killSpawnedRipgrepProcess(child)).toBe(false) + await Promise.all([error, close]) + }) + + it('distinguishes a usable root from a missing spawn cwd', async () => { + await expect(isRipgrepSpawnCwdUsable(process.cwd())).resolves.toBe(true) + await expect( + isRipgrepSpawnCwdUsable(join(process.cwd(), 'orca-definitely-missing-rg-cwd')) + ).resolves.toBe(false) + }) + + it('absorbs a queued spawn error when cleanup wins the race', () => { + const child = createChild(undefined) + absorbPendingRipgrepSpawnError(child, { + errorObserved: false, + unavailableExitObserved: false + }) + + expect(() => child.emit('error', new Error('spawn rg ENOENT'))).not.toThrow() + expect(child.listenerCount('error')).toBe(0) + }) + + it('does not retain a sink for started or already-observed processes', () => { + const started = createChild(1) + absorbPendingRipgrepSpawnError(started, { + errorObserved: false, + unavailableExitObserved: false + }) + const observed = createChild(undefined) + absorbPendingRipgrepSpawnError(observed, { + errorObserved: true, + unavailableExitObserved: true + }) + + expect(started.listenerCount('error')).toBe(0) + expect(observed.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts new file mode 100644 index 00000000000..0b5ef026c0e --- /dev/null +++ b/src/shared/ripgrep-process-availability.ts @@ -0,0 +1,124 @@ +import { constants } from 'node:fs' +import { access, stat } from 'node:fs/promises' +import { spawn, type ChildProcess } from 'node:child_process' + +const RIPGREP_CWD_CHECK_TIMEOUT_MS = 1000 +const RIPGREP_FAILURE_PROBE_TIMEOUT_MS = 5000 + +export class RipgrepUnavailableError extends Error { + constructor() { + super('ripgrep is unavailable') + this.name = 'RipgrepUnavailableError' + } +} + +function ignoreRipgrepSpawnError(): void {} + +export function killSpawnedRipgrepProcess(child: ChildProcess): boolean { + // Why: killing a failed-spawn handle can signal the relay's own process group. + if (Object.hasOwn(child, 'pid') && child.pid === undefined) { + return false + } + return child.kill() +} + +export function absorbPendingRipgrepSpawnError( + child: ChildProcess, + state: { errorObserved: boolean; unavailableExitObserved: boolean } +): void { + if ( + state.errorObserved || + (!state.unavailableExitObserved && !(Object.hasOwn(child, 'pid') && child.pid === undefined)) + ) { + return + } + // Why: concurrent-pass cleanup can win before Node delivers the queued spawn error. + child.once('error', ignoreRipgrepSpawnError) +} + +export async function isRipgrepSpawnCwdUsable(cwd: string): Promise { + let timeout: ReturnType | null = null + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), RIPGREP_CWD_CHECK_TIMEOUT_MS) + timeout.unref?.() + }) + const checked = Promise.all([stat(cwd), access(cwd, constants.X_OK)]).then( + ([entry]) => entry.isDirectory(), + () => false + ) + try { + return await Promise.race([checked, timedOut]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function checkRipgrepAvailableWithoutCwd(): Promise { + return new Promise((resolve) => { + let child: ChildProcess + try { + child = spawn('rg', ['--version'], { stdio: 'ignore' }) + } catch { + resolve(false) + return + } + let settled = false + let errorObserved = false + let unavailableExitObserved = false + let timeout: ReturnType | null = null + const settle = (available: boolean, kill = false): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + child.off('error', onError) + child.off('close', onClose) + if (kill) { + child.once('error', ignoreRipgrepSpawnError) + killSpawnedRipgrepProcess(child) + } else { + absorbPendingRipgrepSpawnError(child, { errorObserved, unavailableExitObserved }) + } + resolve(available) + } + const onError = (): void => { + errorObserved = true + settle(false) + } + const onClose = (code: number | null): void => { + unavailableExitObserved = code !== null && code < 0 + settle(code === 0) + } + child.once('error', onError) + child.once('close', onClose) + timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS) + timeout.unref?.() + }) +} + +export async function isRipgrepUnavailableAfterLaunchFailure(cwd: string): Promise { + if (await isRipgrepSpawnCwdUsable(cwd)) { + return true + } + return !(await checkRipgrepAvailableWithoutCwd()) +} + +export function isRipgrepUnavailableExit( + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + options: { classifyNativeLauncherExit?: boolean } = {} +): boolean { + if (signal) { + return false + } + if ((Object.hasOwn(child, 'pid') && child.pid === undefined) || (code !== null && code < 0)) { + return true + } + return Boolean(options.classifyNativeLauncherExit && code !== null && code > 2) +} diff --git a/src/shared/source-control-primary-action-decision-types.ts b/src/shared/source-control-primary-action-decision-types.ts index 1769dbe0958..c1e2147b315 100644 --- a/src/shared/source-control-primary-action-decision-types.ts +++ b/src/shared/source-control-primary-action-decision-types.ts @@ -60,6 +60,21 @@ export type SourceControlPrimaryActionDecision = { requiresForceWithLease?: boolean } +export type SourceControlCommitAreaPrimaryActionDecision = Omit< + SourceControlPrimaryActionDecision, + 'kind' | 'labelIntent' | 'titleIntent' +> & { + kind: Exclude + labelIntent: Exclude< + SourceControlPrimaryActionDecision['labelIntent'], + 'create_pr_intent' | 'create_pr' + > + titleIntent: Exclude< + SourceControlPrimaryActionTitleIntent, + 'prepare_review' | 'create_review' | 'checking_review_creation' + > +} + export type SourceControlPrimaryActionDecisionInputs = { stagedCount: number hasUnstagedChanges: boolean diff --git a/src/shared/source-control-primary-action-decision.test.ts b/src/shared/source-control-primary-action-decision.test.ts index 6f153d74bbe..892d27cbffb 100644 --- a/src/shared/source-control-primary-action-decision.test.ts +++ b/src/shared/source-control-primary-action-decision.test.ts @@ -159,25 +159,30 @@ describe('source-control primary action decision', () => { expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') }) + it('keeps in-flight review intents out of commit-area decisions', () => { + const input = inputs({ isPrIntentInFlight: true }) + expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr_intent') + expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') + }) + it('returns disabled create review while hosted-review creation eligibility is loading', () => { - const result = resolveSourceControlPrimaryActionDecision( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: null, - nextAction: null, - reviewLookupOutcome: 'not_found' - }, - isHostedReviewCreationLoading: true - }) - ) - expect(result).toMatchObject({ + const input = inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'not_found' + }, + isHostedReviewCreationLoading: true + }) + expect(resolveSourceControlPrimaryActionDecision(input)).toMatchObject({ kind: 'create_pr', titleIntent: 'checking_review_creation', disabled: true }) + expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') }) }) diff --git a/src/shared/source-control-primary-action-decision.ts b/src/shared/source-control-primary-action-decision.ts index acdaf27cbf8..3a06c6f9133 100644 --- a/src/shared/source-control-primary-action-decision.ts +++ b/src/shared/source-control-primary-action-decision.ts @@ -3,6 +3,7 @@ import { supportsHostedReviewCreation } from './hosted-review-creation-providers import { resolveCreateReviewIntentEligibility } from './source-control-create-review-intent' import { resolveSourceControlPrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight' import type { + SourceControlCommitAreaPrimaryActionDecision, SourceControlPrimaryActionDecision, SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types' @@ -11,6 +12,7 @@ import { resolveUnpublishedSourceControlPrimaryAction } from './source-control-p export type { SourceControlPrimaryActionKind, SourceControlRemoteOpKind, + SourceControlCommitAreaPrimaryActionDecision, SourceControlPrimaryActionDecision, SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types' @@ -221,14 +223,14 @@ function shouldOfferCreateReviewLoadingAction( export function resolveSourceControlCommitAreaPrimaryActionDecision( inputs: SourceControlPrimaryActionDecisionInputs -): SourceControlPrimaryActionDecision { +): SourceControlCommitAreaPrimaryActionDecision { // Why: review creation is additive chrome. Commit/mobile bottom areas keep // the local/remote action they would have without review eligibility. return resolveSourceControlPrimaryActionDecision({ ...inputs, hostedReviewCreation: null, isPrIntentInFlight: false - }) + }) as SourceControlCommitAreaPrimaryActionDecision } function resolveCreatePrIntentDecision( diff --git a/src/shared/terminal-input.ts b/src/shared/terminal-input.ts index 52f0ee8960f..bdaa410d04b 100644 --- a/src/shared/terminal-input.ts +++ b/src/shared/terminal-input.ts @@ -3,6 +3,7 @@ import { isClipboardTextByteLengthOverLimitWithYield, measureClipboardTextByteLength } from './clipboard-text' +import { getUtf8ChunkEndIndex } from './utf8-byte-limits' export const TERMINAL_INPUT_CHUNK_MAX_BYTES = 16 * 1024 export const TERMINAL_INPUT_MAX_BYTES = 16 * 1024 * 1024 @@ -13,19 +14,6 @@ export function getTerminalInputByteLength(text: string): number { return measureClipboardTextByteLength(text).byteLength } -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function assertTerminalInputWithinLimit( text: string, maxBytes = TERMINAL_INPUT_MAX_BYTES @@ -87,23 +75,10 @@ export function* iterateTerminalInputChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts index 18902b27426..ee2ac371296 100644 --- a/src/shared/terminal-serialize-absolute-cursor.ts +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -19,7 +19,7 @@ type BufferSerializer = { } /** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ -export type SavedCursorRegister = { x: number; y: number } +export type SavedCursorRegister = { x: number; y: number; originMode: boolean } // xterm keeps the DECSC register on each Buffer (savedY is absolute: // ybase-included). It is not exposed through the public API, so snapshot @@ -27,7 +27,17 @@ export type SavedCursorRegister = { x: number; y: number } // buffer, so an alt-screen TUI yields the alternate screen's own register, // matching the one a post-restore DECRC would consult. type TerminalWithSavedCursorCore = SerializeCursorTerminal & { - _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } + modes?: { originMode?: boolean } + _core?: { + buffer?: { + savedX?: number + savedY?: number + savedOriginMode?: boolean + ybase?: number + scrollTop?: number + scrollBottom?: number + } + } } /** Reads the source terminal's active-buffer DECSC register, or null when it @@ -48,13 +58,14 @@ export function readSavedCursorRegister( // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) - if (x === 0 && y === 0) { + const originMode = core.savedOriginMode === true + if (x === 0 && y === 0 && !originMode) { // Home is xterm's never-saved default: a fresh restore terminal already // sends DECRC to home, and skipping the injection avoids overwriting the // fresh terminal's default saved SGR/charset when nothing was ever saved. return null } - return { x, y } + return { x, y, originMode } } export function serializeWithAbsoluteCursor( @@ -70,15 +81,30 @@ export function serializeWithAbsoluteCursor( if (serialized.length === 0) { return serialized } + return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}` +} + +/** Cursor state appended after serialized modes; safe to replay without the frame body. */ +export function buildAbsoluteCursorRestoreSequence( + terminal: SerializeCursorTerminal, + savedCursor?: SavedCursorRegister | null, + options: { restoreModesWithoutCursor?: boolean } = {} +): string { const { cursorX, cursorY } = terminal.buffer.active + const terminalWithCore = terminal as TerminalWithSavedCursorCore + const buffer = terminalWithCore._core?.buffer + const scrollTop = buffer?.scrollTop ?? 0 + const scrollBottom = buffer?.scrollBottom ?? terminal.rows - 1 + const originMode = terminalWithCore.modes?.originMode === true + const cupCursorY = cursorY - (originMode ? scrollTop : 0) // Why skip wrap-pending sources (cursorX == cols): plain replay already // reproduces that state exactly, while CUP would clamp to the last column // and clear the pending-wrap flag, changing how the next byte renders. // The remaining bounds checks are defensive: never emit a clamping CUP. - // The saved-cursor injection is skipped with it — it moves the cursor, so - // it may only ride along when the absolute CUP restores the position after. - if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { - return serialized + const canRestoreCurrentCursor = + cursorX >= 0 && cursorX < terminal.cols && cupCursorY >= 0 && cupCursorY < terminal.rows + if (!canRestoreCurrentCursor && options.restoreModesWithoutCursor !== true) { + return '' } // Why the DECSC injection: the serialized screen cannot carry the VT100 // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC @@ -87,9 +113,21 @@ export function serializeWithAbsoluteCursor( // the source's saved position, then CUP back to the real cursor. Saved SGR/ // charset are not carried — the synthetic ESC 7 saves the serializer's // final pen, a deliberate position-only fidelity trade. - const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' - // cursorY is viewport-relative (0 at the buffer's base row), which is the - // same coordinate space CUP addresses after replay; scrollback length - // differences between source and destination do not shift it. - return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` + // Install the saved register against a full region so its absolute row is + // representable even when it predates the current DECSTBM/DECOM state. + const savedRestore = savedCursor + ? `\x1b[r\x1b[?6${savedCursor.originMode ? 'h' : 'l'}\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` + : '' + const mustRestoreModes = savedCursor != null || options.restoreModesWithoutCursor === true + const scrollRegionRestore = + mustRestoreModes && (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) + ? `\x1b[${scrollTop + 1};${scrollBottom + 1}r` + : '' + const originModeRestore = mustRestoreModes ? `\x1b[?6${originMode ? 'h' : 'l'}` : '' + // CUP rows become margin-relative under DECOM; ordinary snapshots keep the + // zero offset because scrollback length does not shift viewport coordinates. + const currentCursorRestore = canRestoreCurrentCursor + ? `\x1b[${cupCursorY + 1};${cursorX + 1}H` + : '' + return `${savedRestore}${scrollRegionRestore}${originModeRestore}${currentCursorRestore}` } diff --git a/src/shared/types.ts b/src/shared/types.ts index cd874d94037..96c6d14dabf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1245,6 +1245,30 @@ export type GitHubPRMergeMethodSettings = { allowedMethods: Record } +export type GitHubPRStackEntry = { + position: number + number: number + title: string + url: string + updatedAt?: string + state: PRState + checksStatus: CheckStatus + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + mergeStateStatus?: string | null + headRefName?: string + headSha?: string +} + +export type GitHubPRStack = { + number: number + position: number + size: number + baseRefName: string + baseSha?: string + entries?: GitHubPRStackEntry[] +} + export type PRInfo = { number: number title: string @@ -1259,6 +1283,8 @@ export type PRInfo = { mergeQueueRequired?: boolean | null mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null + /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ + stack?: GitHubPRStack // Why: check-runs are keyed by the PR head commit, not the mutable branch name. // Keeping the head SHA in cached PR metadata lets the checks panel poll the // correct commit without re-querying GitHub or guessing from local branch refs. diff --git a/src/shared/utf8-byte-limits.test.ts b/src/shared/utf8-byte-limits.test.ts new file mode 100644 index 00000000000..9b3c63c9cea --- /dev/null +++ b/src/shared/utf8-byte-limits.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { getUtf8ChunkEndIndex, isUtf8ByteLengthWithinLimit } from './utf8-byte-limits' + +describe('getUtf8ChunkEndIndex', () => { + it.each([ + { name: 'ASCII', text: 'abcdef', maxBytes: 3, endIndex: 3 }, + { name: 'multibyte characters', text: 'aé中z', maxBytes: 6, endIndex: 3 } + ])('respects the byte budget for $name', ({ text, maxBytes, endIndex }) => { + expect(getUtf8ChunkEndIndex(text, 0, maxBytes)).toBe(endIndex) + }) + + it('never splits valid surrogate pairs', () => { + expect(getUtf8ChunkEndIndex('a😀b', 0, 4)).toBe(1) + expect(getUtf8ChunkEndIndex('a😀b', 1, 4)).toBe(3) + }) + + it.each(['\ud800a', '\udc00a'])('counts a lone surrogate as three bytes', (text) => { + expect(getUtf8ChunkEndIndex(text, 0, 3)).toBe(1) + }) + + it('starts measuring at the requested offset', () => { + expect(getUtf8ChunkEndIndex('skipé中tail', 4, 5)).toBe(6) + }) + + it('returns the starting index when no text remains', () => { + expect(getUtf8ChunkEndIndex('', 0, 1)).toBe(0) + expect(getUtf8ChunkEndIndex('abc', 3, 1)).toBe(3) + }) + + it.each([0, -1])('consumes one code point when the %s-byte budget is exceeded', (maxBytes) => { + expect(getUtf8ChunkEndIndex('😀a', 0, maxBytes)).toBe(2) + }) + + it('preserves raw non-finite budget comparisons', () => { + expect(getUtf8ChunkEndIndex('abc', 0, Number.NaN)).toBe(3) + expect(getUtf8ChunkEndIndex('abc', 0, Number.POSITIVE_INFINITY)).toBe(3) + expect(getUtf8ChunkEndIndex('abc', 0, Number.NEGATIVE_INFINITY)).toBe(1) + }) +}) + +describe('isUtf8ByteLengthWithinLimit', () => { + it.each([ + { name: 'ASCII at a finite limit', text: 'abc', maxBytes: 3, expected: true }, + { name: 'ASCII over a finite limit', text: 'abcd', maxBytes: 3, expected: false }, + { name: 'multibyte text at its limit', text: 'é中', maxBytes: 5, expected: true }, + { name: 'multibyte text over its limit', text: 'é中', maxBytes: 4, expected: false }, + { name: 'empty text with a negative limit', text: '', maxBytes: -1, expected: true }, + { + name: 'nonempty text with negative infinity', + text: 'a', + maxBytes: -Infinity, + expected: false + }, + { name: 'text with a NaN limit', text: '😀', maxBytes: Number.NaN, expected: true }, + { name: 'text with positive infinity', text: '😀', maxBytes: Infinity, expected: true } + ])('$name', ({ text, maxBytes, expected }) => { + expect(isUtf8ByteLengthWithinLimit(text, maxBytes)).toBe(expected) + }) +}) diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/utf8-byte-limits.ts index c49be3fff44..b32bd968d57 100644 --- a/src/shared/utf8-byte-limits.ts +++ b/src/shared/utf8-byte-limits.ts @@ -31,6 +31,16 @@ export function getUtf8ByteLength(text: string): number { return measureUtf8ByteLength(text).byteLength } +export function isUtf8ByteLengthWithinLimit(text: string, maxBytes: number): boolean { + if (text.length === 0) { + return true + } + if (text.length > maxBytes) { + return false + } + return !measureUtf8ByteLength(text, { stopAfterBytes: maxBytes }).exceededLimit +} + export function clampUtf8TextTail(text: string, maxBytes: number): Utf8TextTail { if (!text || maxBytes <= 0) { return { text: '', bytes: 0 } @@ -70,6 +80,21 @@ export function clampUtf8TextPrefix(text: string, maxBytes: number): string { return end === text.length ? text : text.slice(0, end) } +export function getUtf8ChunkEndIndex(text: string, startIndex: number, maxBytes: number): number { + let bytes = 0 + let endIndex = startIndex + while (endIndex < text.length) { + const codePoint = text.codePointAt(endIndex) ?? 0 + const codePointBytes = getUtf8ByteLengthForCodePoint(codePoint) + if (bytes > 0 && bytes + codePointBytes > maxBytes) { + break + } + bytes += codePointBytes + endIndex += codePoint > 0xffff ? 2 : 1 + } + return endIndex +} + export function getUtf8ByteLengthForCodePoint(codePoint: number): number { if (codePoint <= 0x7f) { return 1 diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts new file mode 100644 index 00000000000..5540c4d6896 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -0,0 +1,521 @@ +import { test as base, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerRecoveryRecord, + runBuiltOrcaCli, + seedCurrentCodexTranscript, + terminalIdentity +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree-id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' + +const test = base.extend({ + launchEnv: [completedWorkerLaunchEnv, { option: true }] +}) + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) { + test(`completed background worker ${closeMode} retires resume authority before first activation`, async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + clearCompletedWorkerLedger() + await waitForSessionReady(orcaPage) + const coordinatorWorktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }) + + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const isolatedHome = await electronApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const coordinatorResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: coordinatorPane.paneKey } + ) + const coordinatorHandle = coordinatorResolved.result.terminal.handle + const coordinator = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + if (!coordinator) { + throw new Error('Coordinator terminal was not runtime-visible') + } + const coordinatorBefore = terminalIdentity(coordinator) + + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + const rendererWorktreeIds = await orcaPage.evaluate(() => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .map((worktree) => worktree.id) + ) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Retire one completed background worker', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion, then exit normally', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${String(targetWorktreeId)}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId || !worker.incarnationId) { + throw new Error('Background worker did not publish exact PTY identity') + } + const workerBefore = terminalIdentity(worker) + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + expect(worker.worktreeId).toBe(targetWorktreeId) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId: worker.tabId, worktreeId: targetWorktreeId } + ) + await expect + .poll(() => + orcaPage.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerBefore.tabId) + ) + .toBe(true) + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'spawn')) + .toHaveLength(1) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + + await orcaPage.evaluate( + ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const providerSession = { + key: 'session_id' as const, + id: providerSessionId, + transcriptPath + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession, + launchConfig: { + agentCommand: 'codex', + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + state.setAgentStatus( + paneKey, + { state: 'done', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + }, + { + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: worker.tabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + + const expectedRecovery = { + origin: 'live', + state: 'working', + providerSessionId: PROVIDER_SESSION_ID + } + await expect + .poll(() => + orcaPage.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession.id + } + : null + }, workerPaneKey) + ) + .toEqual(expectedRecovery) + await expect + .poll( + () => { + const record = readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey) + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession?.id + } + : null + }, + { timeout: 30_000 } + ) + .toEqual(expectedRecovery) + + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed. It found no work. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + await expect + .poll( + async () => { + const dispatch = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + const tasks = await client.call<{ tasks: { id: string; status: string }[] }>( + 'orchestration.taskList', + { run: run.result.run.id } + ) + return { + dispatch: dispatch.result.dispatch?.status ?? null, + task: + tasks.result.tasks.find((candidate) => candidate.id === task.result.task.id) + ?.status ?? null + } + }, + { timeout: 30_000, message: 'worker completion never settled its task and dispatch' } + ) + .toEqual({ dispatch: 'completed', task: 'completed' }) + + await client.call('terminal.send', { + terminal: workerHandle, + text: 'ORCA_E2E_EXIT_AFTER_DONE', + enter: true + }) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')) + .toHaveLength(1) + expect( + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const state = window.__store?.getState() + return { + tabPresent: Boolean(state?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state?.sleepingAgentSessionsByPaneKey[paneKey]) + } + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + ).toEqual({ tabPresent: true, recoveryPresent: true }) + + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + const e2eWindow = window as typeof window & { + __orcaRetiredWorkerTransitions?: Transition[] + __orcaRetiredWorkerUnsubscribe?: () => void + } + const transitions: Transition[] = [ + { + tabPresent: Boolean( + store.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + recoveryPresent: Boolean(store.getState().sleepingAgentSessionsByPaneKey[paneKey]) + } + ] + e2eWindow.__orcaRetiredWorkerTransitions = transitions + e2eWindow.__orcaRetiredWorkerUnsubscribe = store.subscribe((state) => { + const next = { + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state.sleepingAgentSessionsByPaneKey[paneKey]) + } + const previous = transitions.at(-1) + if ( + !previous || + previous.tabPresent !== next.tabPresent || + previous.recoveryPresent !== next.recoveryPresent + ) { + transitions.push(next) + } + }) + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + + if (closeMode === 'terminal-close-cli') { + const closed = runBuiltOrcaCli(['terminal', 'close', '--terminal', workerHandle, '--json'], { + userDataDir, + cwd: process.cwd() + }) + expect(closed).toMatchObject({ + ok: true, + result: { + close: { + handle: workerHandle, + tabId: workerBefore.tabId, + ptyKilled: true + } + } + }) + } else { + const release = await client.call<{ + dispatchId: string + state: string + processAction: string + }>('orchestration.workerRelease', { dispatch: started.result.dispatchId }) + expect(release.result).toMatchObject({ + dispatchId: started.result.dispatchId, + state: 'released', + processAction: 'closed_agent_terminal' + }) + } + await expect + .poll(() => + orcaPage.evaluate(() => { + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + return (window as typeof window & { __orcaRetiredWorkerTransitions?: Transition[] }) + .__orcaRetiredWorkerTransitions + }) + ) + .toEqual( + expect.arrayContaining([ + { tabPresent: true, recoveryPresent: true }, + { tabPresent: false, recoveryPresent: false } + ]) + ) + await orcaPage.evaluate(() => { + const e2eWindow = window as typeof window & { __orcaRetiredWorkerUnsubscribe?: () => void } + e2eWindow.__orcaRetiredWorkerUnsubscribe?.() + delete e2eWindow.__orcaRetiredWorkerUnsubscribe + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] ?? null, + workerPaneKey + ) + ) + .toBeNull() + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll(() => + orcaPage.evaluate(async (paneKey) => { + const session = await window.api.session.get() + return session.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null + }, workerPaneKey) + ) + .toBeNull() + await orcaPage.evaluate(() => window.api.session.flush()) + expect(readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey)).toBeNull() + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + + const beforeActivation = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + tabCount: state?.tabsByWorktree[worktreeId]?.length ?? 0, + pendingStartupCount: Object.keys(state?.pendingStartupByTabId ?? {}).length + } + }, targetWorktreeId) + expect(beforeActivation).toEqual({ everActivated: false, tabCount: 0, pendingStartupCount: 0 }) + + const targetCard = orcaPage + .locator(`[data-worktree-id="${String(targetWorktreeId)}"]`) + .first() + .locator('[data-worktree-card-surface]') + await targetCard.evaluate((element: HTMLElement) => element.click()) + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId)) + .toBe(targetWorktreeId) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const activatedPane = await waitForActivePaneHookDescriptor(orcaPage) + expect(activatedPane.worktreeId).toBe(targetWorktreeId) + const activatedResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: activatedPane.paneKey } + ) + await expect + .poll( + async () => { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: activatedResolved.result.terminal.handle, + limit: 50 + }) + return read.result.terminal.tail.join('\n') + }, + { timeout: 30_000, message: 'activated fallback terminal never produced output' } + ) + .not.toBe('') + + const spawnEvents = readCompletedWorkerLedger().filter((event) => event.event === 'spawn') + expect(spawnEvents).toHaveLength(1) + expect( + spawnEvents.filter( + (event) => event.args?.includes('resume') && event.args?.includes(PROVIDER_SESSION_ID) + ) + ).toEqual([]) + await expect(orcaPage.locator('.session-restored-banner')).toHaveCount(0) + + const afterActivation = await orcaPage.evaluate( + ({ originalTabId, worktreeId }) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[worktreeId] ?? [] + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + originalTabPresent: tabs.some((tab) => tab.id === originalTabId), + replacementTabCount: tabs.filter((tab) => tab.id !== originalTabId).length, + pendingResumeSessionIds: Object.values(state?.pendingStartupByTabId ?? {}).flatMap( + (startup) => (startup.resumeProviderSession ? [startup.resumeProviderSession.id] : []) + ), + resumeClaimCount: Object.keys(state?.automaticAgentResumeClaimsByTabId ?? {}).length + } + }, + { originalTabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + expect(afterActivation).toEqual({ + everActivated: true, + originalTabPresent: false, + replacementTabCount: 1, + pendingResumeSessionIds: [], + resumeClaimCount: 0 + }) + + const coordinatorAfter = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + expect(coordinatorAfter ? terminalIdentity(coordinatorAfter) : null).toEqual(coordinatorBefore) + expect(coordinatorAfter?.worktreeId).toBe(coordinatorWorktreeId) + }) +} diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts new file mode 100644 index 00000000000..03292dfb0e6 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -0,0 +1,610 @@ +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell' +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import type { TerminalTab, Worktree } from '../../src/shared/types' +import { OrchestrationDb } from '../../src/main/runtime/orchestration/db' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { RpcContext } from '../../src/main/runtime/rpc/core' +import { ORCHESTRATION_METHODS } from '../../src/main/runtime/rpc/methods/orchestration' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { seedStartupSessionRestoredBanner } from '@/components/terminal-pane/session-restored-banner-pane-state' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '@/hooks/legacy-worker-terminal-recovery-event' +import { useAppStore, type AppState } from '@/store' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' +const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3' +const ORIGINAL_LEAF_ID = '0526f763-6729-49af-adf8-85ddbcf2b4e7' +const ORIGINAL_PANE_KEY = makePaneKey(ORIGINAL_TAB_ID, ORIGINAL_LEAF_ID) +const ORIGINAL_PTY_ID = 'pty-background-worker' +const REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WORKTREE_PATH = path.join(path.sep, 'workspace', 'factory-pr-4626-git-crypt') +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const CANARY_WORKTREE_PATH = path.join(path.sep, 'workspace', 'canary') +const CANARY_WORKTREE_ID = `${REPO_ID}::${CANARY_WORKTREE_PATH}` +const CANARY_TAB_ID = 'canary-tab' +const CANARY_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const CANARY_PTY_ID = 'pty-unrelated-canary' +const HELPER_TAB_ID = 'worker-child-terminal' +const HELPER_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const HELPER_PANE_KEY = makePaneKey(HELPER_TAB_ID, HELPER_LEAF_ID) +const TERMINAL_HANDLE = 'terminal-background-worker' +const initialAppStoreState = useAppStore.getState() + +function makeWorktree(id: string, workspacePath: string): Worktree { + return { + id, + repoId: REPO_ID, + path: workspacePath, + head: 'abc123', + branch: 'refs/heads/review', + isBare: false, + isMainWorktree: false, + displayName: path.basename(workspacePath), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'local' + } +} + +function makeTab(id: string, worktreeId: string, ptyId: string, title: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId: string, ptyId: string) { + return { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function seedWorkspace(options: { helper?: boolean } = {}): void { + useAppStore.setState(initialAppStoreState, true) + const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH) + const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH) + const original = makeTab( + ORIGINAL_TAB_ID, + WORKTREE_ID, + ORIGINAL_PTY_ID, + 'PR 4626 unified correction r3' + ) + const unrelated = makeTab(CANARY_TAB_ID, CANARY_WORKTREE_ID, CANARY_PTY_ID, 'Unrelated') + const helper = makeTab(HELPER_TAB_ID, WORKTREE_ID, 'pty-worker-child', 'Worker child') + useAppStore.setState({ + repos: [ + { + id: REPO_ID, + path: path.join(path.sep, 'workspace'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' + } + ], + worktreesByRepo: { [REPO_ID]: [target, canary] }, + activeRepoId: REPO_ID, + activeWorktreeId: CANARY_WORKTREE_ID, + activeTabId: CANARY_TAB_ID, + activeTabType: 'terminal', + activeView: 'terminal', + tabsByWorktree: { + [WORKTREE_ID]: options.helper ? [original, helper] : [original], + [CANARY_WORKTREE_ID]: [unrelated] + }, + ptyIdsByTabId: { + [ORIGINAL_TAB_ID]: [ORIGINAL_PTY_ID], + [CANARY_TAB_ID]: [CANARY_PTY_ID], + ...(options.helper ? { [HELPER_TAB_ID]: ['pty-worker-child'] } : {}) + }, + terminalLayoutsByTabId: { + [ORIGINAL_TAB_ID]: makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID), + [CANARY_TAB_ID]: makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID), + ...(options.helper ? { [HELPER_TAB_ID]: makeLayout(HELPER_LEAF_ID, 'pty-worker-child') } : {}) + }, + activeTabIdByWorktree: { + [WORKTREE_ID]: ORIGINAL_TAB_ID, + [CANARY_WORKTREE_ID]: CANARY_TAB_ID + }, + activeTabTypeByWorktree: { + [WORKTREE_ID]: 'terminal', + [CANARY_WORKTREE_ID]: 'terminal' + }, + tabBarOrderByWorktree: { + [WORKTREE_ID]: options.helper ? [ORIGINAL_TAB_ID, HELPER_TAB_ID] : [ORIGINAL_TAB_ID], + [CANARY_WORKTREE_ID]: [CANARY_TAB_ID] + }, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + everActivatedWorktreeIds: new Set([CANARY_WORKTREE_ID]), + settings: { + ...initialAppStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' }, + setupScriptLaunchMode: 'new-tab' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as Partial) +} + +function recordWorkingWorker() { + const providerSession = { key: 'session_id' as const, id: PROVIDER_SESSION_ID } + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'working', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_478_130, stateStartedAt: 1_786_361_478_130 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + return providerSession +} + +function completeRecordedWorker( + providerSession: ReturnType +): SleepingAgentSessionRecord { + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'done', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_625_666, stateStartedAt: 1_786_361_625_666 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + expect(useAppStore.getState().agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'done', + providerSession + }) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + expect(record).toMatchObject({ + paneKey: ORIGINAL_PANE_KEY, + tabId: ORIGINAL_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession, + state: 'working', + origin: 'live' + }) + return record! +} + +function recordCompletedWorker(): SleepingAgentSessionRecord { + return completeRecordedWorker(recordWorkingWorker()) +} + +function expectCanaryUnchanged(): void { + const state = useAppStore.getState() + expect(state.tabsByWorktree[CANARY_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(state.terminalLayoutsByTabId[CANARY_TAB_ID]).toEqual( + makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID) + ) +} + +function orchestrationMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Missing orchestration method: ${name}`) + } + return method +} + +async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Promise { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const coordinatorPaneKey = 'coordinator-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const run = db.createRun({ + objective: 'Completed worker retirement reproduction', + coordinatorHandle: 'terminal-coordinator', + coordinatorPaneKey + }) + const ctx: RpcContext = { runtime } + const call = async (name: string, params: Record) => { + const method = orchestrationMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'terminal-coordinator' ? coordinatorPaneKey : ORIGINAL_PANE_KEY + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === TERMINAL_HANDLE ? 'runtime:test:worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === TERMINAL_HANDLE + ? ({ + terminalHandle: TERMINAL_HANDLE, + paneKey: ORIGINAL_PANE_KEY, + processIncarnation: 'runtime:test:worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: WORKTREE_ID } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + title: 'PR 4626 unified correction r3' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: TERMINAL_HANDLE, + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + ...(terminalState === 'exited' ? { connected: false } : { status: 'running' }) + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + status: terminalState, + tail: terminalState === 'exited' ? [] : ['completed worker output'], + truncated: false, + nextCursor: terminalState === 'exited' ? null : '1' + }) + const closeTerminal = vi.spyOn(runtime, 'closeTerminal').mockImplementation(async () => { + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + return { handle: TERMINAL_HANDLE, tabId: ORIGINAL_TAB_ID, ptyKilled: true } + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + try { + const task = db.createTask({ spec: 'release completed worker', runId: run.id }) + const started = (await call('orchestration.workerStart', { + task: task.id, + from: 'terminal-coordinator', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(started.state).toBe('ready') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('ready') + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatchId, + outcome: 'succeeded', + result: 'worker completed' + }).action + ).toBe('settled') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + + await expect( + call('orchestration.workerRelease', { dispatch: started.dispatchId }) + ).resolves.toMatchObject({ + dispatchId: started.dispatchId, + state: 'released', + processAction: terminalState === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal' + }) + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + expect(db.getWorkerTerminalResourceByOwner(started.dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released', + pane_key: ORIGINAL_PANE_KEY, + terminal_handle: TERMINAL_HANDLE + }) + expect(closeTerminal).toHaveBeenCalledOnce() + } finally { + db.close() + } +} + +function persistAndParseCurrentSession() { + const payload = buildWorkspaceSessionPayload(useAppStore.getState()) + const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(payload))) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.value +} + +async function hydrateSession( + session: ReturnType +): Promise { + seedWorkspace() + useAppStore.getState().hydrateWorkspaceSession(session) + useAppStore.getState().hydrateTabsSession(session) + await useAppStore.getState().reconnectPersistedTerminals() +} + +beforeEach(() => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtime: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } + } + }) + seedWorkspace() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('completed background-worker retirement resume matrix', () => { + it('does not cold-resume an explicitly completed and retired worker on first activation', async () => { + // Case 1: task completion alone keeps the still-owned provider session recoverable in place. + const ownedRecord = recordCompletedWorker() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBe( + ownedRecord + ) + + // Case 2: the renderer boundary used by an explicit Orca close retires the exact authority. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 3: both running and PTY-exit-first release retire exact resume authority. + seedWorkspace() + recordCompletedWorker() + await releaseCompletedWorker('running') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + seedWorkspace() + recordCompletedWorker() + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + origin: 'live', + state: 'working', + providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } + }) + await releaseCompletedWorker('exited') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + + const retiredRestart = persistAndParseCurrentSession() + expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined() + + // Case 4: legacy rollback preserves a fenced record; exited resolution clears it. + seedWorkspace() + const legacyRecord = recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [ORIGINAL_PANE_KEY]: { + ...legacyRecord, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + }) + const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'rolled_back', + ptyId: ORIGINAL_PTY_ID + }) + expect(legacyAction.kind).toBe('rollback-surface') + if (legacyAction.kind === 'rollback-surface') { + expect( + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail) + ).toBe('removed') + } + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect( + useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + ?.automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'exited' + }) + expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY }) + useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY) + + // Case 5: coordinator manual close is the same safe exact-tab retirement boundary. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 6: normal Codex exit leaves the shell pane; helper close retires only the helper. + seedWorkspace({ helper: true }) + recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + ...useAppStore.getState().sleepingAgentSessionsByPaneKey, + [HELPER_PANE_KEY]: { + paneKey: HELPER_PANE_KEY, + tabId: HELPER_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'unrelated-helper-session' }, + prompt: 'helper', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toEqual( + makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID) + ) + closeTerminalTab(HELPER_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[HELPER_PANE_KEY]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeDefined() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expectCanaryUnchanged() + + // Case 7: restart preserves owned panes, while post-retirement restart keeps authority absent. + seedWorkspace() + const workingProviderSession = recordWorkingWorker() + const beforeCompletion = persistAndParseCurrentSession() + expect(beforeCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(beforeCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'working', + origin: 'live', + providerSession: workingProviderSession + }) + await hydrateSession(beforeCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe(ORIGINAL_PTY_ID) + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toEqual([ORIGINAL_PTY_ID]) + completeRecordedWorker(recordWorkingWorker()) + const afterCompletion = persistAndParseCurrentSession() + expect(afterCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(afterCompletion.terminalLayoutsByTabId[ORIGINAL_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [ORIGINAL_LEAF_ID]: ORIGINAL_PTY_ID + }) + expect(afterCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeDefined() + await hydrateSession(afterCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[CANARY_WORKTREE_ID]?.[0]?.id).toBe(CANARY_TAB_ID) + + activateAndRevealWorktree(CANARY_WORKTREE_ID, { notifyHostRuntime: false }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + await releaseCompletedWorker('exited') + const restartAfterRetirement = persistAndParseCurrentSession() + await hydrateSession(restartAfterRetirement) + + // Case 8: first activation of the never-visited target cannot resurrect retired authority. + const beforeActivation = useAppStore.getState() + expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false) + expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(beforeActivation.sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(Object.keys(beforeActivation.pendingStartupByTabId)).toEqual([]) + const tabCountBeforeActivation = beforeActivation.tabsByWorktree[WORKTREE_ID]?.length ?? 0 + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + const activated = useAppStore.getState() + const replacementTabs = (activated.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== ORIGINAL_TAB_ID + ) + // The provider-ownership gate separately proves this request becomes one transport spawn. + const coldSpawnRequests = replacementTabs.flatMap((tab) => { + const startup = activated.pendingStartupByTabId[tab.id] + if (!startup?.resumeProviderSession) { + return [] + } + const tokens = tokenizeStartupCommand(startup.command, 'posix') + expect(tokens.ok).toBe(true) + const showSessionRestoredBanner = vi.fn() + seedStartupSessionRestoredBanner(startup, 1, showSessionRestoredBanner) + return [ + { + providerSession: startup.resumeProviderSession, + command: startup.command, + argv: tokens.ok ? tokens.tokens : [], + restoredBannerCount: showSessionRestoredBanner.mock.calls.length + } + ] + }) + + expect(tabCountBeforeActivation).toBe(0) + expect(replacementTabs).toHaveLength(1) + expect(activated.tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === ORIGINAL_TAB_ID)).toBe( + false + ) + expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(coldSpawnRequests).toEqual([]) + expectCanaryUnchanged() + expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0) + expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + + // Required invariant: explicit completion plus retirement must revoke provider-resume authority. + expect(coldSpawnRequests).toEqual([]) + }) +}) diff --git a/tests/e2e/golden-core-flows.spec.ts b/tests/e2e/golden-core-flows.spec.ts index c510ce7a68f..96863251081 100644 --- a/tests/e2e/golden-core-flows.spec.ts +++ b/tests/e2e/golden-core-flows.spec.ts @@ -16,7 +16,6 @@ import { const tempRoots: string[] = [] const SORTABLE_TAB = '[data-testid="sortable-tab"]' -const REPO_STEP_HEADING = /Point Orca at some code/i const TASK_SOURCES_HEADING = /Set up GitHub tasks|Connect your task sources/i const WINDOWS_TERMINAL_HEADING = /Set Windows terminal defaults/i const ONBOARDING_ADVANCE_LABEL = /^Continue\b|^Add your first project\b/ @@ -161,7 +160,7 @@ async function continueThroughOptionalSetupToNotifications(page: Page): Promise< await expect(page.getByRole('heading', { name: /Set up notifications/i })).toBeVisible() } -async function continueFromNotificationsToRepo(page: Page): Promise { +async function continueFromNotificationsToAddProject(page: Page): Promise { await continueOnboarding(page) const taskSourcesVisible = await page .getByRole('heading', { name: TASK_SOURCES_HEADING }) @@ -179,22 +178,9 @@ async function continueFromNotificationsToRepo(page: Page): Promise { if (windowsTerminalVisible) { await continueOnboarding(page) } - const repoHeading = page.getByRole('heading', { name: REPO_STEP_HEADING }) - const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i }) - await expect - .poll( - async () => { - if (await repoHeading.isVisible().catch(() => false)) { - return 'repo-step' - } - if (await addProjectDialog.isVisible().catch(() => false)) { - return 'add-project-dialog' - } - return 'waiting' - }, - { timeout: 15_000 } - ) - .not.toBe('waiting') + await expect(page.getByRole('dialog', { name: /Add a project/i })).toBeVisible({ + timeout: 15_000 + }) } async function waitForRepoLoaded(page: Page, repoPath: string): Promise { @@ -457,16 +443,13 @@ test.describe('New-user golden core flow', () => { await continueThroughOptionalSetupToNotifications(orcaPage) await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible() await chooseNotificationSound(orcaPage) - await continueFromNotificationsToRepo(orcaPage) + await continueFromNotificationsToAddProject(orcaPage) const repoPath = await createGitRepo('orca-e2e-golden-new-', 'golden-new-project') await chooseFolderInNativeDialog(electronApp, repoPath) await orcaPage .getByRole('button', { name: /Browse for a folder|Open a folder|Browse folder/i }) .click() - await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toHaveCount(0, { - timeout: 30_000 - }) await waitForRepoLoaded(orcaPage, repoPath) await expectProjectVisible(orcaPage, repoPath) await waitForActiveWorktree(orcaPage) diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts new file mode 100644 index 00000000000..d1211234258 --- /dev/null +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -0,0 +1,194 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { RuntimeClient } from '../../../src/cli/runtime-client' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import type { + RuntimeTerminalListResult, + RuntimeTerminalSummary +} from '../../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-retired-worker-')) +const lifecycleLedgerPath = path.join(fakeCliDir, 'codex-lifecycle.jsonl') +const fakeCodexSource = ` +const { appendFileSync } = require('node:fs') +const ledger = process.env.ORCA_E2E_CODEX_LIFECYCLE_LEDGER +const append = (event) => appendFileSync(ledger, JSON.stringify({ pid: process.pid, ...event }) + '\\n') +const args = process.argv.slice(2) +if (args.includes('app-server')) { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +append({ event: 'spawn', args }) +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +process.stdin.on('data', (chunk) => { + const input = chunk.toString() + append({ event: 'input', input }) + if (input.includes('ORCA_E2E_EXIT_AFTER_DONE')) { + append({ event: 'normal-exit' }) + process.exit(0) + } + if (input.includes('\\r')) process.stdout.write('ACK\\n') +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +export const completedWorkerLaunchEnv = { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath +} + +export type LifecycleEvent = { + pid: number + event: 'spawn' | 'input' | 'normal-exit' + args?: string[] + input?: string +} + +export type TerminalIdentity = Pick< + RuntimeTerminalSummary, + 'handle' | 'incarnationId' | 'leafId' | 'ptyId' | 'tabId' | 'worktreeId' +> + +export function clearCompletedWorkerLedger(): void { + rmSync(lifecycleLedgerPath, { force: true }) +} + +export function cleanupCompletedWorkerFixture(): void { + rmSync(fakeCliDir, { recursive: true, force: true }) +} + +export function readCompletedWorkerLedger(): LifecycleEvent[] { + if (!existsSync(lifecycleLedgerPath)) { + return [] + } + const contents = readFileSync(lifecycleLedgerPath, 'utf8') + const lastCompleteLine = contents.lastIndexOf('\n') + if (lastCompleteLine < 0) { + return [] + } + return contents + .slice(0, lastCompleteLine) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as LifecycleEvent) +} + +export function readCompletedWorkerDispatchCapability(): string | null { + const input = readCompletedWorkerLedger() + .filter((event) => event.event === 'input') + .map((event) => event.input ?? '') + .join('') + return input.match(/--dispatch-capability\s+(\S+)/)?.[1] ?? null +} + +export function runBuiltOrcaCli( + args: string[], + options: { userDataDir: string; cwd: string } +): unknown { + const { + ORCA_ENVIRONMENT: _environment, + ORCA_PAIRING_CODE: _pairingCode, + ORCA_USER_DATA_PATH: _userDataPath, + ...cleanEnv + } = process.env + void _environment + void _pairingCode + void _userDataPath + const output = execFileSync( + process.execPath, + [path.join(process.cwd(), 'out', 'cli', 'index.js'), ...args], + { + cwd: options.cwd, + env: { ...cleanEnv, ORCA_USER_DATA_PATH: options.userDataDir }, + encoding: 'utf8', + timeout: 30_000 + } + ) + return JSON.parse(output) as unknown +} + +export function seedCurrentCodexTranscript( + isolatedHome: string, + providerSessionId: string, + cwd: string +): string { + const now = new Date() + const transcriptDir = path.join( + isolatedHome, + '.codex', + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ) + mkdirSync(transcriptDir, { recursive: true }) + const transcriptPath = path.join(transcriptDir, `rollout-${providerSessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: now.toISOString(), + type: 'session_meta', + payload: { id: providerSessionId, cwd } + })}\n` + ) + return transcriptPath +} + +export function terminalIdentity(terminal: RuntimeTerminalSummary): TerminalIdentity { + const { handle, incarnationId, leafId, ptyId, tabId, worktreeId } = terminal + return { handle, incarnationId, leafId, ptyId, tabId, worktreeId } +} + +export async function listRuntimeTerminals( + client: RuntimeClient +): Promise { + return (await client.call('terminal.list')).result.terminals +} + +export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return null + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + origin?: unknown + state?: unknown + providerSession?: { id?: unknown } + } + > + } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null +} diff --git a/tests/e2e/orca-profiles.spec.ts b/tests/e2e/orca-profiles.spec.ts deleted file mode 100644 index 4d8bef3aec6..00000000000 --- a/tests/e2e/orca-profiles.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { test, expect } from './helpers/orca-app' - -// Why: the multi-profile switcher UI is downscoped behind ORCA_MULTI_PROFILE_UI; -// these specs exercise that full UI, so opt the whole file into the flag. -test.use({ launchEnv: { ORCA_MULTI_PROFILE_UI: '1' } }) - -test('opens the profile switcher and profile dialogs', async ({ orcaPage }) => { - const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) - await expect(switcher).toBeVisible() - - await switcher.click() - await expect(orcaPage.getByText('Personal', { exact: true }).first()).toBeVisible() - const manageProfiles = orcaPage.getByRole('menuitem', { name: /Manage profiles/i }) - await expect(manageProfiles).toBeVisible() - await expect(orcaPage.getByRole('menuitem', { name: /New local profile/i })).toBeVisible() - - await manageProfiles.click() - const managementDialog = orcaPage.getByRole('dialog', { name: /Manage profiles/i }) - await expect(managementDialog).toBeVisible() - await expect(managementDialog.getByText(/projects/i).first()).toBeVisible() - - await orcaPage.keyboard.press('Escape') - await expect(managementDialog).toBeHidden() - - await switcher.click() - await orcaPage.getByRole('menuitem', { name: /New local profile/i }).click() - const createDialog = orcaPage.getByRole('dialog', { name: /New local profile/i }) - await expect(createDialog).toBeVisible() - await expect(createDialog.getByPlaceholder(/Profile name/i)).toBeVisible() - await expect(createDialog.getByRole('button', { name: /Create and Switch/i })).toBeVisible() -}) - -test('places the profile switcher in sidebar footer and full-page titlebar', async ({ - orcaPage -}) => { - const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) - const settingsButton = orcaPage.getByRole('button', { name: /^Settings$/ }) - await expect(switcher).toBeVisible() - await expect(settingsButton).toBeVisible() - - const sidebarSwitchBox = await switcher.boundingBox() - const settingsBox = await settingsButton.boundingBox() - const viewport = await orcaPage.evaluate(() => ({ - width: window.innerWidth, - height: window.innerHeight - })) - expect(sidebarSwitchBox).not.toBeNull() - expect(settingsBox).not.toBeNull() - - expect(sidebarSwitchBox!.x + sidebarSwitchBox!.width).toBeLessThanOrEqual(settingsBox!.x + 2) - expect(Math.abs(sidebarSwitchBox!.y - settingsBox!.y)).toBeLessThanOrEqual(2) - expect(sidebarSwitchBox!.y).toBeGreaterThan(viewport.height - 64) - - await settingsButton.click() - await expect - .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeView)) - .toBe('settings') - - const titlebarSwitchBox = await switcher.boundingBox() - expect(titlebarSwitchBox).not.toBeNull() - expect(titlebarSwitchBox!.x).toBeGreaterThan(viewport.width - 260) - expect(titlebarSwitchBox!.y).toBeLessThan(48) -}) - -test.describe('default single-profile mode', () => { - // Why: no flag — the default build shows no account trigger on a local-only - // (cloud-unconfigured) install. - test.use({ launchEnv: {} }) - - test('hides the account trigger when cloud is unconfigured', async ({ orcaPage }) => { - await expect(orcaPage.getByRole('button', { name: /^Switch profile$/ })).toHaveCount(0) - await expect(orcaPage.getByRole('button', { name: /^Account$/ })).toHaveCount(0) - }) -}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 775f7cc5f62..fe39cfef709 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -50,6 +50,11 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } +function encodedRemoteNodeCommand(script: string): string { + const encoded = Buffer.from(script, 'utf8').toString('base64') + return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` +} + function remoteTypingLoadScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -368,7 +373,9 @@ test.describe('Docker SSH relay perf', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const beforePtyId = await waitForActivePanePtyId(orcaPage, 60_000) const beforeMarker = `SSH_RECONNECT_BEFORE_${Date.now()}` - await execInTerminal(orcaPage, beforePtyId, `printf ${shellQuote(beforeMarker)}`) + const beforeCommand = encodedRemoteNodeCommand(`process.stdout.write('${beforeMarker}\\n')`) + expect(beforeCommand).not.toContain(beforeMarker) + await execInTerminal(orcaPage, beforePtyId, beforeCommand) await waitForTerminalOutput(orcaPage, beforeMarker, 20_000, 60_000) const recoveryStartedMarker = `SSH_RECONNECT_RECOVERY_STARTED_${Date.now()}` const recoveryMarker = `SSH_RECONNECT_RECOVERY_${Date.now()}` @@ -382,7 +389,10 @@ test.describe('Docker SSH relay perf', () => { `if (frame === 256) { clearInterval(timer); process.stdout.write('${recoveryMarker}\\n') }`, '}, 10)' ].join(';') - await execInTerminal(orcaPage, beforePtyId, `node -e ${shellQuote(recoveryScript)}`) + const recoveryCommand = encodedRemoteNodeCommand(recoveryScript) + expect(recoveryCommand).not.toContain(recoveryStartedMarker) + expect(recoveryCommand).not.toContain(recoveryMarker) + await execInTerminal(orcaPage, beforePtyId, recoveryCommand) await waitForTerminalOutput(orcaPage, recoveryStartedMarker, 30_000, 80_000) await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) @@ -392,11 +402,16 @@ test.describe('Docker SSH relay perf', () => { await waitForTerminalOutput(orcaPage, recoveryMarker, 30_000, 80_000) const afterMarker = `SSH_RECONNECT_AFTER_${Date.now()}` const remoteProofPath = `/tmp/${afterMarker}` - await execInTerminal( - orcaPage, - afterPtyId, - `printf ${shellQuote(afterMarker)} | tee ${shellQuote(remoteProofPath)}` + const afterCommand = encodedRemoteNodeCommand( + [ + "const fs = require('node:fs')", + `const marker = '${afterMarker}'`, + `fs.writeFileSync('${remoteProofPath}', marker)`, + "process.stdout.write(marker + '\\n')" + ].join(';') ) + expect(afterCommand).not.toContain(afterMarker) + await execInTerminal(orcaPage, afterPtyId, afterCommand) await waitForTerminalOutput(orcaPage, afterMarker, 20_000, 60_000) expect(execDockerSshRelayTargetCommand(target, `cat ${shellQuote(remoteProofPath)}`)).toBe( afterMarker diff --git a/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs new file mode 100644 index 00000000000..dfdcfe6cf13 --- /dev/null +++ b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const ROW_LIMIT = 10_000 +const PRUNE_BATCH_SIZE = 64 + +function parseArgs(argv) { + const options = { iterations: 32, payloadBytes: 9_500 } + for (let index = 2; index < argv.length; index += 1) { + const value = argv[index + 1] + if (argv[index] === '--iterations') { + options.iterations = Number(value) + } else if (argv[index] === '--payload-bytes') { + options.payloadBytes = Number(value) + } else { + throw new Error(`Unknown argument: ${argv[index]}`) + } + index += 1 + } + if (!Number.isSafeInteger(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive integer') + } + if (!Number.isSafeInteger(options.payloadBytes) || options.payloadBytes < 0) { + throw new Error('--payload-bytes must be a non-negative integer') + } + return options +} + +function createFixture(path, payloadBytes, optimized) { + const db = new DatabaseSync(path) + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE mutation_receipts ( + caller_fingerprint TEXT NOT NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + payload_hash TEXT NOT NULL, + state TEXT NOT NULL, + receipt BLOB, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (caller_fingerprint, request_id) + ); + WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ${ROW_LIMIT} + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, receipt + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), 'completed', zeroblob(${payloadBytes}) + FROM receipt_numbers; + `) + if (optimized) { + db.exec(` + CREATE INDEX idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + CREATE TABLE mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY, + receipt_count INTEGER NOT NULL + ); + INSERT INTO mutation_receipt_ledger VALUES (1, ${ROW_LIMIT}); + CREATE TRIGGER mutation_receipts_count_insert AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count + 1; + END; + CREATE TRIGGER mutation_receipts_count_delete AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count - 1; + END; + `) + } + db.exec('PRAGMA wal_checkpoint(TRUNCATE)') + return db +} + +function runLegacyMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { count } = db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + const completedToRemove = count - ROW_LIMIT + 1 + if (completedToRemove > 0) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(completedToRemove) + } + db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function runOptimizedMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { receipt_count: count } = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() + if (count >= ROW_LIMIT) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(count - ROW_LIMIT + PRUNE_BATCH_SIZE) + } + db.prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function insertReceipt(db, iteration) { + db.prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('benchmark', ?, 'orchestration.send', ?, 'pending')` + ).run(`new_${iteration}`, `new_hash_${iteration}`) +} + +function percentile(sorted, fraction) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +function measure(db, iterations, mutation) { + const samplesMs = [] + for (let index = 0; index < iterations; index += 1) { + const startedAt = performance.now() + mutation(db, index) + samplesMs.push(performance.now() - startedAt) + } + const sorted = samplesMs.toSorted((left, right) => left - right) + return { + firstMs: samplesMs[0], + medianMs: percentile(sorted, 0.5), + p95Ms: percentile(sorted, 0.95), + maxMs: sorted.at(-1), + totalMs: samplesMs.reduce((sum, value) => sum + value, 0) + } +} + +const options = parseArgs(process.argv) +const fixtureDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-bench-')) +try { + const legacyPath = join(fixtureDir, 'legacy.db') + const optimizedPath = join(fixtureDir, 'optimized.db') + const legacyDb = createFixture(legacyPath, options.payloadBytes, false) + const optimizedDb = createFixture(optimizedPath, options.payloadBytes, true) + const databaseBytes = statSync(legacyPath).size + const legacy = measure(legacyDb, options.iterations, runLegacyMutation) + const optimized = measure(optimizedDb, options.iterations, runOptimizedMutation) + legacyDb.close() + optimizedDb.close() + + process.stdout.write( + `${JSON.stringify( + { + rows: ROW_LIMIT, + payloadBytes: options.payloadBytes, + databaseBytes, + iterations: options.iterations, + legacy, + optimized, + medianSpeedup: legacy.medianMs / optimized.medianMs, + totalSpeedup: legacy.totalMs / optimized.totalMs + }, + null, + 2 + )}\n` + ) +} finally { + rmSync(fixtureDir, { recursive: true, force: true }) +}