diff --git a/config/renderer-layout-writer-baseline.txt b/config/renderer-layout-writer-baseline.txt index bf67ac6d8e2..0dd324f61b5 100644 --- a/config/renderer-layout-writer-baseline.txt +++ b/config/renderer-layout-writer-baseline.txt @@ -31,7 +31,6 @@ 25 src/renderer/src/lib/workspace-session-patch.ts 3 src/renderer/src/lib/workspace-session-staged-browser-tabs.ts 17 src/renderer/src/lib/workspace-session.ts -21 src/renderer/src/lib/worktree-activation-created-agent-test-state.ts 3 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts 3 src/renderer/src/runtime/structured-agent-session-tab-retirement.ts 10 src/renderer/src/runtime/sync-runtime-graph/mobile-session-group-projection.ts diff --git a/docs/automated-setup-tab-astra-review.md b/docs/automated-setup-tab-astra-review.md new file mode 100644 index 00000000000..a15bd586e17 --- /dev/null +++ b/docs/automated-setup-tab-astra-review.md @@ -0,0 +1,139 @@ +# Independent review: automated Setup tab + +## Current review verdict + +Reviewed product source head `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`, based on the merged PR 1 commit `7f9b51cf5cc1de8769baa646b40c0c93af76be81`, then the documentation-only head and CI corrections described in Rounds 6–8. **P0: none found. P1: none unresolved. Design and actual source diff have converged, with concrete existing paths and no known smaller adequate implementation.** Review only; this report is my only edited file. The numbered rounds below are historical checkpoints, including questions and pending checks that were subsequently resolved. + +The implementation retains the existing Setup-tab creation paths and removes only automated setup split placement and its unreachable state. It retires the setting through the existing settings stripper, accepts old-host layouts, preserves manual/issue splits and generic lifecycle behavior, and leaves PR 1's native-chat provisioning change intact. No publication, sibling reattachment, generalized retry, or ownership repair was added. + +Product-behavior local validation is complete. Lead's consolidated post-rebase run reports 34 Node suites / 1816 passing tests, two retained expected-failure cases, and two skips on the reviewed source; node/web typechecks and the changed-code gate passed. Rebuilt hidden paired probes passed setup output, independent Setup tab placement, background selection, manual splitting, actual native-chat surface creation, fixture terminal-agent output, and hidden/unfocused host/client windows. I inspected the proof assertions and before/after/native-chat screenshots; I did not independently rerun tests under the lead's exclusive runtime lease. The E2E typecheck comparison reports 261 baseline and 261 final errors, with no new or removed file/error-code pairs; that project remains non-clean. CI subsequently found a fixture-naming ratchet failure; its reviewed correction changes no product behavior, and lead reports both affected ratchets, 23 focused consumer suites (187 passing tests plus two retained expected failures), repeated node/web typechecks and the changed-code gate all passed. + +Accepted **P2** limitations: automatic side-by-side setup placement is removed by design; old hosts can still return splits; the conditional ownership fixture does not prove the incident root cause or a publication/reattachment fix; native-chat proof submits no prompt, the terminal agent is a fixture CLI, and platform construction tests do not prove Windows/WSL/SSH execution. The generated English catalog conservatively retains 20 removed-callsite strings (1,136 uncompressed bytes), without restoring settings/search UI. Its remaining nonnative preflight checks passed per the Round 7 receipt. No unresolved source-review question or local correction-validation item remains. The correction commit, exact-head full PR workflow aggregate, and parent merge decision remain separate gates; this report is not merge authorization. Worker settlement awaits the lead's explicit request. + +The latest CI correction changes only a stale counterfactual listener-count expectation: the actual 15-listener budget already passed and remains unchanged. The lead reproduced the failing arithmetic under the CI Bun test path, then all four tests and the changed-code gate passed after correction. Round 8 records the independent code/history evidence; no product behavior or performance limit was changed. + +## Round 1 — historical design and control-flow review + +Reviewed 2026-10-16 in this worktree at `54bb885bbd391642adecdaef262e87c3424f46e6`. Review only; this report is my only owned file. Implementation/final-diff approval is outstanding. Tests and native builds were not run: the coordinator explicitly reserves the shared native-runtime lease for PR 1. + +**P0: none found. P1: no demonstrated unresolved design defect in the scoped tab-only approach.** This is not approval of an implementation or proof of the incident's cause. The changes below need concrete checks after PR 1 is merged and this branch is rebased onto that baseline. + +### Evidence and scope + +- `src/shared/worktree-first-surface.ts:11–17` explicitly seeds a primary shell for either split setting, even with `callerProvidesSurface`. Removing these two conditions removes a real split-anchor dependency without changing startup or issue-command requirements. +- Existing tab implementations are sufficient: renderer `worktree-setup-issue-command-queue.ts:27–45`, host `runtime-worktree-terminal-provisioning.ts:155–197`, and IPC `worktree-remote.ts:368–411`. Keep these command/env/wrapper/handle paths; do not invent a coordinator, ownership store, or retry mechanism. +- The IPC function is actually `spawnLocalStartupAndSetupTerminals` (`worktree-remote.ts:298`), not `spawnCreatedWorktreeStartup`. It is invoked in the local worktree flow at line 1697 despite the filename. Its no-startup/default-tabs early return at lines 308–310 matters when asserting reachability. Label this surface accurately in the design. +- Host success suppresses fallback: `runtime-local-worktree-terminal-startup.ts:203–214` omits returned setup after provisioning, while `worktree-remote.ts:1741–1749` returns only unconsumed setup. Preserving this control flow is smaller and safer than command deduplication. A failed wrapped setup retains `command` at `worktree-remote.ts:421–428`; the renderer prioritizes it at `worktree-setup-issue-command-queue.ts:28–32`. +- `setup-split-activation-single-owner.test.ts:13–18` describes a conditional reconstruction, and its host fixture deliberately returns no session tabs at lines 103–114 while terminal census returns two orphan records at 117–123. That establishes behavior under those premises, not the original missing-binding cause. Keep manual/saved or old-host split coverage without advertising this policy change as a binding/publication repair. + +### Native-chat reachability: do not inflate the first-terminal concern + +The desktop structured route sets `backendStartup` to undefined (`worktree-creation-flow-execute.ts:63–68`), routes explicit setup through the background seeding branch (`244–269`), and only then launches structured chat (`289–302`). IPC's early return above therefore returns setup for renderer execution. On the ordinary local route, `resolveWorkspaceTerminalHostAuthority` returns `none` (`workspace-terminal-host-authority.ts:124–127`), so it does **not** enter the first-terminal wait at `worktree-initial-terminal-seeding.ts:136–169`. It can use the existing null-primary Setup-tab branch at lines 172–196. + +The host-authority branch really does couple returned setup to a first terminal. However, the host native-chat route normally consumes setup (`runtime-local-worktree-terminal-startup.ts:174–187,203–214`), and the paired `agent.launch` test already exercises a real request (`worktree-agent-first-surface.test.ts:155–180`). A P1 claim needs a request that actually returns fallback setup while host authority is live and no terminal will arrive. A constructed `ensureWorktreeHasInitialTerminal` call alone is insufficient. Keep generic delayed delivery unchanged in PR 2 unless PR 1's actual merged route demonstrates that necessity; ask the parent to resolve any overlap first. + +### Settings compatibility + +Delete the live setting and type; add only a retired-key entry/destructure to `stripRetiredGlobalSettings` (`terminal-settings-migrations.ts:59–91`). The existing function covers loading (`normalize-loaded-global-settings.ts:68`), updates (`settings-update.ts:62`), and serialization (`state-serialization-secret-handling.ts:262`). A separate settings migration/normalization layer has no demonstrated need. + +Current paired read/write types omit this key (`runtime-client-settings-shape.ts:7–40,52–75`), and strict RPC `SettingsUpdate` omits it (`client-settings-params.ts:87–131`). Independent bounded named-path history found: + +1. `b1973657ea6` introduces `SettingsUpdate` in `src/main/runtime/rpc/methods/client-ui.ts` on 2026-05-21. Its explicit strict allowlist and `getClientSettings`/`updateClientSettings` projection in `orca-runtime.ts:1142–1193` exclude setup location; the same revision uses setup location privately in host provisioning at lines 7006–7009. +2. `c84007c541d^` predates the generated RPC parameter move. `rpc/methods/client-settings-schemas.ts:81–129` remains a strict allowlist without the key, and the then-inline types/projection in `runtime-client-settings.ts` omit it. +3. Targeted `git log -SsetupScriptLaunchMode` over the current/previous schema and client-settings paths found no introduction of the key into those schemas. Historical matches in `orca-runtime.ts` are host provisioning uses, not paired settings fields. + +This supports removal without an ignored paired field or wire-schema relaxation. Old hosts still own their placement: accept their returned split layouts without replay, flattening, migration, or interpreting a missing new preference as proof of unexecuted setup. + +### Explicit P2 items and acceptance details + +- **P2 product tradeoff:** automatic side-by-side placement disappears. Manual and issue splits remain. Users of old hosts can still receive split layouts until that host updates. +- **P2 design precision:** fix the IPC function name and distinguish local IPC from actual remote-host ownership. Replace the missing Electron-skill claim with the coordinator's discovered `agent-browser skills get core` / `electron` capability and pending hidden-CDP validation. +- **P2 coverage retention:** do not delete generic remount/restore/purge/PTY ownership assertions just because their fixtures mention setup. Convert them to manual/issue/old-host splits. Preserve startup identity consumption. Remove only setup-specific geometry gating (`terminal-pane-mount-preparation.ts:222–225`, `pty-resize-geometry.ts:219–226,242–244`); the visible startup-grid settle at lines 211–218 and 228 onward is broader than setup. +- **P2 selection precision:** renderer Setup creation restores the primary only when activation is allowed (`worktree-setup-issue-command-queue.ts:35–43`). Host `createTerminal` does not default to focused: `orca-runtime-core.ts:240–251` and `terminal/launch/terminal-create.ts:252–279` distinguish focus from selection-if-empty. Missing `activate:false` in the host's existing tab branch is not itself proof of focus theft. Assert primary/background selection through the real route instead of changing generic focus machinery. +- **P2 setup-only/templates:** simplify the mode predicate in both `worktree-creation-flow-execute.ts:173–177` and `runtime-worktree-terminal-provisioning.ts:129–138`, while preserving explicit `setupOnly` intent and no-setup fallback. Do not suppress templates for ordinary blank/native-chat creation. The existing failure fallback at provisioning lines 204–221 remains necessary when Setup was the only owed surface. + +### Smaller alternatives and convergence + +A default-only change is already inadequate: the default is already `new-tab`, while persisted split values still enter three separate branches and create an anchor shell. Ignoring the setting but retaining queues and geometry leaves dead lifecycle state and fails the requested removal. Replacing the three setup placement branches with their existing tab arm, removing only now-unreachable setup plumbing, and retiring the setting through the existing stripper is the smallest adequate design found. + +Before declaring design convergence, lead should confirm the corrected route labels, the historical-settings evidence above, and that no first-terminal or publication/reattach work will slip into PR 2 without actual post-PR-1 reachability evidence. No unresolved P0/P1 is currently demonstrated. Final convergence remains conditional on reviewing actual code and meaningful tests, including old-host no-replay, remount one-shot execution, setup-only/defaults, native-chat/terminal/background routes, sequencing failure fallback, and preserved manual/issue splits. Windows/WSL/SSH command tests establish construction behavior only; do not claim those platforms were executed unless they were. + +## Round 2 — design convergence + +Re-read the revised design after durable message `msg_bf91c03835ff`. The lead incorporated the correct local-IPC function/route, historical paired-schema evidence, first-terminal reachability qualification, and the PR 1/publication/reattachment boundaries. The design retains generic startup-grid settling and requires retargeting generic split fixtures. The Electron capability statement now identifies the installed specialization and keeps validation pending. + +**Design converged: no unresolved P0/P1, concrete implementation paths identified, and no known smaller adequate design.** The P2 product tradeoff and old-host behavior are explicit and accepted within scope. This permits scoped source implementation; it does not approve unreviewed code, tests, or merging. Re-evaluate against PR 1's exact merged baseline before final review. + +## Implementation review + +At this historical checkpoint, lead revision, PR 1 baseline, actual final diff and test results were pending; their resolution appears in the later rounds and current verdict above. Do not interpret this report as a merge authorization. + +### Interim fixture review + +At the lead's request (`msg_76639d99e47e`), inspected the retargeted delayed-primary/sibling-fallback test in `pty-connection-split-spawn.spec.ts:235–338`. Its CWD promise is a real generic delay (`run-deferred-connect.ts:74–103`), but then it waits for visible startup-grid settling; 20 shared animation callbacks are not necessarily 12 primary settle ticks when the sibling has its own resize callbacks. Recommended making both explicit-startup panes hidden, retaining the CWD gate and shared transport registry, then asserting sibling-first ownership before resolving the primary CWD. Existing `pty-connection-deliberate-sleep-guard.spec.ts:183–213` demonstrates this hidden CWD gate. This preserves the ownership regression purpose without changing production reattachment or restoring removed setup geometry. Lead must verify the proposed fixture correction; reviewer did not run tests or edit source. + +### Round 3 — implementation review before PR 1 rebase + +Reviewed the restored working diff after `msg_065efb1f75ff` (base remains `54bb885bbd391642adecdaef262e87c3424f46e6`). **No production P0/P1 found.** A final post-PR-1-baseline verdict remains outstanding. + +- All three placement changes retain the existing tab arm. Runtime retains completion-token registration, default-tab dressing, explicit setup-only suppression and failed-Setup fallback. IPC retains its wrapper, platform/shell builder, env, inactive/background options, and consumed-result behavior. No setup execution authority or retry mechanism was introduced. +- The removed state is setup-specific. Issue-command queues/consumption remain; `terminal-pane-mount-bootstrap.test.ts` checks the surviving primary-anchor split, env, focus policy and one-shot command clearing. `terminal-pane-pane-created.ts` still passes the captured raw startup identity to `createQueuedStartupConsumer` after removal of the setup-wait copy. +- `terminal-startup-grid-settle.ts` removes only the now-unused readiness predicate and its frame counter. With no predicate, old `readyFrame` and `frame` advanced together, and `usesReadinessGate` was false; the remaining algorithm therefore preserves the former generic branch. Grid stability/cap/cancellation tests and hidden-window start tests remain. +- Compared old and new PTY test-case names: only the obsolete setup-geometry case was deleted; delayed-primary was retargeted, and remount, resize/mobile locks, hidden startup, and replay cases remain. The final hidden-CWD delayed-primary fixture explicitly proves sibling ownership before release and then asserts a distinct primary PTY; lead reports 78/78 passing. +- Existing old-host first-activation reconstruction retains its layout/ownership assertions and now asserts an empty pending-startup map. Its expected-failure branch remains an acknowledged existing limitation, not a claimed fix. Purge/sleep tests retain issue-queue and generic startup coverage while removing only the deleted map's assertions. +- Stale settings are removed on load, update and save/reload by meaningful persistence tests; remaining setup-location references are deliberate stale-profile test input or the stripper. No paired RPC schema change or saved-layout migration was introduced. +- Inspected `setup-before.png` and `setup-after.png` directly in `.bench-fixtures/setup-split-removal-proof`: before has a split inside one terminal tab, after has Terminal 1 and Setup tabs with one visible Setup pane; both display the setup completion marker. Screenshots alone do not prove hidden/focus state; the proof's explicit BrowserWindow assertions and lead's execution receipt cover that separately. + +**P2 correction requested (`msg_ce3248db323b`):** `host-split-and-sleep-journeys.ts:56` calls public `terminal.split`, whose RPC handler (`terminal-lifecycle-methods.ts:96–101`) does not forward inactive options; `terminal-split-surface.ts:129,169` defaults activation to true. Thus the renamed `host-split-first-activation` journey can activate during fixture creation and overstates its coverage. Preserve inactive-first-open through an existing internal test seam, or explicitly navigate away and label this a reactivation fixture, retaining genuine old-host first-open coverage in the unit test. Do not broaden production RPC for the fixture. Also asked the lead to format/check the new proof spec and replace its unchecked result cast. + +Validation reported by lead: 34 Node files / 1799 tests with two existing expected-fail cases and two skipped; a separate 78-test PTY fixture run; node/web typechecks; changed-file quality gate. Reviewer has not independently executed these under the lead's exclusive native lease. Final logs/results, E2E typecheck baseline comparison, corrected P2 fixture, PR 1 merged-base adaptation, and exact-head workflow gates remain to be assessed. + +Round 3 follow-up: verified the P2 fixture correction after `msg_b7784f689bca`: `host-split-and-sleep-journeys.ts:57` returns to the seeded workspace and the oracle scenario is now `host-split-reactivation`. The old-host first-activation unit assertions remain. This resolves the misleading-coverage finding without broadening production scope. The lead will keep the one-off before/after probe under ignored proof artifacts, not in the PR. Lead also reports both hidden before/after probes passed output, tab/pane count, retained manual split, background-selection and hidden/unfocused-window assertions. No unresolved P0/P1 in the currently reviewed PR 2 diff; post-PR-1 review is still required. + +### Round 4 — exact PR 1 merged baseline + +Verified locally that `merge-base(64fbb53d8a4c92e17c5aa19a8aa86cd1869979bc, 7f9b51cf5cc1de8769baa646b40c0c93af76be81)` is the exact PR 1 merge `7f9b51cf5cc1de8769baa646b40c0c93af76be81`. The PR 2 diff does not modify PR 1's production `runtime-local-worktree-terminal-startup.ts`: its shared `callerProvidesInitialSurface` predicate still supplies the host's background agent ownership decision, and host success still suppresses returned setup at lines 202–209. + +Reviewed the only uncommitted overlap adaptation, `src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts`. It retains renderer payload construction → strict RPC parse → runtime argument builder → actual managed-worktree create (lines 72–91), and expands the Claude/Codex setup-only-host assertion across both stale split values, new-tab, and absent settings (95–113). It checks command/env, running setup receipt, consumed setup and absence of an invented startup terminal. Former split-anchor controls now request a real startup and verify distinct startup/Setup tab identities (132–143). Blank, activating attribution, run-hooks, skipped setup and default-command controls remain. The stale field is fixture-only and no deleted live type remains. + +**Post-rebase semantic review: no unresolved P0/P1, no duplicated PR 1 production change, and no smaller adequate alternative identified.** The accepted P2 product/old-host limitations remain as documented. Final post-rebase checks/proof and the eventual exact-head CI/merge authorization are still separate gates; reviewer is awaiting their receipts and the lead's explicit settlement request. + +### Round 5 — committed-head confirmation + +Verified the clean tracked worktree at `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`. Its only change from the Round 4 checkpoint is the already-reviewed native-chat setup test adaptation above; no additional production changes were introduced. **P0: none found. P1: none unresolved. The actual PR 2 diff has converged, with no known smaller adequate implementation.** + +Post-rebase validation receipt from lead (`msg_87e2f64711ef`): 1443 focused/runtime tests passed with one existing skip; node/web typechecks and the changed-code gate passed; the rebuilt paired hidden placement proof passed two tabs/one Setup pane, actual setup output, retained background selection, manual splitting, and hidden/unfocused BrowserWindow assertions. These are lead-executed results under the exclusive runtime lease, not independently rerun tests. The extra native-chat `agent.launch` probe and final E2E typecheck comparison remain pending and must not be described as passed yet. + +Accepted P2 limitations remain: automatic side-by-side setup placement is intentionally removed; old hosts may still publish split layouts; the preserved conditional ownership fixture is not incident root-cause proof or a publication/reattachment fix; Windows/WSL/SSH construction tests do not establish execution on those platforms. The E2E typecheck has a reported baseline of 261 pre-existing file/code failures, with the final comparison still outstanding. This confirms source review on the named head, not exact-head CI or merge authorization. Worker settlement remains pending the lead's explicit request. + +Round 5 validation follow-up (`msg_b865b628c0e7`): lead reports the additional final-baseline hidden probe passed. Independently read its actual paired `agent.launch` calls and assertions: background Claude creates a structured chat, the rendered workspace contains exactly one agent-session and one terminal, and the terminal-agent route creates two terminal tabs and displays the fixture CLI marker. Directly inspected `native-chat-after.png`, which shows Setup and Claude Chat with an empty composer. This proves native-chat surface creation, not a model turn; no prompt was submitted. The terminal-agent check uses a fixture CLI, not a real Codex session. The probe asserts every host/client BrowserWindow is hidden and unfocused. + +Read `e2e-typecheck-comparison.json`: baseline and final each have 261 errors, with empty new/removed file-and-TypeScript-code lists. Thus no new errors were found by that comparison, while the E2E project itself is not typecheck-clean. These receipts resolve the two pending validation items above. **Final source-review verdict on `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`: no unresolved P0/P1; accepted P2 limitations are explicit; no known smaller adequate design.** Awaiting explicit settlement or any CI-driven source revision. + +Final consolidated receipt (`msg_6734645deb90`): the lead reran all 34 selected Node suites after rebasing, reporting 1816 passing tests, two retained expected failures and two skips on the same source head. This supersedes the earlier focused-test counts as the final consolidated local result. All requested local validation items are resolved with the limitations stated in the current verdict; exact-head PR CI and parent merge approval remain outstanding. + +### Round 6 — CI fixture classification correction + +Verified documentation-only head `c6cdb67aaaf1cc7e0a2b5befa7e6e8b7cbb8cab2` preserved the reviewed product source. CI then rejected two fixture calls to `getDefaultSettings` under the focus-read ratchet. These calls replaced incomplete asserted settings during the setup preference removal; they did not add production execution-host routing. + +Independently inspected both fixtures and all repository references before and after the staged correction. Both modules import Vitest, and every discovered consumer is a `.test.ts` file. `check-focus-read-ratchet.mjs:357–359` already excludes `.test-fixture.ts`, with direct coverage at `check-focus-read-ratchet.test.mjs:244`. Renaming to `worktree-activation-created-agent.test-fixture.ts` and `worktree-activation.test-fixture.ts` therefore applies the existing test/product distinction. The staged renames are 100% content-identical; every import edit is test-only. The existing layout detector also excludes fixtures (`check-layout-writer-ratchets.mjs:161–165`), so the correction deletes the one obsolete 21-writer baseline row for the old filename. Neither detector nor any allowance was expanded. + +**No new P0/P1/P2 finding in this correction; prior accepted P2 limitations remain.** Conventional fixture naming is smaller and more accurate than a detector change, a suppression, duplicating defaults, restoring unchecked settings casts, or adding baseline allowances. Final correction receipt (`msg_b98097cf4ff3`): both ratchets passed, 23 focused consumer suites passed 187 tests with two retained expected failures, node/web typechecks passed, and the changed-code gate against the exact PR 1 baseline passed. Existing product-behavior and rendered proof receipts remain applicable; a hidden-app rebuild is unnecessary for content-identical fixture renames and test import changes. No local correction-validation item remains pending. Fresh exact-head full PR CI and parent merge approval are still required. + +### Round 7 — generated English catalog correction + +Verified clean committed fixture-correction head `fe855b8c97ddf90c0d2ad273f1355cf711ee5d8e` exactly matched Round 6. CI then failed catalog verification: removing the Setup UI/search callsites leaves English catalog entries without literal defaults. The existing `collectRuntimeRequiredKeys` contract (`generate-runtime-required-english-catalog.mjs:45–82`) conservatively retains such entries because they may be dynamically referenced; it does not attempt dead-key reachability analysis. The verifier prescribes the existing `sync:localization-runtime-catalog` generator. + +Independently compared flattened JSON before/after generation and the deleted callsites: exactly 20 added entries, zero removed or changed entries, all 20 keys present in the removed setup UI/search code. The uncompressed generated file grows by 1,136 bytes. There is no unrelated generator churn and no restored UI or search registration. Regenerating this single derived catalog is the smallest conservative correction; pruning locale translations requires a broader dynamic-key/locale audit, and changing the verifier would weaken an unrelated contract. + +**No new P0/P1; accepted P2:** some obsolete English text remains in the boot catalog under its existing conservative policy. This does not restore setup split behavior. Lead's final receipt (`msg_83f4e9157231`) reports the remaining nonnative gates passed: localization catalogs/extraction/coverage using the CI Bun commands, feature-wall budget, macOS entitlements, and preload/shared declaration guard. The unit-plan script completed its local full-reference fallback, which is not itself proof of the CI plan; lead separately reports the actual CI plan and all static steps preceding catalog verification, including Typecheck, had passed. No local correction-validation item remains pending. Fresh exact-head full PR CI and parent merge approval remain required after the correction commit. + +### Round 8 — stale listener-savings arithmetic + +Verified committed catalog head `35f132ba2547422cb6e11e00d1b631528fdab424` matched Round 7. A required unit shard then failed `terminal-pane-store-subscription-budget.test.tsx`. Independently traced the exact assertions: the live per-pane budget remains 15 and its assertion passes; the failure is the later equation `45 - 15 = actionKeys.length + 6`, where the action list is now 23, making the right side 29 rather than 30. It is not a measured increase to 30 live subscriptions. + +PR 2 removed `consumeTabSetupSplit` from the stable `useMemo`/`getState` action binding and removed a `useState` initializer that reads `pendingSetupSplitByTabId` through `getState`. Neither opened a subscription. The `PRE_FOLD_LISTENERS_PER_PANE` comment defines a counterfactual current mount with one listener per bound action, not an immutable historical observation. Bounded history corroborates its intended updates: `3e25fe70f6b` lowered 46 to 45 for removal of `readTerminalPaneOnInput`; `93baa828be9` lowered 50 to 46 for four removed actions. Both retained the actual live budget. + +The reviewed correction changes only `PRE_FOLD_LISTENERS_PER_PANE` from 45 to 44 and makes the stale action-count comment count-neutral. The exact 15-listener assertion, linear scaling, zero-listener stable-action binding and action identity assertions remain unchanged. This is smaller than introducing a retired-action offset or changing the test model, and does not loosen any live performance limit. **No new P0/P1/P2; prior accepted P2 limitations remain.** + +Lead's receipt (`msg_278eaae4e38e`): the exact Bun path reproduced one savings-equation failure with three tests passing before the change; all four passed afterward, and the changed-code gate against the exact PR 1 baseline passed. No product source changed. Existing product validation remains applicable; fresh exact-head full CI and parent merge approval remain separate pending gates. diff --git a/docs/automated-setup-tab-design.md b/docs/automated-setup-tab-design.md new file mode 100644 index 00000000000..710d08c7255 --- /dev/null +++ b/docs/automated-setup-tab-design.md @@ -0,0 +1,74 @@ +# Automated workspace setup: dedicated Setup tab + +Status: design converged; implementation, local validation, and independent source review complete. Exact-head CI and merge authorization are tracked by [PR #29819](https://github.com/stablyai/orca/pull/29819). + +## Decision and boundary + +Run terminal-backed automated workspace setup in its own **Setup** tab using the existing new-tab path. Remove automated setup split placement, its preference/control, and its one-shot split/geometry state. Keep manual splits, issue-command splits, generic startup consumption, saved layouts, default-tab policy, and setup-to-agent sequencing. + +This is PR 2. [PR 1 (#29810)](https://github.com/stablyai/orca/pull/29810) separately fixed background native-chat setup provisioning and merged first as `7f9b51cf5cc1de8769baa646b40c0c93af76be81`. This branch was rebased onto that exact merge before final review and relevant checks. Parent chat-publication and sibling-reattachment fixes, broad terminal ownership, corrupt saved-binding repair, and retries are outside this PR. + +## Evidence and severity + +* Confirmed pre-change mechanism: `shouldSeedWorktreePrimaryTerminal` treated split setup as a reason to create a shell even when the caller supplies native chat. Renderer `queueSetupAndIssueCommands`, runtime `provisionWorktreeTerminals`, and `spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts` each branched on setup location. This PR reuses their existing dedicated-tab arms. +* The supplied incident investigation observed two leaves bound to one PTY and an independent equal-count chat-publication failure. This worktree does not establish the original missing-binding cause or two setup executions. Removing placement complexity is the user-requested product policy; it is not proof of fixing every frozen terminal. +* No demonstrated P0. P1 acceptance risks are replaying setup after host provisioning, losing requested startup/setup sequencing, or changing primary selection/background workspace selection. The validation below exercises actual creation routes and the preserved one-shot startup mechanisms. +* P2 tradeoff: automatic side-by-side setup placement disappears; manual splitting remains. Older execution hosts can still return existing split layouts, which updated clients must accept without moving/replaying setup. +* The first-terminal delayed delivery coupling exists in `ensureWorktreeHasInitialTerminal` for runtime-owned fallback commands. Ordinary desktop structured creation passes no backend startup and returns setup through local IPC, then uses host authority `none`, so its null-primary Setup path is already independent. The host native-chat route normally consumes setup before returning. Keep delayed delivery unchanged: no actual request proves the alleged native-chat P1. The post-PR-1 semantic review found no reason to expand this scope. The conditional STA-9417 expected-failure fixture does not establish the original incident's cause. + +## Surfaces and selection + +| Intent | Surfaces owed by this create with setup | +| --- | --- | +| Native chat | Requested chat plus Setup, without a split-anchor shell. | +| Terminal agent | Requested agent terminal plus Setup. | +| Plain terminal | Normal primary terminal plus Setup. | +| Setup only | Setup alone; preserve current suppression of default tabs. | +| Explicit default tabs | Existing template allocation/commands/title/color policy plus Setup. | +| No setup | Existing behavior. | + +Issue automation retains its independent split from the normal primary pane, command/env, one-shot behavior, and focus policy. Setup stays unselected when another primary surface exists. Setup-only can select Setup when creation is still in view. Background creates/navigation away preserve workspace/tab selection and never reveal/focus a native window. + +## Small existing mechanisms + +1. Renderer: always create Setup with `pendingStartup`, title it through `setTabCustomTitle`, and preserve active primary/background options. Remove the location input from primary-shell and setup-only decisions. Retain host-spawn-result handling: `runtime-local-worktree-terminal-startup.ts` and local IPC omit consumed setup from fallback results, and remount restores its existing surface. +2. Runtime host: always `createTerminal` titled Setup; retain command construction, env, completion token/returned handle, fallback surface, `surfaceOwner`, and default-tab dressing. Remove setup's use of `splitTerminal` without changing generic split APIs. +3. Local IPC creation (`spawnLocalStartupAndSetupTerminals` in `worktree-remote.ts`): always use the existing new-tab branch; preserve platform runner/shell selection, sequencing wrapper, `activate: false`, and host authority. Accept old-host startup/layout evidence as authoritative. +4. Delete `pendingSetupSplitByTabId`, queue/consume actions, setup props/captured refs/bootstrap branch, setup-only geometry waits. Preserve generic geometry/resize functions used elsewhere and startup identity consumption. Do not remove regression purpose when updating fixtures: use manual/saved or issue-command splits for generic restore, purge, remount, and PTY ownership coverage. +5. Remove the Setup Script Location section and search registration. No new style/token/component is needed; follow `docs/STYLEGUIDE.md`. + +## Settings and mixed versions + +The implementation deletes `setupScriptLaunchMode` from live `GlobalSettings`, defaults, and launch types, and adds it to existing `stripRetiredGlobalSettings` so stale load/update/save keys are dropped. Remaining setting inputs are deliberate legacy-profile regression fixtures. There is no dead normalization or shared type retained for an unproven peer requirement. + +Current `SettingsUpdate` is a strict explicit client-settings allowlist without this key; `RuntimeClientSettings` and `RuntimeClientSettingsUpdate` also omit it. Independent bounded history confirms the initial paired schema/projection at `b1973657ea6` (2026-05-21) and the pre-generated schema/types at `c84007c541d^` omit it; targeted key-history finds no paired-field introduction (see the Astra report for paths). Retire through the existing stripper, which already covers load, update and serialization; do not add a new normalization layer. Hosts own placement: an updated host ignores retired settings, and an old host may still produce a split. Updated clients accept returned layouts without replay or migration. Removing a client setting cannot force old hosts to adopt the new policy. + +No protocol field/opcode, live-layout migration, scheduler, ownership store, command-string deduplication, or retry machinery is proposed. Runner arguments/env/cwd, Windows batch/shebang, WSL/SSH paths, and setup completion remain with existing functions. + +## Alternatives + +Changing only the default retains legacy split behavior and lifecycle complexity. Keeping the setting while ignoring it retains an unused contract unless actual old-peer evidence requires it. Moving all setup provisioning into a new service duplicates existing working paths and crosses PR 1. General binding enforcement and saved-layout repair solve broader problems and need separate evidence/design. Independent design and actual-diff review found no known smaller adequate approach; this is a scoped engineering judgment, not a mathematical optimality claim. + +## Validation and review gates + +Before implementation: Astra round 1 found no demonstrated unresolved P0/P1 in this scoped design, with concrete route/settings evidence and no known smaller adequate approach. Round 2 confirms the corrected local-IPC label, historical settings evidence, unchanged first-terminal delivery and strict PR 1/publication/reattachment boundary. The independent report is `docs/automated-setup-tab-astra-review.md`; implementation review remains separate. + +Meaningful automated coverage: renderer/native-chat/terminal/setup-only/default-tab/background actual requests, host/remote no-split command/env preservation, sequencing/failure fallback, old-host returned layout/no replay, remount one-shot startup, issue-command/manual split/restore/purge regression intent. Run relevant tests with `ORCA_BACKGROUND_LAUNCH=1`, node/web typechecks, changed-file code-quality and format checks. Preserve platform-specific runner tests; report platforms actually exercised. + +Rendered checks require rebuilt hidden Electron plus Playwright CDP; inspect tab count/selection/setup output and retained manual splits, with before/after evidence. The installed agent-browser CLI provides the version-matched Electron skill (`agent-browser skills get core`, then `agent-browser skills get electron`), now read; use it alongside Playwright CDP. The coordinator serialized the shared native-runtime lease after an initial local rebuild failure; the supported rebuilt hidden-app checks subsequently passed. + +After implementation: Astra reviews actual final diff/tests and semantic overlap after PR 1 rebase. No merge until PR 1 confirmation, exact-head full `.github/workflows/pr.yml` successful aggregate `verify` / PR checks passed and all required checks, and parent approval through the mandatory blocking merge-gate ask. Use `--match-head-commit` and confirm the merge. + + +## Implemented validation record + +Reviewed source head: `d96ae2ca6afdca919b4d7f8d85308363abe8b28b`, based on the exact PR 1 merge above. The follow-up documentation commit changes no source behavior. The independent review records no unresolved P0/P1 and explicit P2 tradeoffs. + +- **Node tests:** final consolidated post-rebase selection passed 34 suites and 1,816 tests; two existing conditional expected-failure cases and two skips remain visible. The selection includes composed native-chat create payload/RPC/host routes for Claude and Codex, retired locations, setup-only/default tabs, wrapper/env/shell construction, failure fallback, one-shot startup, old-host layouts, manual/issue splits, remount, restore, sleep and purge behavior. +- **Types and static checks:** node/web typechecks, changed-file code-quality gate and staged Oxlint/format checks passed. The extra e2e project still reports 261 baseline diagnostics; baseline/final comparison by file and TypeScript error code found none added. This is not a clean e2e-project typecheck. A Bun `node:sqlite` suite-loader failure was avoided by executing the complete selected suites with the supported `test:node` command; no test-routing refactor is included. +- **Real rendered before/after:** rebuilt e2e-mode Electron host and paired client, both hidden, legacy vertical-split preference. Before: one terminal tab and two panes. After: Terminal 1 and Setup tabs, one Setup pane. Both displayed the real setup completion marker, retained background tasks selection, and supported another manual split. Post-rebase after-proof passed again. Screenshots are attached to the PR outside the source diff. +- **Rendered agent surfaces:** actual paired `agent.launch` created Claude native chat plus Setup with no shell anchor; the empty chat composer rendered and no prompt/model turn was submitted. A terminal-agent fixture CLI launched separately beside Setup and displayed its marker. Every native window was asserted hidden and unfocused. +- **Coverage retention:** the public `terminal.split` API activates by design, so its host layout fixture explicitly navigates away and tests reactivation. Genuine conditional old-host first-activation ownership/no-replay assertions remain in the unit fixture. Its expected-failure reconstruction is not presented as incident-cause proof. +- **Platforms actually executed:** macOS local Electron host plus paired desktop client. Windows/WSL command construction is tested, while live Linux, Windows, WSL and SSH execution were not performed. Existing saved layouts remain intact, and old execution hosts can still publish split layouts until upgraded. + +Design/Astra artifacts are intentionally preserved in this PR. One-off probe sources and raw local logs live under ignored `.bench-fixtures/setup-split-removal-proof`; no images, profiles, auth data, or temporary test spec are committed. Merge still requires the exact current head's full PR workflow aggregate and all required checks, then the parent's mandatory blocking merge-gate approval. diff --git a/src/main/codex-accounts/codex-account-settings-fixture.ts b/src/main/codex-accounts/codex-account-settings-fixture.ts index b048bb8e3d6..339f59c11b2 100644 --- a/src/main/codex-accounts/codex-account-settings-fixture.ts +++ b/src/main/codex-accounts/codex-account-settings-fixture.ts @@ -18,7 +18,6 @@ export function createCodexAccountSettings( terminalDividerColorLight: '#ffffff', terminalPaneOpacityTransitionMs: 150, terminalDividerThicknessPx: 1, - setupScriptLaunchMode: 'split-vertical', localAccountRuntime: 'host', floatingTerminalEnabled: false, terminalMacOptionAsAlt: 'false', diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4bf0435f74d..7f8b97ce765 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -8,7 +8,6 @@ import { existsSync } from 'node:fs' import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params' -import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' import type { CreateWorktreeArgs, @@ -301,10 +300,9 @@ async function spawnLocalStartupAndSetupTerminals(args: { startup: CreateWorktreeArgs['startup'] setup: CreateWorktreeResult['setup'] defaultTabs: CreateWorktreeResult['defaultTabs'] - settings: GlobalSettings createdWithAgent: CreateWorktreeArgs['createdWithAgent'] }): Promise { - const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args + const { runtime, worktree, startup, setup, defaultTabs, createdWithAgent } = args if (!startup || defaultTabs?.tabs.length) { return { didSpawnSetup: false } } @@ -377,37 +375,17 @@ async function spawnLocalStartupAndSetupTerminals(args: { ), setup.shell ) - const setupLaunchMode = - (settings as Partial>) - .setupScriptLaunchMode ?? 'new-tab' - if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') { - if (!startupTerminalHandle) { - throw new Error('startup_terminal_missing') - } - await runtime.splitTerminal( - startupTerminalHandle, - { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }, - worktree - ) - } else { - await runtime.createTerminal( - `id:${worktree.id}`, - { - title: 'Setup', - command: setupCommand, - env: setup.envVars, - activate: false, - surfaceOwner: false - }, - worktree - ) - } + await runtime.createTerminal( + `id:${worktree.id}`, + { + title: 'Setup', + command: setupCommand, + env: setup.envVars, + activate: false, + surfaceOwner: false + }, + worktree + ) didSpawnSetup = true } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -1700,7 +1678,6 @@ async function performLocalWorktreeCreate( startup: args.startup, setup, defaultTabs, - settings, createdWithAgent: args.createdWithAgent }) ) diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 97093580638..bd8d9f4aef5 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -788,7 +788,7 @@ describe('registerWorktreeHandlers', () => { expect(result.setup?.command).toContain('printf') }) - it('splits split-mode setup into the startup terminal without surfacing the workspace', async () => { + it('opens Setup beside the startup terminal without surfacing the workspace', async () => { addWorktreeMock.mockResolvedValue({}) listWorktreesMock.mockResolvedValueOnce([ { @@ -818,12 +818,13 @@ describe('registerWorktreeHandlers', () => { startup: { command: 'claude' } }) - expect(runtimeStub.createTerminal).toHaveBeenCalledTimes(1) + expect(runtimeStub.createTerminal).toHaveBeenCalledTimes(2) + expect(runtimeStub.splitTerminal).not.toHaveBeenCalled() // A user who moved on must not be scrolled to the new workspace by its setup pane (#9944). - expect(runtimeStub.splitTerminal).toHaveBeenCalledWith( - 'term-startup', + expect(runtimeStub.createTerminal).toHaveBeenLastCalledWith( + 'id:repo-1::/workspace/improve-dashboard', { - direction: 'vertical', + title: 'Setup', command: expect.stringContaining('setup-runner.sh'), env: expect.any(Object), activate: false, diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 574577c27f8..f265c9f6676 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -57,6 +57,7 @@ export function readLegacyTerminalScrollbackSettings( } type RetiredGlobalSettings = { + setupScriptLaunchMode?: unknown terminalScrollbackBytes?: unknown enableGitHubAttribution?: unknown showAgentsSidebar?: unknown @@ -72,6 +73,7 @@ export function stripRetiredGlobalSettings( settings: Partial | undefined ): Partial { const { + setupScriptLaunchMode: _legacySetupScriptLaunchMode, terminalScrollbackBytes: _legacyScrollbackBytes, enableGitHubAttribution: _legacyGitHubAttribution, showAgentsSidebar: _legacyShowAgentsSidebar, @@ -81,6 +83,7 @@ export function stripRetiredGlobalSettings( openAgentTabsInChatByDefault: _legacyChatDefaultView, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings + void _legacySetupScriptLaunchMode void _legacyScrollbackBytes void _legacyGitHubAttribution void _legacyShowAgentsSidebar diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 3714e96ea01..393afcd0b19 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -22,6 +22,20 @@ function normalizeLegacyProfile(overrides: Record): PersistedSt return normalizeLoadedGlobalSettings(parsed, terminal, profile) } +describe('retired setup placement', () => { + it.each(['split-vertical', 'split-horizontal', 'new-tab', undefined])( + 'drops saved setup location %s without changing terminal preferences', + (setupScriptLaunchMode) => { + const normalized = normalizeLegacyProfile({ + setupScriptLaunchMode, + terminalFocusFollowsMouse: true + }) + expect(normalized).not.toHaveProperty('setupScriptLaunchMode') + expect(normalized.terminalFocusFollowsMouse).toBe(true) + } + ) +}) + describe('retired Agents sidebar setting', () => { it('does not mark new profiles as migrated', () => { expect(normalizeLegacyProfile({}).agentsSidebarMigratedFromExperimental).toBe(false) diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts index 3550c3b5312..8ad12db5805 100644 --- a/src/main/persistence/loading-store/state-write-round-trip.test.ts +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -79,6 +79,25 @@ function session(activeTabId: string): WorkspaceSessionState { } describe('persisted state survives a save/load round trip', () => { + it.each(['split-vertical', 'split-horizontal', 'new-tab'])( + 'drops retired setup location %s on update and disk serialization', + (setupScriptLaunchMode) => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-retired-setup-location-'))), + 'state.json' + ) + const written = openStore(dataFile) + const updates = { setupScriptLaunchMode, terminalFocusFollowsMouse: true } + expect(written.updateSettings(updates)).not.toHaveProperty('setupScriptLaunchMode') + written.flush() + const saved = JSON.parse(readPersistedStateJson(dataFile)) + expect(saved.settings).not.toHaveProperty('setupScriptLaunchMode') + const loaded = openStore(dataFile).getSettings() + expect(loaded).not.toHaveProperty('setupScriptLaunchMode') + expect(loaded.terminalFocusFollowsMouse).toBe(true) + } + ) + it('keeps a #22551 settings-slot OpenCode Go key on disk until its new owner has it', () => { const dataFile = join( realpathSync(mkdtempSync(join(tmpdir(), 'orca-legacy-opencode-go-key-'))), diff --git a/src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts index 040f37d57cb..147df11a1b4 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-native-chat-setup.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../../shared/constants' import { WorktreeCreate } from '../../../shared/rpc-contract/worktree-create-params' -import type { SetupScriptLaunchMode } from '../../../shared/worktree/launch-types' import { buildRuntimeWorktreeCreateParams, type WorktreeCreateRequest @@ -24,18 +23,20 @@ const SETUP_ENV = { ORCA_WORKTREE_PATH: WORKTREE_PATH, SETUP_INPUT: 'preserved' async function createFromRendererPayload(options: { agent?: WorktreeCreateRequest['createdWithAgent'] - mode?: SetupScriptLaunchMode + mode?: 'new-tab' | 'split-horizontal' | 'split-vertical' activate?: boolean runHooks?: boolean withSetup?: boolean defaultTabs?: boolean startup?: boolean }) { - const mode = options.mode ?? 'new-tab' const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) const runtime = new OrcaRuntimeService({ ...runtimeStore, - getSettings: () => ({ ...store.getSettings(), setupScriptLaunchMode: mode }) + getSettings: () => ({ + ...store.getSettings(), + ...(options.mode ? { setupScriptLaunchMode: options.mode } : {}) + }) }) const spawn = vi.fn(async (_args: { command?: string; env?: Record }) => ({ id: `pty-native-chat-${spawn.mock.calls.length}` @@ -92,10 +93,17 @@ async function createFromRendererPayload(options: { } describe('native-chat setup through renderer payload, RPC and execution host', () => { - it.each(['claude', 'codex'] as const)( - 'creates only Setup for a background %s chat in new-tab mode', - async (agent) => { - const { result, spawn } = await createFromRendererPayload({ agent }) + it.each( + (['claude', 'codex'] as const).flatMap((agent) => + (['new-tab', 'split-horizontal', 'split-vertical', undefined] as const).map((mode) => ({ + agent, + mode + })) + ) + )( + 'creates only Setup for a background $agent chat with retired location $mode', + async ({ agent, mode }) => { + const { result, spawn } = await createFromRendererPayload({ agent, mode }) expect(spawn.mock.calls.map(([args]) => args.command)).toEqual([`bash ${RUNNER}`]) expect(spawn.mock.calls[0][0].env).toMatchObject(SETUP_ENV) expect(result.setup).toBeUndefined() @@ -122,13 +130,15 @@ describe('native-chat setup through renderer payload, RPC and execution host', ( }) it.each(['split-horizontal', 'split-vertical'] as const)( - 'keeps the primary shell needed by %s setup', + 'gives the requested startup and Setup separate tabs despite retired location %s', async (mode) => { - const { spawn } = await createFromRendererPayload({ agent: 'codex', mode }) - expect(spawn.mock.calls.map(([args]) => args.command)).toEqual([undefined, `bash ${RUNNER}`]) + const { spawn } = await createFromRendererPayload({ agent: 'codex', mode, startup: true }) + expect(spawn.mock.calls.map(([args]) => args.command)).toEqual(['codex', `bash ${RUNNER}`]) expect(spawn.mock.calls[0][0].env?.ORCA_TAB_ID).toEqual(expect.any(String)) expect(spawn.mock.calls[0][0].env?.ORCA_TAB_ID).not.toBe('') - expect(spawn.mock.calls[0][0].env?.ORCA_TAB_ID).toBe(spawn.mock.calls[1][0].env?.ORCA_TAB_ID) + expect(spawn.mock.calls[0][0].env?.ORCA_TAB_ID).not.toBe( + spawn.mock.calls[1][0].env?.ORCA_TAB_ID + ) } ) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts index 0f121cc56d5..0eb69ea5328 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-02.spec.ts @@ -520,7 +520,7 @@ describe('OrcaRuntimeService', () => { expect(revealPayload).not.toHaveProperty('surfaceOwner') }) - it('honors split setup placement for CLI-created worktrees without startup agents', async () => { + it('uses a Setup tab for CLI-created worktrees with a retired split preference', async () => { const runtimeStore = { ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ @@ -594,16 +594,14 @@ describe('OrcaRuntimeService', () => { const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expectStablePaneKeyEnv(mainEnv) expectStablePaneKeyEnv(setupEnv) - expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) - const mainLeafId = mainEnv.ORCA_PANE_KEY!.slice(`${mainEnv.ORCA_TAB_ID!}:`.length) + expect(setupEnv.ORCA_TAB_ID).not.toBe(mainEnv.ORCA_TAB_ID) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ ptyId: 'pty-cli-setup-setup', - tabId: mainEnv.ORCA_TAB_ID, + tabId: setupEnv.ORCA_TAB_ID, activate: false, - splitFromLeafId: mainLeafId, - splitDirection: 'vertical' + title: 'Setup' }) ) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index 3a978bcbb56..a61da54aac2 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -22,7 +22,7 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { - it('does not surface the new workspace when a background create splits its setup pane', async () => { + it('does not surface the new workspace when a background create opens Setup', async () => { const runtimeStore = { ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ @@ -86,8 +86,6 @@ describe('OrcaRuntimeService', () => { setupDecision: 'run' }) - // Split launch modes reach the renderer through splitTerminal, not - // createTerminal — that path must suppress owner surfacing too. expect(activateWorktree).not.toHaveBeenCalled() await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} @@ -96,16 +94,16 @@ describe('OrcaRuntimeService', () => { result.worktree.id, expect.objectContaining({ ptyId: 'pty-bg-split-setup', - tabId: mainEnv.ORCA_TAB_ID, + tabId: expect.not.stringMatching(`^${mainEnv.ORCA_TAB_ID}$`), activate: false, surfaceOwner: false, - splitDirection: 'horizontal' + title: 'Setup' }) ) ) }) - it('still surfaces the new workspace when an activating create splits its setup pane', async () => { + it('still surfaces the new workspace when an activating create opens Setup', async () => { const runtimeStore = { ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, getSettings: () => ({ @@ -180,14 +178,12 @@ describe('OrcaRuntimeService', () => { result.worktree.id, expect.objectContaining({ ptyId: 'pty-active-split-setup', - splitDirection: 'horizontal' + title: 'Setup' }) ) ) - const splitRevealPayload = revealTerminalSession.mock.lastCall?.[1] as - | { surfaceOwner?: boolean } - | undefined - expect(splitRevealPayload).not.toHaveProperty('surfaceOwner') + const setupRevealPayload = revealTerminalSession.mock.lastCall?.[1] + expect(setupRevealPayload).not.toHaveProperty('surfaceOwner') }) it('does not warn when setup is explicitly skipped for CLI-created worktrees', async () => { diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts index a10e25a76ff..35fa483007e 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-04.spec.ts @@ -236,7 +236,7 @@ describe('OrcaRuntimeService', () => { expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) - it('honors split setup placement for opted-in local startup-draft worktrees', async () => { + it('uses a Setup tab beside opted-in local startup-draft agents', async () => { const metaById: Record = {} const runtimeStore = { ...makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()).runtimeStore, @@ -364,16 +364,14 @@ describe('OrcaRuntimeService', () => { }) expect(mainEnv.ORCA_TAB_ID).toBeDefined() expect(mainEnv.ORCA_PANE_KEY).toBeDefined() - expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) - const mainLeafId = mainEnv.ORCA_PANE_KEY!.slice(`${mainEnv.ORCA_TAB_ID!}:`.length) + expect(setupEnv.ORCA_TAB_ID).not.toBe(mainEnv.ORCA_TAB_ID) expect(revealTerminalSession).toHaveBeenLastCalledWith( result.worktree.id, expect.objectContaining({ ptyId: 'pty-startup-split-setup', - tabId: mainEnv.ORCA_TAB_ID, + tabId: setupEnv.ORCA_TAB_ID, activate: false, - splitFromLeafId: mainLeafId, - splitDirection: 'vertical' + title: 'Setup' }) ) }) diff --git a/src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts b/src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts index 1591559d190..71096fe72c1 100644 --- a/src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts +++ b/src/main/runtime/orca-runtime-worktree-provisioning-availability.test.ts @@ -104,7 +104,7 @@ describe('startup hosts retain current runtime capabilities', () => { ).rejects.toThrow('runtime_unavailable') }) - it('reads current create and split overrides with the captured worktree and three arguments', async () => { + it('reads the current create override with the captured worktree and three arguments', async () => { const runtime = new OrcaRuntimeService(store) onTestFinished(() => { vi.restoreAllMocks() @@ -142,22 +142,8 @@ describe('startup hosts retain current runtime capabilities', () => { expect(evidence).toBe(created) return { handle: 'created-handle', worktreeId: created.id, title: null } }) - const split = vi.spyOn(runtime, 'splitTerminal').mockImplementation(async function ( - this: OrcaRuntimeService, - handle, - input, - evidence - ) { - expect(this).toBe(runtime) - expect(arguments.length).toBe(3) - expect([handle, input, evidence]).toEqual(['created-handle', options, created]) - expect(evidence).toBe(created) - return { handle: 'split-handle', tabId: 'tab', paneRuntimeId: 1 } - }) await host.createTerminal('id:created-worktree', options) - await host.splitTerminal('created-handle', options) expect(create).toHaveBeenCalledOnce() - expect(split).toHaveBeenCalledOnce() }) it('reads replacement providers and preserves readiness failures after removal', async () => { @@ -199,17 +185,14 @@ describe('startup hosts retain current runtime capabilities', () => { expect(() => hasChildProcesses('startup-pty')).toThrow(TypeError) }) - it('reads the current store and canonical setup-token map when invoked', () => { + it('writes the canonical setup-token map when invoked', () => { const runtime = new OrcaRuntimeService(store) const host = runtime['getWorktreeTerminalProvisioningHost']() const original = runtime['setupCompletionTokenByPtyId'] const replacement = new Map() - const settings = { ...store.getSettings(), workspaceDir: tmpdir() } Object.defineProperty(runtime, 'setupCompletionTokenByPtyId', { value: replacement }) - Object.defineProperty(runtime, 'store', { value: { ...store, getSettings: () => settings } }) host.recordSetupCompletionToken?.('startup-pty', 'late-token') expect(replacement.get('startup-pty')).toBe('late-token') expect(original.has('startup-pty')).toBe(false) - expect(host.getSettings()).toBe(settings) }) }) diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 2d6babcc502..c010af9b5f3 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -114,7 +114,6 @@ export type RuntimeStore = { branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] newWorkspaceSetupOnly?: GlobalSettings['newWorkspaceSetupOnly'] - setupScriptLaunchMode?: GlobalSettings['setupScriptLaunchMode'] disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts index b30651dd31f..51202405031 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts @@ -20,10 +20,8 @@ function fakeHost() { created += 1 return { handle: `term_${created}` } }), - splitTerminal: vi.fn(), setTabTitle: vi.fn(async (_handle: string, _title: string) => {}), setTabColor: vi.fn(async (_handle: string, _color: string) => {}), - getSettings: () => ({}), getPtyId: () => undefined, recordSetupCompletionToken: vi.fn() } satisfies Partial> @@ -97,7 +95,7 @@ describe('default tabs beside the agent a create started', () => { const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} } -function createHost(settings: Record): { +function createHost(): { host: WorktreeTerminalProvisioningHost titles: () => (string | undefined)[] } { @@ -108,11 +106,8 @@ function createHost(settings: Record): { host: { canSpawn: () => true, createTerminal, - splitTerminal: vi.fn(async () => ({ handle: 'split' })), setTabTitle: vi.fn(async () => {}), setTabColor: vi.fn(async () => {}), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: provisioning reads only the optional keys each test supplies. - getSettings: () => settings as ReturnType, getPtyId: () => undefined, recordSetupCompletionToken: vi.fn() }, @@ -141,16 +136,14 @@ function provisionWithSetup( } describe('provisionWorktreeTerminals with a cleared creation choice', () => { - const none = { defaultTuiAgent: 'blank', newWorkspaceSetupOnly: true } - - it('honours a one-off None choice over the host blank default', async () => { - const { host, titles } = createHost({ defaultTuiAgent: 'blank' }) + it('creates only Setup for an explicit setup-only choice', async () => { + const { host, titles } = createHost() await provisionWithSetup(host, true, true) expect(titles()).toEqual(['Setup']) }) it('skips configured template commands when Setup is the only tab', async () => { - const { host, titles } = createHost({ defaultTuiAgent: 'blank' }) + const { host, titles } = createHost() await provisionWithSetup(host, true, true, DEFAULT_TABS) expect(titles()).toEqual(['Setup']) expect(host.setTabTitle).not.toHaveBeenCalled() @@ -158,42 +151,38 @@ describe('provisionWorktreeTerminals with a cleared creation choice', () => { }) it.each([ - { withSetup: true, setupOnly: false, mode: 'new-tab', titles: ['Dev', 'Tests', 'Setup'] }, - { withSetup: false, setupOnly: true, mode: 'new-tab', titles: ['Dev', 'Tests'] }, - { withSetup: true, setupOnly: true, mode: 'split-vertical', titles: ['Dev', 'Tests'] } - ])( - 'keeps templates for blank, skipped, and split setup: $mode/$withSetup/$setupOnly', - async (test) => { - const { host, titles } = createHost({ setupScriptLaunchMode: test.mode }) - await provisionWithSetup(host, test.withSetup, test.setupOnly, DEFAULT_TABS) - expect(titles()).toEqual(test.titles) - expect(host.createTerminal).toHaveBeenCalledWith('id:wt-1', { - title: 'Dev', - command: 'pnpm dev' - }) - } - ) + { withSetup: true, setupOnly: false, titles: ['Dev', 'Tests', 'Setup'] }, + { withSetup: false, setupOnly: true, titles: ['Dev', 'Tests'] } + ])('keeps templates for blank or skipped setup: $withSetup/$setupOnly', async (test) => { + const { host, titles } = createHost() + await provisionWithSetup(host, test.withSetup, test.setupOnly, DEFAULT_TABS) + expect(titles()).toEqual(test.titles) + expect(host.createTerminal).toHaveBeenCalledWith('id:wt-1', { + title: 'Dev', + command: 'pnpm dev' + }) + }) - it('honours a one-off blank choice over the host None default', async () => { - const { host, titles } = createHost(none) + it('keeps the primary terminal for an explicit plain-terminal choice', async () => { + const { host, titles } = createHost() await provisionWithSetup(host, true, false) expect(titles()).toEqual([undefined, 'Setup']) }) - it('ignores the retired saved None flag without an explicit cleared choice', async () => { - const { host, titles } = createHost(none) + it('keeps the primary terminal without a cleared creation choice', async () => { + const { host, titles } = createHost() await expect(provisionWithSetup(host)).resolves.toMatchObject({ setupSpawned: true }) expect(titles()).toEqual([undefined, 'Setup']) }) - it('keeps the blank terminal for a plain blank default', async () => { - const { host, titles } = createHost({ defaultTuiAgent: 'blank' }) - await provisionWithSetup(host) - expect(titles()).toEqual([undefined, 'Setup']) + it('creates Setup without a shell when the caller supplies the primary surface', async () => { + const { host, titles } = createHost() + await provisionWithSetup(host, true, false, undefined, true) + expect(titles()).toEqual(['Setup']) }) it('falls back to a blank terminal when no setup runs', async () => { - const { host, titles } = createHost(none) + const { host, titles } = createHost() await provisionWithSetup(host, false, true) expect(titles()).toEqual([undefined]) }) @@ -201,7 +190,7 @@ describe('provisionWorktreeTerminals with a cleared creation choice', () => { it.each([false, true])( 'opens a failed-Setup recovery shell only when the caller owes the surface (%s)', async (callerProvidesSurface) => { - const { host, titles } = createHost(none) + const { host, titles } = createHost() const createTerminal = vi.mocked(host.createTerminal) createTerminal.mockRejectedValueOnce(new Error('spawn failed')) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -215,11 +204,4 @@ describe('provisionWorktreeTerminals with a cleared creation choice', () => { warn.mockRestore() } ) - - it('keeps the shell a split setup needs', async () => { - const { host, titles } = createHost({ ...none, setupScriptLaunchMode: 'split-vertical' }) - await provisionWithSetup(host, true, true) - expect(titles()).toEqual([undefined]) - expect(host.splitTerminal).toHaveBeenCalledTimes(1) - }) }) diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.ts index f2dc7160e45..fc03dfa4ecf 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.ts @@ -3,14 +3,12 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import { shouldSeedWorktreePrimaryTerminal } from '../../shared/worktree-first-surface' import { buildObservedSetupCommand } from './orchestration/setup-completion-signal' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' -import type { RuntimeStore } from './runtime-store-contract' type TerminalResult = { handle: string } export type WorktreeProvisionTerminalOptions = { title?: string command?: string env?: Record - direction?: 'horizontal' | 'vertical' activate?: boolean surfaceOwner?: false } @@ -21,15 +19,10 @@ export type WorktreeTerminalProvisioningHost = { selector: string, options: WorktreeProvisionTerminalOptions ) => Promise - splitTerminal: ( - handle: string, - options: WorktreeProvisionTerminalOptions - ) => Promise // Why: keyed by the terminal; the host resolves its tab, so no create path can forget to pass it. // The title is the tab's own, as the window's setTabCustomTitle sets it, never the pane's title. setTabTitle: (handle: string, title: string) => Promise setTabColor: (handle: string, color: string) => Promise - getSettings: () => ReturnType getPtyId: (handle: string) => string | undefined recordSetupCompletionToken: (ptyId: string, token: string) => void } @@ -123,12 +116,7 @@ export async function provisionWorktreeTerminals( let setupTerminalHandle: string | null = null let shellSkippedForSetup = false try { - const setupLaunchMode = args.setup - ? (host.getSettings().setupScriptLaunchMode ?? 'new-tab') - : 'new-tab' - // A cleared creation choice leaves Setup as the only surface; splits still need a shell. - const setupOnly = - args.setup !== undefined && setupLaunchMode === 'new-tab' && args.setupOnly === true + const setupOnly = args.setup !== undefined && args.setupOnly === true const defaultHandles = await createWorktreeDefaultTabTerminals( host, args.worktreeSelector, @@ -137,7 +125,7 @@ export async function provisionWorktreeTerminals( surfacing, args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined ) - let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null + const primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null if (!args.hasStartupTerminal && !primaryHandle && setupOnly) { shellSkippedForSetup = true } @@ -146,11 +134,10 @@ export async function provisionWorktreeTerminals( shouldSeedWorktreePrimaryTerminal({ hasPrimarySurface: args.hasStartupTerminal || primaryHandle !== null, callerProvidesSurface: args.callerProvidesSurface === true, - startupNeedsTerminal: args.startupNeedsTerminal, - ...(args.setup ? { setupLaunchMode } : {}) + startupNeedsTerminal: args.startupNeedsTerminal }) ) { - primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle + await host.createTerminal(args.worktreeSelector, surfacing) } if (args.setup) { const completionToken = @@ -172,23 +159,12 @@ export async function provisionWorktreeTerminals( args.setup.shell ) const env = { ...args.setup.envVars, ...observed?.env } - const shouldSplit = - primaryHandle && - (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') - const setupTerminal = await (shouldSplit - ? host.splitTerminal(primaryHandle, { - direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', - command, - env, - activate: false, - ...surfacing - }) - : host.createTerminal(args.worktreeSelector, { - title: 'Setup', - command, - env, - ...surfacing - })) + const setupTerminal = await host.createTerminal(args.worktreeSelector, { + title: 'Setup', + command, + env, + ...surfacing + }) setupTerminalHandle = setupTerminal.handle setupSpawned = true const ptyId = host.getPtyId(setupTerminal.handle) diff --git a/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts b/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts index 6c0e3c67d5d..ed062b63bc8 100644 --- a/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts +++ b/src/main/runtime/workspaces/startup/workspace-terminal-provisioning.ts @@ -46,7 +46,6 @@ export abstract class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends canSpawn: () => Boolean(this.ptyController?.spawn), createTerminal: (selector, options) => this.createTerminal(selector, options, createdWorktree), - splitTerminal: (handle, options) => this.splitTerminal(handle, options, createdWorktree), setTabTitle: async (handle, title) => { const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) // Why not renameTerminal: it writes the pane's title, stamped to outrank every later @@ -61,7 +60,6 @@ export abstract class OrcaRuntimeWithGetWorktreeTerminalProvisioningHost extends const { worktreeId, tabId } = this.getProvisionedTerminalTab(handle) await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId, color }) }, - getSettings: () => this.requireStore().getSettings(), getPtyId: (handle) => this.getLivePtyForHandle(handle)?.pty.ptyId, recordSetupCompletionToken: (ptyId, token) => this.setupCompletionTokenByPtyId.set(ptyId, token) diff --git a/src/main/runtime/worktree-agent-first-surface.test.ts b/src/main/runtime/worktree-agent-first-surface.test.ts index 3903c8ef6c3..693138afac9 100644 --- a/src/main/runtime/worktree-agent-first-surface.test.ts +++ b/src/main/runtime/worktree-agent-first-surface.test.ts @@ -114,7 +114,7 @@ describe('host-created workspaces awaiting their agent chat', () => { observeSetupCompletion: true, activate }) - const commandCount = withDefaults ? 3 : setupScriptLaunchMode === 'new-tab' ? 1 : 2 + const commandCount = withDefaults ? 3 : 1 expect(spawn).toHaveBeenCalledTimes(commandCount) expect(spawn.mock.calls.filter(([args]) => args.command === 'pnpm dev')).toHaveLength( withDefaults ? 1 : 0 @@ -146,7 +146,7 @@ describe('host-created workspaces awaiting their agent chat', () => { ).initialSurfaceLaunch ).toBeUndefined() expect(snapshot.tabs).toHaveLength(commandCount) - if (setupScriptLaunchMode === 'new-tab' && !withDefaults) { + if (!withDefaults) { expect(snapshot.tabs[0]?.title).toBe('Setup') } } diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index 5a7869cc6f7..cb9af4f9fbb 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -158,12 +158,6 @@ vi.mock('./TerminalRenderingSection', () => ({ } })) -vi.mock('./TerminalSetupScriptSection', () => ({ - TerminalSetupScriptSection: function TerminalSetupScriptSection() { - return null - } -})) - vi.mock('@/lib/terminal-theme', () => ({ clampNumber: (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)), resolveEffectiveTerminalAppearance: () => ({ diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 05c7a3041cd..1a27632420e 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -14,8 +14,7 @@ import { getTerminalMacOptionSearchEntries, getTerminalMacYenSearchEntries, getTerminalPaneInteractionSearchEntries, - getTerminalRenderingSearchEntries, - getTerminalSetupScriptSearchEntries + getTerminalRenderingSearchEntries } from './terminal-search' import { getTerminalRightClickToPasteSearchEntry, @@ -27,7 +26,6 @@ import { ManageSessionsSection } from './ManageSessionsSection' import { TerminalAdvancedSection } from './TerminalAdvancedSection' import { TerminalInteractionSection } from './TerminalInteractionSection' import { TerminalRenderingSection } from './TerminalRenderingSection' -import { TerminalSetupScriptSection } from './TerminalSetupScriptSection' import { TerminalWindowsShellSection } from './TerminalWindowsShellSection' import { TerminalWindowsAdministratorSection } from './TerminalWindowsAdministratorSection' import { TerminalCustomShellInput } from './TerminalCustomShellInput' @@ -331,13 +329,6 @@ export function TerminalPane({ searchQuery={searchQuery} /> ) : null, - matchesSettingsSearch(searchQuery, getTerminalSetupScriptSearchEntries()) ? ( - - ) : null, matchesSettingsSearch(searchQuery, getManageSessionsSearchEntries()) ? ( ) : null, diff --git a/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx b/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx deleted file mode 100644 index 92bb6fa44a3..00000000000 --- a/src/renderer/src/components/settings/TerminalSetupScriptSection.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { SetupScriptLaunchMode } from '../../../../shared/worktree/launch-types' -import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' -import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls' -import { SearchableSetting } from './SearchableSetting' -import { translate } from '@/i18n/i18n' - -type TerminalSetupScriptSectionProps = { - settings: GlobalSettings - updateSettings: (updates: Partial) => void -} - -export function TerminalSetupScriptSection({ - settings, - updateSettings -}: TerminalSetupScriptSectionProps): React.JSX.Element { - return ( -
- - -
- - { - if (!value) { - return - } - updateSettings({ - setupScriptLaunchMode: value as SetupScriptLaunchMode - }) - }} - variant="outline" - size="sm" - className="h-8 flex-wrap" - > - - {translate('auto.components.settings.TerminalPane.1158f8fd55', 'New Tab')} - - - {translate( - 'auto.components.settings.TerminalPane.332e8a2872', - 'Split Vertically' - )} - - - {translate( - 'auto.components.settings.TerminalPane.003df129fe', - 'Split Horizontally' - )} - - - } - /> - -
-
- ) -} diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index fd0c5651e00..90dd149976f 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -29,7 +29,6 @@ import { } from './terminal-windows-search' import { getManageSessionsSearchEntries, - getTerminalSetupScriptSearchEntries, getTerminalWindowSearchEntries } from './terminal-window-setup-search' import { createLocalizedCatalog } from '@/i18n/localized-catalog' @@ -61,8 +60,7 @@ export { } from './terminal-advanced-platform-search' export { getManageSessionsSearchEntries, - getTerminalWindowSearchEntries, - getTerminalSetupScriptSearchEntries + getTerminalWindowSearchEntries } from './terminal-window-setup-search' type TerminalAppearanceSearchOptions = { @@ -179,7 +177,6 @@ export function getTerminalPaneSearchEntries(platform: { ] : []), ...getTerminalRightClickToPasteSearchEntry(), - ...getTerminalSetupScriptSearchEntries(), ...getManageSessionsSearchEntries(), ...getTerminalAdvancedSearchEntries(), ...(platform.isMac diff --git a/src/renderer/src/components/settings/terminal-window-setup-search.ts b/src/renderer/src/components/settings/terminal-window-setup-search.ts index 31877d088b1..9f7313b3cb4 100644 --- a/src/renderer/src/components/settings/terminal-window-setup-search.ts +++ b/src/renderer/src/components/settings/terminal-window-setup-search.ts @@ -131,31 +131,3 @@ export const getTerminalWindowSearchEntries = createLocalizedCatalog(() => [ ] } ]) - -export const getTerminalSetupScriptSearchEntries = createLocalizedCatalog(() => [ - { - title: translate( - 'auto.components.settings.terminal.search.5be2d67678', - 'Setup Script Location' - ), - description: translate( - 'auto.components.settings.terminal.search.2610ee3b56', - "Where the repository setup script runs when a new workspace is created: a vertical split (default), a horizontal split, or a background tab titled 'Setup'." - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.terminal.search.4529806908', 'setup'), - ...translateSearchKeyword('auto.components.settings.terminal.search.6b659fff2a', 'script'), - ...translateSearchKeyword('auto.components.settings.terminal.search.7a48c7715b', 'workspace'), - ...translateSearchKeyword('auto.components.settings.terminal.search.de7bc1d5f5', 'split'), - ...translateSearchKeyword( - 'auto.components.settings.terminal.search.54a9b3725b', - 'horizontal' - ), - ...translateSearchKeyword('auto.components.settings.terminal.search.18ce996647', 'vertical'), - ...translateSearchKeyword('auto.components.settings.terminal.search.f44643328e', 'tab'), - ...translateSearchKeyword('auto.components.settings.terminal.search.fd6c24313d', 'new'), - ...translateSearchKeyword('auto.components.settings.terminal.search.b872de3926', 'location'), - ...translateSearchKeyword('auto.components.settings.terminal.search.c047f398cc', 'launch') - ] - } -]) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts index fed96cf0aad..38cb2ef4c7c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts @@ -1,7 +1,7 @@ import { describe } from 'vitest' import { registerPtyConnectionPendingSpawnTests } from './pty-connection-pending-spawn.spec' import { registerPtyConnectionFreshSpawnGuardsTests } from './pty-connection-fresh-spawn-guards.spec' -import { registerPtyConnectionSetupSplitSpawnTests } from './pty-connection-setup-split-spawn.spec' +import { registerPtyConnectionSplitSpawnTests } from './pty-connection-split-spawn.spec' import { registerPtyConnectionRuntimeOwnerSpawnRoutingTests } from './pty-connection-runtime-owner-spawn-routing.spec' import { registerPtyConnectionStartupCommandDeliveryTests } from './pty-connection-startup-command-delivery.spec' import { registerPtyConnectionStartupDraftDeliveryTests } from './pty-connection-startup-draft-delivery.spec' @@ -10,7 +10,7 @@ import { registerPtyConnectionRemoteRuntimeAttachTests } from './pty-connection- describe('pty-connection-pending-spawn', registerPtyConnectionPendingSpawnTests) describe('pty-connection-fresh-spawn-guards', registerPtyConnectionFreshSpawnGuardsTests) -describe('pty-connection-setup-split-spawn', registerPtyConnectionSetupSplitSpawnTests) +describe('pty-connection-split-spawn', registerPtyConnectionSplitSpawnTests) describe( 'pty-connection-runtime-owner-spawn-routing', registerPtyConnectionRuntimeOwnerSpawnRoutingTests diff --git a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts similarity index 78% rename from src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts rename to src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts index fec0d47a6d0..77ab1268a2e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-setup-split-spawn.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-split-spawn.spec.ts @@ -22,7 +22,7 @@ import { restoreTerminalTestGlobals } from './pty-connection-test-environment' -export function registerPtyConnectionSetupSplitSpawnTests(): void { +export function registerPtyConnectionSplitSpawnTests(): void { const { notifyCodexPaneBoundForStaleSweep } = ptyConnectionTestMocks let mockStoreState: StoreState @@ -175,93 +175,6 @@ export function registerPtyConnectionSetupSplitSpawnTests(): void { } }) - it('waits for setup-split geometry before spawning the initial startup command', async () => { - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const runNextFrame = (): void => { - const callback = frameCallbacks.shift() - if (!callback) { - throw new Error('expected a queued animation frame') - } - callback(0) - } - - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, - ptyIdsByTabId: { 'tab-1': [] } - } - - const pane = createPane(1) - const siblingPane = createPane(2) - let panes = [pane] - let proposedGrid = { cols: 240, rows: 50 } - let splitMounted = false - const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const split = createMeasuredElement({ - className: () => (splitMounted ? 'pane-split is-vertical' : ''), - rect: () => createRect(1200, 800) - }) - const mainContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : root), - rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) - }) - const setupContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : null), - rect: () => createRect(599, 800, 601, 0) - }) - pane.container = mainContainer - siblingPane.container = setupContainer - ;( - pane.fitAddon as unknown as { - proposeDimensions: () => { cols: number; rows: number } - } - ).proposeDimensions = vi.fn(() => proposedGrid) - pane.fitAddon.fit = vi.fn(() => { - pane.terminal.cols = proposedGrid.cols - pane.terminal.rows = proposedGrid.rows - }) - - const manager = createManager(1) - manager.getPanes = vi.fn(() => panes) - connectPanePty( - pane as never, - manager as never, - createDeps({ - startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' } - }) as never - ) - - runNextFrame() - for (let i = 0; i < 8; i++) { - runNextFrame() - } - expect(transport.connect).not.toHaveBeenCalled() - - splitMounted = true - panes = [pane, siblingPane] - proposedGrid = { cols: 120, rows: 50 } - let postSplitFrames = 0 - while (frameCallbacks.length > 0 && transport.connect.mock.calls.length === 0) { - if (postSplitFrames >= 12) { - throw new Error('startup did not connect after setup split became ready') - } - postSplitFrames += 1 - runNextFrame() - } - - expect(createdTransportOptions[0]?.command).toBe('codex') - expect(transport.connect).toHaveBeenCalledWith( - expect.objectContaining({ cols: 120, rows: 50 }) - ) - }) - it.each(['before', 'during'] as const)( 'spawns a startup command without waiting for frames when the window is hidden %s the grid settle', async (hiddenWhen) => { @@ -319,20 +232,7 @@ export function registerPtyConnectionSetupSplitSpawnTests(): void { } ) - it('spawns the delayed main setup-split pane when its sibling owns the tab PTY fallback', async () => { - const frameCallbacks: FrameRequestCallback[] = [] - globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { - frameCallbacks.push(callback) - return frameCallbacks.length - }) - const runNextFrame = (): void => { - const callback = frameCallbacks.shift() - if (!callback) { - throw new Error('expected a queued animation frame') - } - callback(0) - } - + it('spawns the delayed primary pane of an issue-command split when its sibling owns the tab PTY fallback', async () => { const { connectPanePty } = await import('./pty-connection') let mainPtyId: string | null = null const mainTransport = createMockTransport() @@ -348,18 +248,18 @@ export function registerPtyConnectionSetupSplitSpawnTests(): void { onPtySpawn?.(mainPtyId) return mainPtyId }) - let setupPtyId: string | null = null - const setupTransport = createMockTransport() - setupTransport.getPtyId.mockImplementation(() => setupPtyId) - setupTransport.connect.mockImplementation(async () => { - setupPtyId = 'pty-setup' + let issuePtyId: string | null = null + const issueTransport = createMockTransport() + issueTransport.getPtyId.mockImplementation(() => issuePtyId) + issueTransport.connect.mockImplementation(async () => { + issuePtyId = 'pty-issue' const onPtySpawn = createdTransportOptions[1]?.onPtySpawn as | ((ptyId: string) => void) | undefined - onPtySpawn?.(setupPtyId) - return setupPtyId + onPtySpawn?.(issuePtyId) + return issuePtyId }) - transportFactoryQueue.push(mainTransport, setupTransport) + transportFactoryQueue.push(mainTransport, issueTransport) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, @@ -374,73 +274,54 @@ export function registerPtyConnectionSetupSplitSpawnTests(): void { }) const mainPane = createPane(1) - const setupPane = createPane(2) - let splitMounted = false - const root = createMeasuredElement({ rect: () => createRect(1200, 800) }) - const split = createMeasuredElement({ - className: () => (splitMounted ? 'pane-split is-vertical' : ''), - rect: () => createRect(1200, 800) - }) - const mainContainer = createMeasuredElement({ - parentElement: () => (splitMounted ? split : root), - rect: () => (splitMounted ? createRect(600, 800) : createRect(1200, 800)) - }) - const setupContainer = createMeasuredElement({ - parentElement: () => split, - rect: () => createRect(599, 800, 601, 0) - }) - mainPane.container = mainContainer - setupPane.container = setupContainer + const issuePane = createPane(2) const manager = createManager(2) - manager.getPanes = vi.fn(() => [mainPane, setupPane]) + manager.getPanes = vi.fn(() => [mainPane, issuePane]) + const primaryCwd = createDeferred() const sharedTransportsRef = { current: new Map() } - connectPanePty( - mainPane as never, - manager as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pane, manager and dependency doubles implement every member reached by fresh split connection. + const mainArgs = [ + mainPane, + manager, createDeps({ - startup: { command: 'codex', waitForSetupSplitDirection: 'vertical' }, + startup: { command: 'codex' }, + cwdPromise: primaryCwd.promise, + isVisibleRef: { current: false }, paneTransportsRef: sharedTransportsRef, updateTabPtyId: updateStoreTabPtyId - }) as never - ) - connectPanePty( - setupPane as never, - manager as never, + }) + ] as unknown as Parameters + connectPanePty(...mainArgs) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sibling uses the same checked fixture shape with its own command and transport. + const issueArgs = [ + issuePane, + manager, createDeps({ - startup: { command: 'bash setup-runner.sh' }, + startup: { command: 'echo issue' }, + isVisibleRef: { current: false }, paneTransportsRef: sharedTransportsRef, updateTabPtyId: updateStoreTabPtyId - }) as never - ) + }) + ] as unknown as Parameters + connectPanePty(...issueArgs) - for (let frame = 0; frame < 40 && setupTransport.connect.mock.calls.length === 0; frame++) { - runNextFrame() - } - expect(setupTransport.connect).toHaveBeenCalledTimes(1) - expect(mockStoreState.tabsByWorktree['wt-1'][0].ptyId).toBe('pty-setup') + await flushAsyncTicks(20) + expect(issueTransport.connect).toHaveBeenCalledTimes(1) + expect(mockStoreState.tabsByWorktree['wt-1'][0].ptyId).toBe('pty-issue') expect(mainTransport.connect).not.toHaveBeenCalled() - splitMounted = true - for ( - let frame = 0; - frame < 20 && - mainTransport.connect.mock.calls.length === 0 && - mainTransport.attach.mock.calls.length === 0; - frame++ - ) { - runNextFrame() - } - + primaryCwd.resolve('/workspace') + await flushAsyncTicks(20) expect(mainTransport.attach).not.toHaveBeenCalled() expect(mainTransport.connect).toHaveBeenCalledTimes(1) expect(mainTransport.connect).toHaveBeenCalledWith( expect.not.objectContaining({ sessionId: expect.any(String) }) ) expect(createdTransportOptions[0]?.command).toBe('codex') - expect(createdTransportOptions[1]?.command).toBe('bash setup-runner.sh') + expect(createdTransportOptions[1]?.command).toBe('echo issue') expect(mainPtyId).toBe('pty-main') - expect(setupPtyId).toBe('pty-setup') + expect(issuePtyId).toBe('pty-issue') }) it('does not reuse a sibling split pane pending spawn after remount', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index c43720378fd..e7ce975cce8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -10,7 +10,6 @@ import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color- import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -45,8 +44,6 @@ export type PtyPaneStartup = { initialAgentStatus?: { agent: TuiAgent; prompt: string } /** Show the restored-session banner when this startup command mounts. */ showSessionRestoredBanner?: boolean - /** Initial startup may be paired with a setup split that changes its grid. */ - waitForSetupSplitDirection?: SetupSplitDirection } | null export type PaneProcessExit = { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts index d08931290fa..b89590c7e21 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -10,8 +10,6 @@ import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/t import { waitForStableStartupGrid } from '../terminal-startup-grid-settle' import { getWindowParkVisible, subscribeWindowParkVisibility } from '@/lib/window-park-visibility' -import { isSetupSplitGeometryReady } from './setup-split-geometry' - import type { ConnectPanePtySession } from './connect-pane-pty-session' export function installPtyResizeGeometry(session: ConnectPanePtySession): void { @@ -216,15 +214,6 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { getWindowParkVisible() && !session.unservedSshTargetId && session.runtimeEnvironmentId === null - session.isStartupGridReadyForConnect = (): boolean => { - const setupSplitDirection = session.paneStartup?.waitForSetupSplitDirection - if (!setupSplitDirection) { - return true - } - // Why: the setup split reparents the main pane before its xterm grid - // necessarily reflects the new flex geometry; wait for both to agree. - return isSetupSplitGeometryReady(session.pane, session.manager, setupSplitDirection) - } session.settleStartupGridBeforeConnect = (connect: () => void): void => { session.startupGridSettleHandle?.cancel() let settled = false @@ -239,9 +228,6 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { // has settled; spawn from a briefly stable grid so the TUI paints cleanly. const handle = waitForStableStartupGrid({ isAlive: () => !session.disposed, - isReadyToSettle: session.paneStartup?.waitForSetupSplitDirection - ? session.isStartupGridReadyForConnect - : undefined, measure: session.measureStartupGrid, onSettled: finish, requestFrame: (callback) => requestAnimationFrame(callback), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts deleted file mode 100644 index fc65e447b22..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/setup-split-geometry.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' -import type { SetupSplitDirection } from '../../../../../shared/worktree/launch-types' - -const SPLIT_GEOMETRY_EPSILON_PX = 1 - -export function readElementRect(element: HTMLElement | null | undefined): DOMRect | null { - try { - return element?.getBoundingClientRect?.() ?? null - } catch { - return null - } -} - -export function hasVisibleRect(rect: DOMRect | null): rect is DOMRect { - return Boolean( - rect && rect.width > SPLIT_GEOMETRY_EPSILON_PX && rect.height > SPLIT_GEOMETRY_EPSILON_PX - ) -} - -export function readProposedPaneGrid(pane: ManagedPane): { cols: number; rows: number } | null { - try { - const dimensions = pane.fitAddon.proposeDimensions() - if (!dimensions || dimensions.cols <= 0 || dimensions.rows <= 0) { - return null - } - return dimensions - } catch { - return null - } -} - -export function isPaneGridAlignedWithFit(pane: ManagedPane): boolean { - const proposed = readProposedPaneGrid(pane) - return Boolean( - proposed && pane.terminal.cols === proposed.cols && pane.terminal.rows === proposed.rows - ) -} - -export function isSetupSplitGeometryReady( - pane: ManagedPane, - manager: PaneManager, - direction: SetupSplitDirection -): boolean { - const splitElement = pane.container.parentElement - const directionClass = direction === 'vertical' ? 'is-vertical' : 'is-horizontal' - if ( - !splitElement?.classList?.contains('pane-split') || - !splitElement.classList.contains(directionClass) - ) { - return false - } - - const sibling = manager - .getPanes() - .find( - (candidate) => candidate.id !== pane.id && candidate.container.parentElement === splitElement - ) - const splitRect = readElementRect(splitElement) - const paneRect = readElementRect(pane.container) - const siblingRect = readElementRect(sibling?.container) - if (!hasVisibleRect(splitRect) || !hasVisibleRect(paneRect) || !hasVisibleRect(siblingRect)) { - return false - } - - const splitAxis = direction === 'vertical' ? splitRect.width : splitRect.height - const paneAxis = direction === 'vertical' ? paneRect.width : paneRect.height - const siblingAxis = direction === 'vertical' ? siblingRect.width : siblingRect.height - return ( - paneAxis > SPLIT_GEOMETRY_EPSILON_PX && - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && - splitAxis - paneAxis > SPLIT_GEOMETRY_EPSILON_PX && - splitAxis - siblingAxis > SPLIT_GEOMETRY_EPSILON_PX && - isPaneGridAlignedWithFit(pane) - ) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts index b1fed8e492f..577a06778ba 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts @@ -5,7 +5,6 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-t import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { PaneExternalDropHandler, PaneExternalDropResolver, @@ -23,12 +22,6 @@ import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/sessi export type TerminalPaneStartup = Exclude -export type TerminalPaneSetupSplit = { - command: string - env?: Record - direction: SetupSplitDirection -} - export type TerminalPaneIssueCommandSplit = { command: string env?: Record @@ -39,8 +32,6 @@ export type UseTerminalPaneLifecycleDeps = { worktreeId: string cwd?: string startup?: TerminalPaneStartup | null - /** Split pane runs the setup command so the main terminal stays interactive. */ - setupSplit?: TerminalPaneSetupSplit | null /** Split pane runs the repo's issue-automation command with the issue number interpolated. */ issueCommandSplit?: TerminalPaneIssueCommandSplit | null isActive: boolean diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts new file mode 100644 index 00000000000..ce52cb96744 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record' +import type { PtyConnectionDeps } from './pty-connection-types' +import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap' + +// Only startup is read by the bootstrap; the full PTY dependency bag belongs to mounting. +function startupDeps(): PtyConnectionDeps { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: bootstrap only mutates startup and never reaches any other PTY dependency. + return { startup: { command: 'agent' } } as PtyConnectionDeps +} + +describe('issue automation bootstrap', () => { + it.each([false, true])( + 'splits once from the primary pane and preserves focus policy (%s)', + (isActive) => { + const ptyDeps = startupDeps() + const issue = { command: 'issue runner', env: { ISSUE: '42' } } + const commands: unknown[] = [] + const splitPane = vi.fn(() => { + commands.push(ptyDeps.startup) + return { id: 2 } + }) + const setActivePane = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: bootstrap only uses pane ids and these four manager methods. + const manager = { + getActivePane: () => ({ id: 1 }), + getPanes: () => [{ id: 1 }], + splitPane, + setActivePane + } as unknown as BootstrapSplitManager + runTerminalPaneBootstrapSplits({ manager, ptyDeps, issueCommandSplit: issue, isActive }) + expect(splitPane).toHaveBeenCalledExactlyOnceWith(1, 'vertical') + expect(commands).toEqual([issue]) + expect(setActivePane).toHaveBeenCalledExactlyOnceWith(1, { focus: isActive }) + expect(ptyDeps.startup).toBeNull() + manager.splitPane(1, 'horizontal') + expect(commands).toEqual([issue, null]) + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts index a6401762f4f..5a319a90c55 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts @@ -1,39 +1,17 @@ import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record' import type { PtyConnectionDeps } from './pty-connection-types' -import type { - TerminalPaneSetupSplit, - TerminalPaneIssueCommandSplit -} from './terminal-pane-lifecycle-types' +import type { TerminalPaneIssueCommandSplit } from './terminal-pane-lifecycle-types' import { splitPaneWithOneShotStartup } from './terminal-pane-lifecycle-primitives' export function runTerminalPaneBootstrapSplits(args: { manager: BootstrapSplitManager ptyDeps: PtyConnectionDeps - setupSplit: TerminalPaneSetupSplit | null | undefined issueCommandSplit: TerminalPaneIssueCommandSplit | null | undefined isActive: boolean }): void { - const { manager, ptyDeps, setupSplit, issueCommandSplit, isActive } = args - const initialPane = manager.getActivePane() ?? manager.getPanes()[0] - let issueAutomationAnchorPaneId: number | null = null - if (setupSplit && initialPane) { - const setupPane = splitPaneWithOneShotStartup( - ptyDeps, - { command: setupSplit.command, env: setupSplit.env }, - () => manager.splitPane(initialPane.id, setupSplit.direction) - ) - issueAutomationAnchorPaneId = setupPane?.id ?? null - manager.setActivePane(initialPane.id, { focus: isActive }) - } - if (!issueCommandSplit) { - return - } - let targetPane = manager.getActivePane() ?? manager.getPanes()[0] ?? null - if (issueAutomationAnchorPaneId !== null) { - targetPane = - manager.getPanes().find((pane) => pane.id === issueAutomationAnchorPaneId) ?? targetPane - } - if (!targetPane) { + const { manager, ptyDeps, issueCommandSplit, isActive } = args + const targetPane = manager.getActivePane() ?? manager.getPanes()[0] + if (!issueCommandSplit || !targetPane) { return } splitPaneWithOneShotStartup( @@ -41,7 +19,5 @@ export function runTerminalPaneBootstrapSplits(args: { { command: issueCommandSplit.command, env: issueCommandSplit.env }, () => manager.splitPane(targetPane.id, 'vertical') ) - const focusPaneId = - issueAutomationAnchorPaneId !== null ? (initialPane?.id ?? targetPane.id) : targetPane.id - manager.setActivePane(focusPaneId, { focus: isActive }) + manager.setActivePane(targetPane.id, { focus: isActive }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts index 2db53efd96d..d9baf8cfdd7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts @@ -18,7 +18,6 @@ export type TerminalPaneMountContext = { ptyDeps: PtyConnectionDeps /** Mount-local: numeric pane ids are remount-scoped, the handles are keyed by tab/leaf identity. */ deferredSplitHandoffs: Map - startupWithSetupSplitWait: PtyConnectionDeps['startup'] startupCwd: string defaultTabCwd: string worktreePath: string diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index ebc22e5fdab..82984b64c8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -47,7 +47,6 @@ export type TerminalPaneMountPreparation = { startupCwd: string terminalHomePath: string | null wslDistro: string | null - startupWithSetupSplitWait: PtyConnectionDeps['startup'] ptyDeps: PtyConnectionDeps deferredSplitHandoffs: Map unregisterRuntimeTab: () => void @@ -218,13 +217,6 @@ export function prepareTerminalPaneMount( if (hydratedInitialScrollback.hydrated) { deps.initialLayoutRef.current = hydratedInitialScrollback.layout } - const startupWithSetupSplitWait = - deps.startup && deps.setupSplit - ? { - ...deps.startup, - waitForSetupSplitDirection: deps.setupSplit.direction - } - : deps.startup // Numeric pane ids are mount-local; the handle itself is keyed by the // durable tab/leaf identity so a whole-tab remount can reclaim it. const deferredSplitHandoffs = new Map() @@ -232,7 +224,6 @@ export function prepareTerminalPaneMount( deps, refs, startupCwd, - startupWithSetupSplitWait, mountFollowsTerminalPark, restoredPtyIdByLeafId: deps.initialLayoutRef.current.ptyIdsByLeafId ?? {}, deferredSplitHandoffs @@ -265,7 +256,6 @@ export function prepareTerminalPaneMount( startupCwd, terminalHomePath, wslDistro, - startupWithSetupSplitWait, ptyDeps, deferredSplitHandoffs, unregisterRuntimeTab, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index 4e0d7bd960c..219fe2b6c3e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -40,7 +40,7 @@ export function createTerminalPaneCreatedHandler( ): (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void { // Split spawn hints let the renderer pane appear before a slow inherited-cwd lookup finishes. return (pane, spawnHints) => { - const { deps, refs, ptyDeps, startupWithSetupSplitWait, startup, osc7UncHost } = context + const { deps, refs, ptyDeps, startup, osc7UncHost } = context const manager = deps.managerRef.current if (!manager) { return @@ -170,7 +170,7 @@ export function createTerminalPaneCreatedHandler( context.applyAppearance(manager) const onQueuedStartupSpawned = createQueuedStartupConsumer( ptyDeps.startup, - startupWithSetupSplitWait, + startup, () => useAppStore.getState().consumeTabStartupCommand(deps.tabId), () => useAppStore.getState().pendingStartupByTabId[deps.tabId] === startup ) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts index f42057e694f..5af8168ca8b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts @@ -12,19 +12,11 @@ export function createTerminalPanePtyDeps(args: { deps: UseTerminalPaneLifecycleDeps refs: TerminalPaneLifecycleRefs startupCwd: string - startupWithSetupSplitWait: PtyConnectionDeps['startup'] mountFollowsTerminalPark: boolean restoredPtyIdByLeafId: Record deferredSplitHandoffs: Map }): PtyConnectionDeps { - const { - deps, - refs, - startupCwd, - startupWithSetupSplitWait, - mountFollowsTerminalPark, - deferredSplitHandoffs - } = args + const { deps, refs, startupCwd, mountFollowsTerminalPark, deferredSplitHandoffs } = args // A concrete PTY owns the input queue and settles the split admission fence, // so the deferred lookup stops being reusable. Both layout-binding variants // must run this: main routes live binds through the leaf-keyed one. @@ -43,7 +35,7 @@ export function createTerminalPanePtyDeps(args: { tabId: deps.tabId, worktreeId: deps.worktreeId, cwd: startupCwd, - startup: startupWithSetupSplitWait, + startup: deps.startup, mountFollowsTerminalPark, paneTransportsRef: deps.paneTransportsRef, paneMode2031Ref: deps.paneMode2031Ref, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx index 0ad1d80e69e..5a3f6c7eb42 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx @@ -29,7 +29,7 @@ import { const TERMINAL_PANE_LISTENER_BUDGET = 15 /** What the same mount cost before the stable-action and unified-tab folds: one listener per * bound action (each new stable action raises this by one, never the budget) plus the folded reads. */ -const PRE_FOLD_LISTENERS_PER_PANE = 45 +const PRE_FOLD_LISTENERS_PER_PANE = 44 const originalState = useAppStore.getState() @@ -105,7 +105,7 @@ describe('TerminalPane store subscription budget', () => { expect(perPane).toBe(TERMINAL_PANE_LISTENER_BUDGET) expect(perPane).toBeLessThan(PRE_FOLD_LISTENERS_PER_PANE) - // 25 stable actions, four duplicate unified-tab reads, one dead dispatch-status read, + // Stable actions, four duplicate unified-tab reads, one dead dispatch-status read, // one retired per-pane Chat UI setting read. expect(PRE_FOLD_LISTENERS_PER_PANE - perPane).toBe(TERMINAL_PANE_STORE_ACTION_KEYS.length + 6) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts index 9dc23576f0d..90007f4d9d9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.test.ts @@ -88,78 +88,6 @@ describe('waitForStableStartupGrid', () => { expect(onSettled).toHaveBeenCalledWith({ cols: 88, rows: 50 }) }) - it('ignores pre-ready stable grids until an external split gate opens', () => { - const scheduler = createFrameScheduler() - const onSettled = vi.fn() - let ready = false - const measure = vi.fn(() => (ready ? { cols: 88, rows: 50 } : { cols: 180, rows: 50 })) - - waitForStableStartupGrid({ - isAlive: () => true, - isReadyToSettle: () => ready, - measure, - onSettled, - requestFrame: scheduler.requestFrame, - cancelFrame: scheduler.cancelFrame, - minFrames: 3, - stableFrames: 2, - maxFrames: 4, - maxReadinessWaitFrames: 10 - }) - - scheduler.run(8) - expect(onSettled).not.toHaveBeenCalled() - - ready = true - scheduler.run() - - expect(onSettled).toHaveBeenCalledWith({ cols: 88, rows: 50 }) - }) - - it('stops polling at the default cap when the external split gate never opens', () => { - const scheduler = createFrameScheduler() - const onSettled = vi.fn() - const measure = vi.fn(() => ({ cols: 88, rows: 50 })) - - waitForStableStartupGrid({ - isAlive: () => true, - isReadyToSettle: () => false, - measure, - onSettled, - requestFrame: scheduler.requestFrame, - cancelFrame: scheduler.cancelFrame - }) - - expect(scheduler.run(119)).toBe(119) - expect(scheduler.pending()).toBe(1) - expect(onSettled).not.toHaveBeenCalled() - - expect(scheduler.run(10)).toBe(1) - expect(scheduler.pending()).toBe(0) - expect(measure).toHaveBeenCalledTimes(120) - expect(onSettled).toHaveBeenCalledWith({ cols: 88, rows: 50 }) - }) - - it('settles after a bounded wait when the split gate never opens', () => { - const scheduler = createFrameScheduler() - const onSettled = vi.fn() - const measure = vi.fn(() => ({ cols: 180, rows: 50 })) - - waitForStableStartupGrid({ - isAlive: () => true, - isReadyToSettle: () => false, - measure, - onSettled, - requestFrame: scheduler.requestFrame, - cancelFrame: scheduler.cancelFrame, - maxReadinessWaitFrames: 4 - }) - - expect(scheduler.run()).toBe(4) - expect(onSettled).toHaveBeenCalledWith({ cols: 180, rows: 50 }) - expect(scheduler.pending()).toBe(0) - }) - it('uses the latest usable grid at the frame cap when dimensions keep changing', () => { const scheduler = createFrameScheduler() const onSettled = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.ts b/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.ts index dc312dddc4a..2691d272b8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-grid-settle.ts @@ -5,7 +5,6 @@ export type TerminalStartupGridDimensions = { export type TerminalStartupGridSettleOptions = { isAlive: () => boolean - isReadyToSettle?: () => boolean measure: () => TerminalStartupGridDimensions | null onSettled: (dimensions: TerminalStartupGridDimensions | null) => void requestFrame: (callback: () => void) => number @@ -13,7 +12,6 @@ export type TerminalStartupGridSettleOptions = { minFrames?: number stableFrames?: number maxFrames?: number - maxReadinessWaitFrames?: number } export type TerminalStartupGridSettleHandle = { @@ -23,8 +21,6 @@ export type TerminalStartupGridSettleHandle = { const DEFAULT_MIN_FRAMES = 6 const DEFAULT_STABLE_FRAMES = 2 const DEFAULT_MAX_FRAMES = 12 -// Why: ten settle windows tolerate slow split layout without permitting permanent polling. -const DEFAULT_MAX_READINESS_WAIT_FRAMES = DEFAULT_MAX_FRAMES * 10 function usableDimensions( dimensions: TerminalStartupGridDimensions | null @@ -45,10 +41,6 @@ export function waitForStableStartupGrid( const minFrames = Math.max(1, options.minFrames ?? DEFAULT_MIN_FRAMES) const stableFrames = Math.max(1, options.stableFrames ?? DEFAULT_STABLE_FRAMES) const maxFrames = Math.max(minFrames, options.maxFrames ?? DEFAULT_MAX_FRAMES) - const maxReadinessWaitFrames = Math.max( - 1, - options.maxReadinessWaitFrames ?? DEFAULT_MAX_READINESS_WAIT_FRAMES - ) let frame = 0 let stableFrameCount = 0 let previous: TerminalStartupGridDimensions | null = null @@ -56,9 +48,6 @@ export function waitForStableStartupGrid( let observedGridChange = false let pendingFrame: number | null = null let cancelled = false - let readyFrame = 0 - let latestReadinessWaitUsable: TerminalStartupGridDimensions | null = null - const usesReadinessGate = options.isReadyToSettle !== undefined const settle = (dimensions: TerminalStartupGridDimensions | null): void => { if (cancelled) { @@ -78,26 +67,7 @@ export function waitForStableStartupGrid( } frame += 1 - // Measure before the readiness predicate: a measurement may perform the - // fit that makes a newly mounted split's grid eligible for the predicate. const measured = options.measure() - const readyToSettle = options.isReadyToSettle?.() ?? true - if (!readyToSettle) { - if (usableDimensions(measured)) { - latestReadinessWaitUsable = measured - } - if (frame >= maxReadinessWaitFrames) { - settle(latestReadinessWaitUsable) - return - } - previous = null - stableFrameCount = 0 - observedGridChange = false - pendingFrame = options.requestFrame(tick) - return - } - - readyFrame += 1 if (usableDimensions(measured)) { latestUsable = measured if (dimensionsEqual(previous, measured)) { @@ -111,13 +81,10 @@ export function waitForStableStartupGrid( stableFrameCount = 0 } - const settleFrame = usesReadinessGate ? readyFrame : frame - const heldMinimumWindow = settleFrame >= minFrames + const heldMinimumWindow = frame >= minFrames const stableAfterChange = - (observedGridChange || usesReadinessGate) && - heldMinimumWindow && - stableFrameCount >= stableFrames - if ((latestUsable && stableAfterChange) || settleFrame >= maxFrames) { + observedGridChange && heldMinimumWindow && stableFrameCount >= stableFrames + if ((latestUsable && stableAfterChange) || frame >= maxFrames) { settle(latestUsable) return } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts index fce30696a91..ba2d740c1f7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts @@ -52,7 +52,6 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr setTabPaneExpanded, settings, settingsRef, - setupSplit, showRestoredSessionBanner, startup, syncExpandedLayout, @@ -74,7 +73,6 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr worktreeId, cwd, startup, - setupSplit, issueCommandSplit, isActive, isVisible: isRendererVisible, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts index a69e629d8c2..56f91f322e1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -46,9 +46,8 @@ export function useTerminalPaneMountLifecycle( ptyDeps, deferredSplitHandoffs, // Keep the raw startup object for identity checks against the store's queued command. - // `ptyDeps.startup` may be a setup-wait copy and is consumed/mutated during bootstrap. + // `ptyDeps.startup` is consumed/mutated during bootstrap. startup: deps.startup, - startupWithSetupSplitWait: preparation.startupWithSetupSplitWait, defaultTabCwd: preparation.defaultTabCwd, startupCwd: preparation.startupCwd, worktreePath: preparation.worktreePath, @@ -92,7 +91,6 @@ export function useTerminalPaneMountLifecycle( ? recordingBootstrapSplits(manager, deps.worktreeId, deps.tabId) : manager, ptyDeps, - setupSplit: deps.setupSplit, issueCommandSplit: deps.issueCommandSplit, isActive: deps.isActive }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts index b1e577b2414..2eea4fcb752 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-startup-actions.ts @@ -30,7 +30,6 @@ import type { TerminalPaneStoreController } from './use-terminal-pane-store-bind export function useTerminalPaneStartupActions(controller: TerminalPaneStoreController) { const { consumeTabIssueCommandSplit, - consumeTabSetupSplit, consumeTabStartupCommand, containerRef, issueCommandSplit, @@ -51,7 +50,6 @@ export function useTerminalPaneStartupActions(controller: TerminalPaneStoreContr setTerminalError, setTerminalErrorsByPaneId, settings, - setupSplit, shouldMeasureHiddenStartup, startup, tabId, @@ -153,11 +151,6 @@ export function useTerminalPaneStartupActions(controller: TerminalPaneStoreContr useAppStore.getState().upsertTerminalQuickCommand(quickCommandEditorHostId, command), [quickCommandEditorHostId] ) - useEffect(() => { - if (setupSplit) { - consumeTabSetupSplit(tabId) - } - }, [setupSplit, tabId, consumeTabSetupSplit]) useEffect(() => { if (issueCommandSplit) { consumeTabIssueCommandSplit(tabId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts index 289eb35cb19..3160ab47726 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts @@ -20,7 +20,6 @@ export function useTerminalPaneStoreActions() { consumePendingCodexPaneRestart: state.consumePendingCodexPaneRestart, consumeSuppressedPtyExit: state.consumeSuppressedPtyExit, consumeTabIssueCommandSplit: state.consumeTabIssueCommandSplit, - consumeTabSetupSplit: state.consumeTabSetupSplit, consumeTabStartupCommand: state.consumeTabStartupCommand, isPtyShutdownPending: state.isPtyShutdownPending, markTerminalBell: state.markTerminalBell, @@ -52,7 +51,6 @@ export const TERMINAL_PANE_STORE_ACTION_KEYS = [ 'consumePendingCodexPaneRestart', 'consumeSuppressedPtyExit', 'consumeTabIssueCommandSplit', - 'consumeTabSetupSplit', 'consumeTabStartupCommand', 'isPtyShutdownPending', 'markTerminalBell', diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts index 387ea0a5647..079bc19d65e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts @@ -12,7 +12,6 @@ export function useTerminalPaneStoreBindings(controller: TerminalPaneChatControl clearRuntimePaneTitle, clearTabPtyId, consumeTabIssueCommandSplit, - consumeTabSetupSplit, consumeTabStartupCommand, markTerminalBell, openSpacePage, @@ -39,7 +38,6 @@ export function useTerminalPaneStoreBindings(controller: TerminalPaneChatControl const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState< Map >(() => new Map()) - const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const [issueCommandSplit] = useState( () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] ) @@ -69,8 +67,6 @@ export function useTerminalPaneStoreBindings(controller: TerminalPaneChatControl sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds, consumeTabStartupCommand, - setupSplit, - consumeTabSetupSplit, issueCommandSplit, consumeTabIssueCommandSplit } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b2c06832724..cd9a625eabd 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1544,19 +1544,29 @@ "typographyAdvanced": "Typography" }, "TerminalPane": { + "003df129fe": "Split Horizontally", "05efc0bada": "true", + "1158f8fd55": "New Tab", "219aaa59f4": "WSL Distribution", + "21f8da2078": "Workspace Setup Script", "2503f1e86b": "Used for new WSL terminal panes and local agent detection when the active workspace is not already inside WSL.", "29154326bb": "on", + "332e8a2872": "Split Vertically", "348246b06f": "false", + "34a0dfa06e": "Where the repository setup script runs when a new workspace is created.", "5936387ddd": "auto", "5fe79a5e56": "Choose which WSL distribution new WSL terminals and local agent scans use.", + "623e62df99": "Split horizontally", + "691ce810e0": "Split vertically", + "6c6a054a1c": "Run in a new tab", + "a9d47451d1": "\"New Tab\" opens the setup command in a background tab titled \"Setup\" without stealing focus.", "ab20575a8a": "off", "ab3a1f9068": "wsl.exe", "adbafefe56": "custom", "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", "cc8c5ca224": "Windows default", + "d23b43c5be": "Setup Script Location", "d78fc4fdef": "Loading distributions" }, "TerminalSettingsPreview": { @@ -1829,6 +1839,18 @@ } }, "terminal": { + "search": { + "2610ee3b56": "Where the repository setup script runs when a new workspace is created: a vertical split (default), a horizontal split, or a background tab titled 'Setup'.", + "4529806908": "setup", + "5be2d67678": "Setup Script Location", + "6b659fff2a": "script", + "7a48c7715b": "workspace", + "b872de3926": "location", + "c047f398cc": "launch", + "de7bc1d5f5": "split", + "f44643328e": "tab", + "fd6c24313d": "new" + }, "windows": { "search": { "02c772582a": "linux", diff --git a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts index d20c676bb20..908ebc5d525 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts @@ -7,7 +7,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' -import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import { makePaneKey } from '../../../shared/stable-pane-id' import { markHostSessionMirrorHydrated, diff --git a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts index 8be9580b11a..9a80aebd530 100644 --- a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts +++ b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts @@ -1,8 +1,9 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' -import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import { makePaneKey } from '../../../shared/stable-pane-id' import { hasHostSessionMirrorHydrated, @@ -91,9 +92,9 @@ function seedState(worktree: ReturnType): void } as never }, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as AppState['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), diff --git a/src/renderer/src/lib/paired-runtime-closed-tab-resume.test.ts b/src/renderer/src/lib/paired-runtime-closed-tab-resume.test.ts index e8d0fd09d5e..dd24889b548 100644 --- a/src/renderer/src/lib/paired-runtime-closed-tab-resume.test.ts +++ b/src/renderer/src/lib/paired-runtime-closed-tab-resume.test.ts @@ -14,7 +14,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r import type { Worktree } from '../../../shared/worktree/types' import { makePaneKey } from '../../../shared/stable-pane-id' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' -import { makeCreatedAgentWorktree } from './worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree } from './worktree-activation-created-agent.test-fixture' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' // #21235: a paired client is never the execution host, so no local record can tell "another client diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts index 6c8db563765..2ddd15ef67c 100644 --- a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -8,10 +8,10 @@ import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' import { activateAndRevealWorktree } from './worktree-activation' import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate' -import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent.test-fixture' /** - * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a + * Conditional old-host reconstruction, not proof of STA-9417: a host-created worktree with a * split of the (unmounted) primary tab, then the first activation of that worktree. When main's * split reveal does not reach the window, the activation sweep mints a second tab for the setup * PTY, so two saved panes own one terminal and one of them freezes. @@ -62,8 +62,7 @@ function baseState(): Partial { settings: { ...getDefaultSettings(path.join(path.sep, 'home')), agentCmdOverrides: {}, - defaultTuiAgent: 'codex', - setupScriptLaunchMode: 'split-vertical' + defaultTuiAgent: 'codex' }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), @@ -225,7 +224,7 @@ afterEach(() => { useAppStore.setState(initialState, true) }) -describe('STA-9417: setup split of an unmounted tab, then first activation', () => { +describe('old-host split of an unmounted tab, then first activation', () => { async function runScenario(revealSplit: boolean): Promise { const worktree = makeWorktree() const primaryPtyId = `${worktree.id}@@primary` @@ -242,6 +241,7 @@ describe('STA-9417: setup split of an unmounted tab, then first activation', () const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] expect(tabs.map((tab) => tab.id)).toEqual([TAB_B]) + expect(useAppStore.getState().pendingStartupByTabId).toEqual({}) expect(Object.fromEntries(ownersByPty(worktree.id))).toEqual({ [primaryPtyId]: [`${TAB_B}:${PRIMARY_LEAF}`], [setupPtyId]: [`${TAB_B}:${SETUP_LEAF}`] diff --git a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts index 47fa6113d18..3b2b9e5adf5 100644 --- a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts +++ b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts @@ -4,7 +4,7 @@ import { createMockStore, registerWorktreeActivationReset, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' registerWorktreeActivationReset() @@ -28,7 +28,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(createdTabStartups(store)['tab-1']).toEqual({ command: 'claude "Fix this bug"' }) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts b/src/renderer/src/lib/worktree-activation-created-agent.test-fixture.ts similarity index 97% rename from src/renderer/src/lib/worktree-activation-created-agent-test-state.ts rename to src/renderer/src/lib/worktree-activation-created-agent.test-fixture.ts index 6c0a50906c9..d508eb45e96 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent-test-state.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test-fixture.ts @@ -1,3 +1,4 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { vi } from 'vitest' import type { Worktree } from '../../../shared/worktree/types' @@ -58,9 +59,9 @@ function baseSeedState(worktree: Worktree, worktrees: Worktree[]): Partial { } }, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), @@ -259,9 +259,9 @@ describe('activateAndRevealWorktree', () => { tabBarOrderByWorktree: {}, pendingStartupByTabId: {}, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), @@ -309,10 +309,10 @@ describe('activateAndRevealWorktree', () => { activeTabIdByWorktree: {}, tabBarOrderByWorktree: {}, settings: { + ...getDefaultSettings(''), agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'web-runtime-1', - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + activeRuntimeEnvironmentId: 'web-runtime-1' + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), @@ -372,10 +372,10 @@ describe('activateAndRevealWorktree', () => { activeTabIdByWorktree: {}, tabBarOrderByWorktree: {}, settings: { + ...getDefaultSettings(''), agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'focused-runtime', - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + activeRuntimeEnvironmentId: 'focused-runtime' + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), @@ -437,8 +437,7 @@ describe('activateAndRevealWorktree', () => { settings: { ...getDefaultSettings('/workspace/.orca-workspaces'), agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'web-runtime-1', - setupScriptLaunchMode: 'new-tab' + activeRuntimeEnvironmentId: 'web-runtime-1' }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), @@ -587,8 +586,7 @@ describe('activateAndRevealWorktree', () => { settings: { ...getDefaultSettings('/workspace/.orca-workspaces'), agentCmdOverrides: {}, - activeRuntimeEnvironmentId: 'web-runtime-1', - setupScriptLaunchMode: 'new-tab' + activeRuntimeEnvironmentId: 'web-runtime-1' }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), diff --git a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts index 22665ff6527..c4d86793f69 100644 --- a/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts @@ -14,11 +14,11 @@ import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree -} from '@/lib/worktree-activation-created-agent-test-state' +} from '@/lib/worktree-activation-created-agent.test-fixture' import { createMockStore, registerWorktreeActivationReset -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' const defaultChat = vi.hoisted(() => ({ open: vi.fn(), diff --git a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts index e6651635319..ad37700eb25 100644 --- a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts @@ -4,7 +4,7 @@ import { createMockStore, registerWorktreeActivationReset, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' registerWorktreeActivationReset() @@ -19,7 +19,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') expect(createdTabStartups(store)).toEqual({}) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('does not recreate a terminal after the user closed the last one', () => { @@ -182,7 +181,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { expect(store.createTab).not.toHaveBeenCalled() expect(store.setActiveTab).not.toHaveBeenCalled() expect(createdTabStartups(store)).toEqual({}) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts index 13cedba66df..6425c9681b2 100644 --- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts +++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts @@ -12,7 +12,7 @@ import { toSshExecutionHostId } from '../../../shared/execution-host' import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree -} from '@/lib/worktree-activation-created-agent-test-state' +} from '@/lib/worktree-activation-created-agent.test-fixture' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' const initialAppStoreState = useAppStore.getState() diff --git a/src/renderer/src/lib/worktree-activation-issue-command.test.ts b/src/renderer/src/lib/worktree-activation-issue-command.test.ts index 07c3337910e..7e8d49e023b 100644 --- a/src/renderer/src/lib/worktree-activation-issue-command.test.ts +++ b/src/renderer/src/lib/worktree-activation-issue-command.test.ts @@ -3,9 +3,9 @@ import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-se import { createMockStore, registerWorktreeActivationReset, - setSetupScriptLaunchMode, + setLegacySetupScriptLaunchMode, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' registerWorktreeActivationReset() @@ -25,7 +25,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { pendingActivationSpawn: true }) expect(store.setActiveTab).toHaveBeenCalledWith('tab-1') - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', { command: 'bash /tmp/repo/.git/orca/issue-command-runner.sh', env: { @@ -50,8 +49,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) - it('queues both setup split and issue command split when both are provided', () => { - setSetupScriptLaunchMode('split-vertical') + it('creates a Setup tab and queues issue automation on the primary terminal', () => { + setLegacySetupScriptLaunchMode('split-vertical') const store = createMockStore() ensureWorktreeHasInitialTerminal( @@ -68,11 +67,9 @@ describe('ensureWorktreeHasInitialTerminal', () => { } ) - expect(createdTabStartups(store)).toEqual({}) - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + expect(createdTabStartups(store)['tab-2']).toEqual({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh', - env: { ORCA_ROOT_PATH: '/tmp/repo' }, - direction: 'vertical' + env: { ORCA_ROOT_PATH: '/tmp/repo' } }) expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', { command: 'bash /tmp/repo/.git/orca/issue-command-runner.sh', diff --git a/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts b/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts index 6749ba5a5dd..177391e840f 100644 --- a/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts +++ b/src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts @@ -12,8 +12,8 @@ import * as activationGate from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree -} from './worktree-activation-created-agent-test-state' -import { registerWorktreeActivationReset } from './worktree-activation-test-harness' +} from './worktree-activation-created-agent.test-fixture' +import { registerWorktreeActivationReset } from './worktree-activation.test-fixture' import { beginHostAdmittedStructuredLaunch } from './structured-agent-session-launch-admission' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' diff --git a/src/renderer/src/lib/worktree-activation-reveal.test.ts b/src/renderer/src/lib/worktree-activation-reveal.test.ts index f536936bf12..ea0afbc0a05 100644 --- a/src/renderer/src/lib/worktree-activation-reveal.test.ts +++ b/src/renderer/src/lib/worktree-activation-reveal.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { activateAndRevealWorktree } from './worktree-activation' -import { registerWorktreeActivationReset } from './worktree-activation-test-harness' +import { registerWorktreeActivationReset } from './worktree-activation.test-fixture' import { useAppStore } from '@/store' registerWorktreeActivationReset() @@ -65,7 +65,6 @@ describe('activateAndRevealWorktree', () => { setTabColor: vi.fn(), markDefaultTerminalTabsApplied: vi.fn(), queueTabInitialCwd, - queueTabSetupSplit: vi.fn(), queueTabIssueCommandSplit: vi.fn(), revealWorktreeInSidebar } as never) @@ -134,7 +133,6 @@ describe('activateAndRevealWorktree', () => { setTabColor: vi.fn(), markDefaultTerminalTabsApplied: vi.fn(), queueTabInitialCwd: vi.fn(), - queueTabSetupSplit: vi.fn(), queueTabIssueCommandSplit: vi.fn(), revealWorktreeInSidebar: vi.fn() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies only the activation slice used by the test. diff --git a/src/renderer/src/lib/worktree-activation-setup-script.test.ts b/src/renderer/src/lib/worktree-activation-setup-script.test.ts index e7f9491d5c1..a8bfced0a35 100644 --- a/src/renderer/src/lib/worktree-activation-setup-script.test.ts +++ b/src/renderer/src/lib/worktree-activation-setup-script.test.ts @@ -4,9 +4,9 @@ import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-se import { createMockStore, registerWorktreeActivationReset, - setSetupScriptLaunchMode, + setLegacySetupScriptLaunchMode, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' registerWorktreeActivationReset() @@ -37,7 +37,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1' } }) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('queues setup through returned POSIX shell metadata on native Windows paths', () => { @@ -88,7 +87,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { command: 'bash -lc wrapped-setup', env: { ORCA_ROOT_PATH: '/tmp/repo' } }) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('queues wrapped setup on an existing terminal tab when setup gates startup', () => { @@ -123,7 +121,6 @@ describe('ensureWorktreeHasInitialTerminal', () => { command: expect.stringContaining('printf') }) ) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('queues WSL setup launch commands with WSL path conversion on native Windows paths', () => { @@ -150,7 +147,7 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) it('gates startup behind setup completion when both are provided in new-tab mode', () => { - setSetupScriptLaunchMode('new-tab') + setLegacySetupScriptLaunchMode('new-tab') let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab }) @@ -192,11 +189,10 @@ describe('ensureWorktreeHasInitialTerminal', () => { command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh') }) ) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) it('starts setup and agent side by side by default', () => { - setSetupScriptLaunchMode('new-tab') + setLegacySetupScriptLaunchMode('new-tab') let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab }) @@ -220,8 +216,8 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) - it('gates startup behind setup completion when setup is a split', () => { - setSetupScriptLaunchMode('split-vertical') + it('keeps setup sequencing after retiring a saved split preference', () => { + setLegacySetupScriptLaunchMode('split-vertical') const store = createMockStore() ensureWorktreeHasInitialTerminal( @@ -242,20 +238,18 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) ) - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + expect(createdTabStartups(store)['tab-2']).toEqual({ command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'), - env: { ORCA_ROOT_PATH: '/tmp/repo' }, - direction: 'vertical' + env: { ORCA_ROOT_PATH: '/tmp/repo' } }) - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + expect(createdTabStartups(store)['tab-2']).toEqual({ command: expect.stringContaining('printf'), - env: { ORCA_ROOT_PATH: '/tmp/repo' }, - direction: 'vertical' + env: { ORCA_ROOT_PATH: '/tmp/repo' } }) }) it('keeps WSL setup shell metadata when gating startup behind setup completion', () => { - setSetupScriptLaunchMode('split-vertical') + setLegacySetupScriptLaunchMode('split-vertical') const store = createMockStore() ensureWorktreeHasInitialTerminal( @@ -279,47 +273,35 @@ describe('ensureWorktreeHasInitialTerminal', () => { }) }) ) - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { + expect(createdTabStartups(store)['tab-2']).toEqual({ command: expect.stringContaining('bash /mnt/c/repo/.git/orca/setup-runner.sh'), - env: { ORCA_ROOT_PATH: 'C:\\repo' }, - direction: 'vertical' + env: { ORCA_ROOT_PATH: 'C:\\repo' } }) }) - it('queues a vertical setup split when setupScriptLaunchMode is split-vertical', () => { - setSetupScriptLaunchMode('split-vertical') - const store = createMockStore() - - ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { ORCA_ROOT_PATH: '/tmp/repo' } - }) - - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', - env: { ORCA_ROOT_PATH: '/tmp/repo' }, - direction: 'vertical' - }) - }) - - it('queues a horizontal setup split when setupScriptLaunchMode is split-horizontal', () => { - setSetupScriptLaunchMode('split-horizontal') - const store = createMockStore() - - ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { - runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', - envVars: { ORCA_ROOT_PATH: '/tmp/repo' } - }) - - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', { - command: 'bash /tmp/repo/.git/orca/setup-runner.sh', - env: { ORCA_ROOT_PATH: '/tmp/repo' }, - direction: 'horizontal' - }) - }) + it.each(['split-vertical', 'split-horizontal', 'new-tab', null] as const)( + 'creates a dedicated Setup tab for retired location %s', + (mode) => { + setLegacySetupScriptLaunchMode(mode) + const store = createMockStore() + ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, { + runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh', + envVars: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + expect(store.createTab).toHaveBeenCalledTimes(2) + expect(createdTabStartups(store)['tab-2']).toEqual({ + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: { ORCA_ROOT_PATH: '/tmp/repo' } + }) + expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1') + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', { + recordInteraction: false + }) + } + ) it('creates a background Setup tab when setupScriptLaunchMode is new-tab', () => { - setSetupScriptLaunchMode('new-tab') + setLegacySetupScriptLaunchMode('new-tab') let createdIndex = 0 const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) const store = createMockStore({ createTab }) @@ -341,6 +323,5 @@ describe('ensureWorktreeHasInitialTerminal', () => { command: 'bash /tmp/repo/.git/orca/setup-runner.sh', env: { ORCA_ROOT_PATH: '/tmp/repo' } }) - expect(store.queueTabSetupSplit).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts index d26c131d672..18299123ae4 100644 --- a/src/renderer/src/lib/worktree-activation-store-contract.ts +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -1,7 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { Tab } from '../../../shared/tab-types' import type { TuiAgent } from '../../../shared/tui-agent' -import type { SetupSplitDirection } from '../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -47,10 +46,6 @@ export type WorktreeActivationStore = Partial & { setTabColor: (tabId: string, color: string | null) => void markDefaultTerminalTabsApplied: (worktreeId: string) => void reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number } - queueTabSetupSplit: ( - tabId: string, - startup: { command: string; env?: Record; direction: SetupSplitDirection } - ) => void queueTabIssueCommandSplit: ( tabId: string, startup: { command: string; env?: Record } diff --git a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts index 3b855050973..dae397b554b 100644 --- a/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts +++ b/src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts @@ -5,13 +5,13 @@ import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-se import { makeCreatedAgentWorktree as makeWorktree, seedEmptyActivatableWorktree -} from '@/lib/worktree-activation-created-agent-test-state' +} from '@/lib/worktree-activation-created-agent.test-fixture' import { createMockStore, registerWorktreeActivationReset, - setSetupScriptLaunchMode, + setLegacySetupScriptLaunchMode, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' const initialAppStoreState = useAppStore.getState() @@ -56,19 +56,35 @@ describe('seeding beside a caller-provided chat surface', () => { }) }) - it('still seeds a shell when setup runs as a split', () => { - let createdIndex = 0 - const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` })) - const store = createMockStore({ createTab }) - setSetupScriptLaunchMode('split-vertical') - - ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, setup, undefined, undefined, { - callerProvidesSurface: true - }) - - expect(createTab).toHaveBeenCalledTimes(1) - expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', expect.anything()) - }) + it.each(['split-vertical', 'split-horizontal', 'new-tab', null] as const)( + 'ignores retired setup location %s beside a caller-provided chat', + (mode) => { + setLegacySetupScriptLaunchMode(mode) + const store = createMockStore() + const primary = ensureWorktreeHasInitialTerminal( + store, + 'wt-1', + undefined, + setup, + undefined, + undefined, + { callerProvidesSurface: true, activateCreatedTabs: false } + ) + expect(primary).toBeNull() + expect(store.createTab).toHaveBeenCalledExactlyOnceWith('wt-1', undefined, undefined, { + recordInteraction: false, + activate: false, + pendingStartup: { + command: 'bash /tmp/repo/.git/orca/setup-runner.sh', + env: setup.envVars + } + }) + expect(store.setActiveTab).not.toHaveBeenCalled() + expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-1', 'Setup', { + recordInteraction: false + }) + } + ) it('still seeds a shell for issue automation, which splits from it', () => { let createdIndex = 0 @@ -119,4 +135,22 @@ describe('seeding beside a caller-provided chat surface', () => { expect(result === false ? 'unused' : result.primaryTabId).toBeNull() expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) }) + it('does not enqueue setup again when its consumed tab is reactivated', () => { + const worktree = makeWorktree() + seedEmptyActivatableWorktree(worktree) + activateAndRevealWorktree(worktree.id, { + providesInitialSurface: true, + notifyHostRuntime: false, + setup + }) + const state = useAppStore.getState() + const setupTab = state.tabsByWorktree[worktree.id]?.[0] + if (!setupTab) { + throw new Error('missing Setup tab') + } + expect(state.consumeTabStartupCommand(setupTab.id)?.command).toContain('setup-runner.sh') + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1) + expect(useAppStore.getState().pendingStartupByTabId).toEqual({}) + }) }) diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index c361bf4a09e..1d695fff573 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -1,17 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation' import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding' -import type { AppStoreState } from './worktree-activation-test-harness' +import type { AppStoreState } from './worktree-activation.test-fixture' import { createMockStore, registerWorktreeActivationReset, createdTabStartups -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' import { useAppStore } from '@/store' import { makeCreatedAgentWorktree, seedEmptyActivatableWorktree -} from './worktree-activation-created-agent-test-state' +} from './worktree-activation-created-agent.test-fixture' import { resetWebRuntimeWakeTerminalRespawnForTests, shouldSkipWebRuntimeWakeTerminalRespawn diff --git a/src/renderer/src/lib/worktree-activation-test-harness.ts b/src/renderer/src/lib/worktree-activation.test-fixture.ts similarity index 85% rename from src/renderer/src/lib/worktree-activation-test-harness.ts rename to src/renderer/src/lib/worktree-activation.test-fixture.ts index f7e6958dcc1..0ab2c5dbfb6 100644 --- a/src/renderer/src/lib/worktree-activation-test-harness.ts +++ b/src/renderer/src/lib/worktree-activation.test-fixture.ts @@ -1,6 +1,6 @@ import { afterEach, vi } from 'vitest' import type { Mock } from 'vitest' -import type { SetupScriptLaunchMode } from '../../../shared/worktree/launch-types' +import { getDefaultSettings } from '../../../shared/constants' import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' import { useAppStore } from '@/store' @@ -12,13 +12,11 @@ const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById const initialPendingIssueCommandSplitByTabId = useAppStore.getState().pendingIssueCommandSplitByTabId -export function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void { +export function setLegacySetupScriptLaunchMode( + mode: 'split-vertical' | 'split-horizontal' | 'new-tab' | null +): void { useAppStore.setState((state) => ({ - settings: state.settings - ? { ...state.settings, setupScriptLaunchMode: mode ?? 'new-tab' } - : mode !== null - ? ({ setupScriptLaunchMode: mode } as unknown as typeof state.settings) - : state.settings + settings: { ...(state.settings ?? getDefaultSettings('')), setupScriptLaunchMode: mode } })) } @@ -31,7 +29,7 @@ export function registerWorktreeActivationReset(): void { ? { ...state.settings, activeRuntimeEnvironmentId: null } : ({ activeRuntimeEnvironmentId: null } as unknown as typeof state.settings) })) - setSetupScriptLaunchMode('new-tab') + setLegacySetupScriptLaunchMode(null) resetHookCommandDelayedDeliveryForTests() useAppStore.setState({ tabsByWorktree: initialTabsByWorktree, @@ -53,22 +51,21 @@ export type MockActivationStore = { markDefaultTerminalTabsApplied: Mock<() => void> reconcileWorktreeTabModel: Mock<() => { renderableTabCount: number }> queueTabInitialCwd: Mock<() => void> - queueTabSetupSplit: Mock<() => void> queueTabIssueCommandSplit: Mock<() => void> } export function createMockStore(overrides: Record = {}): MockActivationStore { + let created = 0 return { tabsByWorktree: {} as Record, defaultTerminalTabsAppliedByWorktreeId: {} as Record, - createTab: vi.fn(() => ({ id: 'tab-1' })), + createTab: vi.fn(() => ({ id: `tab-${++created}` })), setActiveTab: vi.fn(), setTabCustomTitle: vi.fn(), setTabColor: vi.fn(), markDefaultTerminalTabsApplied: vi.fn(), reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), queueTabInitialCwd: vi.fn(), - queueTabSetupSplit: vi.fn(), queueTabIssueCommandSplit: vi.fn(), ...overrides } diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts index 74513cba19f..99ae44a6fc8 100644 --- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -1,3 +1,4 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' @@ -9,7 +10,7 @@ import type { import * as sleepingResume from './resume-sleeping-agent-session' import { activateAndRevealWorktree } from './worktree-activation' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' -import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent.test-fixture' const initialState = useAppStore.getState() @@ -48,10 +49,10 @@ function baseState(): Partial { agentStatusByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, settings: { + ...getDefaultSettings(''), agentCmdOverrides: {}, - defaultTuiAgent: 'codex', - setupScriptLaunchMode: 'new-tab' - } as AppState['settings'], + defaultTuiAgent: 'codex' + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), diff --git a/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts b/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts index 2ab86fba0fe..d7b6b7103c0 100644 --- a/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts +++ b/src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts @@ -20,7 +20,7 @@ import { queueWorkspaceActivationTerminalFocus } from './workspace-activation-te import { makeCreatedAgentWorktree, seedEmptyActivatableWorktree -} from './worktree-activation-created-agent-test-state' +} from './worktree-activation-created-agent.test-fixture' const initialAppStoreState = useAppStore.getState() const AGENT_TAB_ID = 'b1a1c0de-0000-4000-8000-000000000001' diff --git a/src/renderer/src/lib/worktree-creation-background-mount.test.ts b/src/renderer/src/lib/worktree-creation-background-mount.test.ts index b44e87687be..a39bd6dcac2 100644 --- a/src/renderer/src/lib/worktree-creation-background-mount.test.ts +++ b/src/renderer/src/lib/worktree-creation-background-mount.test.ts @@ -23,7 +23,7 @@ import { runBackgroundWorktreeCreation } from './worktree-creation-flow' import { makeCreatedAgentWorktree, seedEmptyActivatableWorktree -} from './worktree-activation-created-agent-test-state' +} from './worktree-activation-created-agent.test-fixture' const initialAppStoreState = useAppStore.getState() const worktree = makeCreatedAgentWorktree() @@ -117,11 +117,7 @@ describe('a create finishing after the user switched away', () => { expect(requestMount).toHaveBeenCalledWith({ worktreeId: worktree.id, tabIds: [setupTab?.id] }) }) - it('mounts a plain terminal whose only work is a queued setup split', async () => { - useAppStore.setState((current) => ({ - settings: current.settings && { ...current.settings, setupScriptLaunchMode: 'split-vertical' } - })) - + it('mounts the dedicated Setup tab beside an idle primary terminal', async () => { await createAfterUserSwitchedAway( { worktree, @@ -129,16 +125,42 @@ describe('a create finishing after the user switched away', () => { }, makeBlankTerminalRequest() ) - const next = useAppStore.getState() const tabs = next.tabsByWorktree[worktree.id] ?? [] - expect(tabs).toHaveLength(1) - const tabId = tabs[0]?.id ?? '' - expect(next.pendingStartupByTabId[tabId]).toBeUndefined() - expect(next.pendingSetupSplitByTabId[tabId]).toBeDefined() - expect(requestMount).toHaveBeenCalledWith({ worktreeId: worktree.id, tabIds: [tabId] }) + expect(tabs).toHaveLength(2) + const setupTab = tabs.find((tab) => tab.customTitle === 'Setup') + expect(setupTab).toBeDefined() + expect(requestMount).toHaveBeenCalledExactlyOnceWith({ + worktreeId: worktree.id, + tabIds: [setupTab?.id] + }) + expect(next.activeWorktreeId).toBeNull() }) + it.each(['split-vertical', 'split-horizontal', 'new-tab'] as const)( + 'keeps setup-only creation to one tab with retired location %s', + async (setupScriptLaunchMode) => { + const currentSettings = useAppStore.getState().settings + if (!currentSettings) { + throw new Error('missing creation settings') + } + const settings = { ...currentSettings, setupScriptLaunchMode } + useAppStore.setState({ settings }) + await createAfterUserSwitchedAway( + { + worktree, + setup: { runnerScriptPath: '/workspace/setup.sh', envVars: {} }, + defaultTabs: { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true } + }, + makeBlankTerminalRequest({ setupOnly: true }) + ) + const next = useAppStore.getState() + expect(next.tabsByWorktree[worktree.id]).toHaveLength(1) + expect(next.tabsByWorktree[worktree.id]?.[0]?.customTitle).toBe('Setup') + expect(next.activeWorktreeId).toBeNull() + } + ) + it('mounts a plain terminal whose only work is a queued issue-command split', async () => { await createAfterUserSwitchedAway( { worktree }, diff --git a/src/renderer/src/lib/worktree-creation-background-mount.ts b/src/renderer/src/lib/worktree-creation-background-mount.ts index 384d8a0adad..1b9410c04bd 100644 --- a/src/renderer/src/lib/worktree-creation-background-mount.ts +++ b/src/renderer/src/lib/worktree-creation-background-mount.ts @@ -13,7 +13,6 @@ export function mountCreatedWorktreeStartupTabsInBackground(worktreeId: string): .filter( (tabId) => state.pendingStartupByTabId[tabId] !== undefined || - state.pendingSetupSplitByTabId[tabId] !== undefined || state.pendingIssueCommandSplitByTabId[tabId] !== undefined ) if (tabIds.length > 0) { diff --git a/src/renderer/src/lib/worktree-creation-chat-setup.test.ts b/src/renderer/src/lib/worktree-creation-chat-setup.test.ts index c41afd2b851..a34eba10599 100644 --- a/src/renderer/src/lib/worktree-creation-chat-setup.test.ts +++ b/src/renderer/src/lib/worktree-creation-chat-setup.test.ts @@ -5,8 +5,8 @@ import { launchStructuredWorktreeSession } from './worktree-creation-structured- import { makeCreatedAgentWorktree, seedEmptyActivatableWorktree -} from './worktree-activation-created-agent-test-state' -import { registerWorktreeActivationReset } from './worktree-activation-test-harness' +} from './worktree-activation-created-agent.test-fixture' +import { registerWorktreeActivationReset } from './worktree-activation.test-fixture' import type { WorktreeCreationRequest } from './pending-worktree-creation' vi.mock('./worktree-creation-structured-session', () => ({ diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 1790e3f9c87..170212db944 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -170,10 +170,7 @@ export async function executeWorktreeCreation( // workspace) is handed it; anyone who moved on (another workspace or an app // view) keeps their place and gets a toast instead (#9944). const shouldActivateOnCompletion = isCreatedWorkspaceInView(creationId, worktree.id) - const setupIsOnlySurface = - preparedRequest.setupOnly === true && - result.setup !== undefined && - (useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab') === 'new-tab' + const setupIsOnlySurface = preparedRequest.setupOnly === true && result.setup !== undefined const defaultTabs = setupIsOnlySurface ? undefined : result.defaultTabs // Why: the worktree exists past this point and nothing awaits this caller, so diff --git a/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts b/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts index cd563d9cbe7..4346531f1e8 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts @@ -3,7 +3,7 @@ import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-se import { createMockStore, registerWorktreeActivationReset -} from './worktree-activation-test-harness' +} from './worktree-activation.test-fixture' import { CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS, MAX_CLOSED_TERMINAL_TAB_TOMBSTONES, diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts index 928b7e330cf..3fcd05c2a47 100644 --- a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -176,10 +176,7 @@ export function ensureWorktreeHasInitialTerminal( hasPrimarySurface: false, callerProvidesSurface: opts?.callerProvidesSurface === true, startupNeedsTerminal: sequencedStartup !== undefined, - issueCommandNeedsTerminal: issueCommand !== undefined, - ...(setup - ? { setupLaunchMode: useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' } - : {}) + issueCommandNeedsTerminal: issueCommand !== undefined }) && !defaultTabs?.tabs.length && opts?.createNewTerminalForStartup !== true diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index fba6d4f08d0..351d001cf04 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -1,9 +1,10 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' -import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import { makePaneKey } from '../../../shared/stable-pane-id' import type { ExecutionHostId } from '../../../shared/execution-host' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' @@ -59,9 +60,9 @@ function baseState(worktree: ReturnType): Partial ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), diff --git a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts index 03253f7220f..33fa2b55f8d 100644 --- a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts @@ -1,8 +1,9 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' -import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import { makePaneKey } from '../../../shared/stable-pane-id' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' @@ -92,9 +93,9 @@ function baseState(worktree: ReturnType): Parti } as never }, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index 9b8ce4918bf..d2ba6846221 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -1,9 +1,10 @@ +import { getDefaultSettings } from '../../../shared/constants' import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' -import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' +import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent.test-fixture' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' const initialAppStoreState = useAppStore.getState() @@ -58,9 +59,9 @@ function baseState(worktree: ReturnType): Partial agentStatusByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, settings: { - agentCmdOverrides: {}, - setupScriptLaunchMode: 'new-tab' - } as unknown as ReturnType['settings'], + ...getDefaultSettings(''), + agentCmdOverrides: {} + }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), diff --git a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts index b98f4961654..b95fd8343ea 100644 --- a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts +++ b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts @@ -1,6 +1,5 @@ import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types' import { buildSetupRunnerCommand } from './setup-runner' -import { useAppStore } from '@/store' import type { InitialTerminalOptions, WorktreeActivationStore @@ -14,16 +13,14 @@ export type IssueCommandLaunch = export function queueSetupAndIssueCommands( store: WorktreeActivationStore, worktreeId: string, - /** Null when the caller opens its own primary surface: setup still gets its own tab, but there is no shell to split from or return focus to. */ + /** Null when the caller opens its own primary surface: setup still gets its own tab, with no terminal selection to restore. */ terminalTabId: string | null, setup: WorktreeSetupLaunch | undefined, issueCommand: IssueCommandLaunch | undefined, wrappedSetupCommandStr: string | undefined, opts: InitialTerminalOptions | undefined ): void { - // Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent. if (setup) { - const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab' const setupCommand = { command: wrappedSetupCommandStr ?? @@ -31,24 +28,17 @@ export function queueSetupAndIssueCommands( buildSetupRunnerCommand(setup.runnerScriptPath, setup.shell), env: setup.envVars } - if (mode === 'new-tab') { - const setupTab = store.createTab(worktreeId, undefined, undefined, { - recordInteraction: false, - ...(opts?.activateCreatedTabs === false ? { activate: false } : {}), - pendingStartup: setupCommand - }) - // Why: createTab auto-activates the new tab; revert so focus stays on the primary terminal while Setup runs in the background. - if (opts?.activateCreatedTabs !== false && terminalTabId) { - store.setActiveTab(terminalTabId) - } - // Why: customTitle overrides the auto "Terminal N" label everywhere the tab renders, so it's the authoritative label source. - store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false }) - } else if (terminalTabId) { - store.queueTabSetupSplit(terminalTabId, { - ...setupCommand, - direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical' - }) + const setupTab = store.createTab(worktreeId, undefined, undefined, { + recordInteraction: false, + ...(opts?.activateCreatedTabs === false ? { activate: false } : {}), + pendingStartup: setupCommand + }) + // Why: createTab auto-activates the new tab; revert so focus stays on the primary terminal while Setup runs in the background. + if (opts?.activateCreatedTabs !== false && terminalTabId) { + store.setActiveTab(terminalTabId) } + // Why: customTitle overrides the auto "Terminal N" label everywhere the tab renders, so it's the authoritative label source. + store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false }) } // Why: issue automation runs in its own split, queued independently from setup so both can start in parallel (separate concerns). diff --git a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts index 54f23698e7b..47af0c7b37e 100644 --- a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts +++ b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts @@ -100,7 +100,7 @@ export function seedState(): void { ptyIdsByLeafId: { [LEAF_ID]: 'pty-host-old-2' } } }, - settings: { agentCmdOverrides: {}, setupScriptLaunchMode: 'new-tab' } + settings: { agentCmdOverrides: {} } } as never, true ) diff --git a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts index a5c88559ac5..3a5e2d8d319 100644 --- a/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts +++ b/src/renderer/src/store/slices/bulk-worktree-purge-terminal-maps-leak.test.ts @@ -11,7 +11,7 @@ * Tab-keyed (evicted via the doomed-tab set): * lastKnownRelayPtyIdByTabId, pendingReconnectPtyIdByTabId, * deferredSshSessionIdsByTabId, pendingInitialCwdByTabId, - * pendingIssueCommandSplitByTabId, pendingSetupSplitByTabId, pendingStartupByTabId + * pendingIssueCommandSplitByTabId, pendingStartupByTabId * Pty-keyed (evicted via the doomed-pty set derived from live and durable bindings): * codexRestartNoticeByPtyId, migrationUnsupportedByPtyId, * suppressedPtyExitIds, pendingCodexPaneRestartIds @@ -97,10 +97,6 @@ function seedMaps(store: ReturnType): void { [TAB1]: { command: 'a' }, [TAB2]: { command: 'b' } }, - pendingSetupSplitByTabId: { - [TAB1]: { command: 'setup-a', direction: 'vertical' }, - [TAB2]: { command: 'setup-b', direction: 'vertical' } - }, pendingStartupByTabId: { [TAB1]: { command: 'start-a' }, [TAB2]: { command: 'start-b' } }, codexRestartNoticeByPtyId: { [PTY1]: { previousAccountLabel: 'a1', nextAccountLabel: 'a2' }, @@ -153,7 +149,6 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.deferredSshSessionIdsByTabId[TAB1]).toBeUndefined() expect(s.pendingInitialCwdByTabId[TAB1]).toBeUndefined() expect(s.pendingIssueCommandSplitByTabId[TAB1]).toBeUndefined() - expect(s.pendingSetupSplitByTabId[TAB1]).toBeUndefined() expect(s.pendingStartupByTabId[TAB1]).toBeUndefined() expect(s.codexRestartNoticeByPtyId[PTY1]).toBeUndefined() expect(s.migrationUnsupportedByPtyId[PTY1]).toBeUndefined() @@ -170,7 +165,6 @@ describe('bulk worktree purge evicts the per-tab/per-pty terminal maps it previo expect(s.deferredSshSessionIdsByTabId[TAB2]).toBe('ssh-sess-2') expect(s.pendingInitialCwdByTabId[TAB2]).toBe('/path/wt2') expect(s.pendingIssueCommandSplitByTabId[TAB2]).toEqual({ command: 'b' }) - expect(s.pendingSetupSplitByTabId[TAB2]).toEqual({ command: 'setup-b', direction: 'vertical' }) expect(s.pendingStartupByTabId[TAB2]).toEqual({ command: 'start-b' }) expect(s.codexRestartNoticeByPtyId[PTY2]).toBeDefined() expect(s.migrationUnsupportedByPtyId[PTY2]).toBeDefined() diff --git a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts index 07e0565de59..9aa43cb5d97 100644 --- a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts @@ -368,7 +368,6 @@ describe('setActiveWorktree', () => { 'terminalLayoutsByTabId', 'pendingStartupByTabId', 'pendingInitialCwdByTabId', - 'pendingSetupSplitByTabId', 'pendingIssueCommandSplitByTabId', 'automaticAgentResumeClaimsByTabId', 'tabBarOrderByWorktree', diff --git a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts index 11bba797aac..4fbcacf9401 100644 --- a/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts +++ b/src/renderer/src/store/slices/store-sleep-pane-hibernation.test.ts @@ -85,7 +85,6 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { unreadTerminalPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, unreadAgentCompletionPanes: { [targetPaneKey]: true, [siblingPaneKey]: true }, lastTerminalInputAtByPaneKey: { [targetPaneKey]: 1000, [siblingPaneKey]: 1100 }, - pendingSetupSplitByTabId: { 'tab-1': { command: 'setup', direction: 'horizontal' } }, pendingIssueCommandSplitByTabId: { 'tab-1': { command: 'issue' } } }) store.setState({ dropAgentStatusByWorktree: dropByWorktree as never }) @@ -152,7 +151,6 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { expect(state.unreadAgentCompletionPanes[siblingPaneKey]).toBe(true) expect(state.lastTerminalInputAtByPaneKey[targetPaneKey]).toBeUndefined() expect(state.lastTerminalInputAtByPaneKey[siblingPaneKey]).toBe(1100) - expect(state.pendingSetupSplitByTabId['tab-1']).toBeDefined() expect(state.pendingIssueCommandSplitByTabId['tab-1']).toBeDefined() expect(dropByWorktree).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts index 30ba7be1633..0faa7ea5fc1 100644 --- a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts +++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts @@ -230,7 +230,6 @@ export const RECONCILIATION_WRITABLE_KEYS = [ 'terminalLayoutsByTabId', 'pendingStartupByTabId', 'pendingInitialCwdByTabId', - 'pendingSetupSplitByTabId', 'pendingIssueCommandSplitByTabId', 'automaticAgentResumeClaimsByTabId', 'nativeChatLaunchPromptByTabId', diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index 64d5c71b12d..4dd0db48885 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -45,7 +45,6 @@ type OrphanTerminalCleanupState = Pick< | 'terminalLayoutsByTabId' | 'pendingStartupByTabId' | 'pendingInitialCwdByTabId' - | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' @@ -103,7 +102,6 @@ export function buildOrphanTerminalCleanupPatch( | 'terminalLayoutsByTabId' | 'pendingStartupByTabId' | 'pendingInitialCwdByTabId' - | 'pendingSetupSplitByTabId' | 'pendingIssueCommandSplitByTabId' | 'automaticAgentResumeClaimsByTabId' | 'nativeChatLaunchPromptByTabId' @@ -123,7 +121,6 @@ export function buildOrphanTerminalCleanupPatch( terminalLayoutsByTabId: state.terminalLayoutsByTabId, pendingStartupByTabId: state.pendingStartupByTabId, pendingInitialCwdByTabId: state.pendingInitialCwdByTabId, - pendingSetupSplitByTabId: state.pendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: state.automaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: state.nativeChatLaunchPromptByTabId, @@ -145,7 +142,6 @@ export function buildOrphanTerminalCleanupPatch( const nextTerminalLayoutsByTabId = { ...state.terminalLayoutsByTabId } const nextPendingStartupByTabId = { ...state.pendingStartupByTabId } const nextPendingInitialCwdByTabId = { ...state.pendingInitialCwdByTabId } - const nextPendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId } const nextPendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId } const nextAutomaticAgentResumeClaimsByTabId = { ...state.automaticAgentResumeClaimsByTabId @@ -172,7 +168,6 @@ export function buildOrphanTerminalCleanupPatch( delete nextTerminalLayoutsByTabId[orphanTabId] delete nextPendingStartupByTabId[orphanTabId] delete nextPendingInitialCwdByTabId[orphanTabId] - delete nextPendingSetupSplitByTabId[orphanTabId] delete nextPendingIssueCommandSplitByTabId[orphanTabId] delete nextAutomaticAgentResumeClaimsByTabId[orphanTabId] delete nextNativeChatLaunchPromptByTabId[orphanTabId] @@ -203,7 +198,6 @@ export function buildOrphanTerminalCleanupPatch( terminalLayoutsByTabId: nextTerminalLayoutsByTabId, pendingStartupByTabId: nextPendingStartupByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index f954184a6d5..2e9af111743 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -39,7 +39,6 @@ export const createTerminalSlice: StateCreator localOnlyScrollbackByTabId: {}, pendingStartupByTabId: {}, pendingInitialCwdByTabId: {}, - pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, automaticAgentResumeClaimsByTabId: {}, nativeChatLaunchPromptByTabId: {}, diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 53672d2725e..e8c8ca7def4 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -173,7 +173,6 @@ export function buildWorktreePurgeState( deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId), pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId), pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId), - pendingSetupSplitByTabId: omitByTabId(s.pendingSetupSplitByTabId), pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId), codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId), migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId), diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 8f059e368b7..c1f9ea95078 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -4,7 +4,6 @@ import type { TabSplitTarget } from '../slices/tabs/tabs-slice-contract' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -236,19 +235,6 @@ export type TerminalActions = { showSessionRestoredBanner?: boolean telemetry?: AgentStartedTelemetry } | null - queueTabSetupSplit: ( - tabId: string, - startup: { - command: string - env?: Record - direction: SetupSplitDirection - } - ) => void - consumeTabSetupSplit: (tabId: string) => { - command: string - env?: Record - direction: SetupSplitDirection - } | null queueTabIssueCommandSplit: ( tabId: string, issueCommand: { diff --git a/src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts b/src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts index 1b6cff948f1..541d3e05352 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-map-identity.test.ts @@ -15,7 +15,6 @@ const UNTOUCHED_FIELDS = [ 'pendingPtyShutdownIds', 'pendingCodexPaneRestartIds', 'codexRestartNoticeByPtyId', - 'pendingSetupSplitByTabId', 'pendingIssueCommandSplitByTabId', 'terminalLayoutsByTabId', 'runtimePaneTitlesByTabId', @@ -31,7 +30,6 @@ function buildState(overrides: Partial = {}): AppState { pendingPtyShutdownIds: {}, pendingCodexPaneRestartIds: {}, codexRestartNoticeByPtyId: {}, - pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index b5ea726d3cf..64378b58caa 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -88,7 +88,6 @@ export function commitTerminalShutdownState({ } const runtimePaneTitlesByTabId = copyOnWriteRecord(state.runtimePaneTitlesByTabId) - const pendingSetupSplitByTabId = copyOnWriteRecord(state.pendingSetupSplitByTabId) const pendingIssueCommandSplitByTabId = copyOnWriteRecord( state.pendingIssueCommandSplitByTabId ) @@ -97,7 +96,6 @@ export function commitTerminalShutdownState({ const lastTerminalInputAtByPaneKey = copyOnWriteRecord(state.lastTerminalInputAtByPaneKey) for (const tab of tabs) { - pendingSetupSplitByTabId.delete(tab.id) pendingIssueCommandSplitByTabId.delete(tab.id) const panePrefix = `${tab.id}:` for (const paneKey of Object.keys(state.lastTerminalInputAtByPaneKey)) { @@ -126,7 +124,6 @@ export function commitTerminalShutdownState({ pendingPtyShutdownIds: pendingPtyShutdownIds.read(), pendingCodexPaneRestartIds: pendingCodexPaneRestartIds.read(), codexRestartNoticeByPtyId: codexRestartNoticeByPtyId.read(), - pendingSetupSplitByTabId: pendingSetupSplitByTabId.read(), pendingIssueCommandSplitByTabId: pendingIssueCommandSplitByTabId.read(), terminalLayoutsByTabId: terminalLayoutsByTabId.read(), ...removeTabsAttention( diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index af56050ef23..87597397918 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -10,8 +10,6 @@ export function createTerminalStartupQueueActions( | 'queueTabInitialCwd' | 'consumeTabInitialCwd' | 'consumeTabStartupCommand' - | 'queueTabSetupSplit' - | 'consumeTabSetupSplit' | 'queueTabIssueCommandSplit' | 'consumeTabIssueCommandSplit' | 'consumePendingSnapshot' @@ -70,26 +68,6 @@ export function createTerminalStartupQueueActions( }) return pending }, - queueTabSetupSplit: (tabId, startup) => { - set((s) => ({ - pendingSetupSplitByTabId: { - ...s.pendingSetupSplitByTabId, - [tabId]: startup - } - })) - }, - consumeTabSetupSplit: (tabId) => { - const pending = get().pendingSetupSplitByTabId[tabId] - if (!pending) { - return null - } - set((s) => { - const next = { ...s.pendingSetupSplitByTabId } - delete next[tabId] - return { pendingSetupSplitByTabId: next } - }) - return pending - }, queueTabIssueCommandSplit: (tabId, issueCommand) => { set((s) => ({ pendingIssueCommandSplitByTabId: { diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 9cd6c9ae53f..32314533d6e 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -1,7 +1,6 @@ import type { ClosedTerminalTabTombstonesByTabId } from '../../../../shared/closed-terminal-tab-tombstones' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -67,14 +66,6 @@ export type TerminalState = { } > pendingInitialCwdByTabId: Record - pendingSetupSplitByTabId: Record< - string, - { - command: string - env?: Record - direction: SetupSplitDirection - } - > pendingIssueCommandSplitByTabId: Record< string, { diff --git a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts index 171929962dc..ff6b8eaa8fc 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts @@ -125,7 +125,6 @@ vi.mock('@/lib/agent-status', async (importOriginal) => ({ 'nativeChatLaunchPromptByTabId', 'nativeChatLaunchDraftByTabId', 'pendingInitialCwdByTabId', - 'pendingSetupSplitByTabId', 'pendingIssueCommandSplitByTabId', 'expandedPaneByTabId', 'canExpandPaneByTabId', diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 4bd16af3d46..a4bffc99303 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -153,7 +153,6 @@ export function createTerminalTabCloseActions( const nextNativeChatLaunchPromptByTabId = omitByTabId(s.nativeChatLaunchPromptByTabId) const nextNativeChatLaunchDraftByTabId = omitByTabId(s.nativeChatLaunchDraftByTabId) const nextPendingInitialCwdByTabId = omitByTabId(s.pendingInitialCwdByTabId) - const nextPendingSetupSplitByTabId = omitByTabId(s.pendingSetupSplitByTabId) const nextPendingIssueCommandSplitByTabId = omitByTabId(s.pendingIssueCommandSplitByTabId) // Why: cache timer keys are `${tabId}:${leafId}` composites; remove all entries for the closing tab. const nextCacheTimer = removePaneKeysByTabPrefix(s.cacheTimerByKey, tabId) @@ -228,7 +227,6 @@ export function createTerminalTabCloseActions( nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, - pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, cacheTimerByKey: nextCacheTimer, tabBarOrderByWorktree: nextTabBarOrderByWorktree, diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 9a71e4342ac..c3f17692483 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -121,7 +121,6 @@ export function buildDefaultSettings(args: { terminalAllowOsc52Clipboard: true, terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, claudeAgentTeamsMode: 'off', - setupScriptLaunchMode: 'new-tab', terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, httpProxyUrl: '', httpProxyBypassRules: '', diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 5794026f9fc..69b7194459a 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -42,7 +42,6 @@ import type { SourceControlViewMode, TaskViewPresetId } from './ui-chrome-types' -import type { SetupScriptLaunchMode } from './worktree/launch-types' import type { WorktreeVisibilityDefaults } from './repo-types' export type { WorktreeVisibilityDefaults } from './repo-types' @@ -201,8 +200,6 @@ export type GlobalSettings = GlobalSettingsBase & { terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ claudeAgentTeamsMode?: ClaudeAgentTeamsMode - /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ - setupScriptLaunchMode: SetupScriptLaunchMode terminalScrollbackRows: number /** Optional app-level proxy for Electron networking and local PTYs; empty preserves system/inherited proxy env. */ httpProxyUrl?: string diff --git a/src/shared/worktree-first-surface.ts b/src/shared/worktree-first-surface.ts index 60943fa112e..2a086aa056a 100644 --- a/src/shared/worktree-first-surface.ts +++ b/src/shared/worktree-first-surface.ts @@ -1,19 +1,14 @@ -import type { GlobalSettings } from './global-settings-types' - /** Creation intent belongs to this call, never to persisted agent provenance. */ export function shouldSeedWorktreePrimaryTerminal(args: { hasPrimarySurface: boolean callerProvidesSurface: boolean startupNeedsTerminal?: boolean issueCommandNeedsTerminal?: boolean - setupLaunchMode?: GlobalSettings['setupScriptLaunchMode'] }): boolean { return ( !args.hasPrimarySurface && (!args.callerProvidesSurface || args.startupNeedsTerminal === true || - args.issueCommandNeedsTerminal === true || - args.setupLaunchMode === 'split-horizontal' || - args.setupLaunchMode === 'split-vertical') + args.issueCommandNeedsTerminal === true) ) } diff --git a/src/shared/worktree/launch-types.ts b/src/shared/worktree/launch-types.ts index 5fc9f18d397..fb140b4e0ab 100644 --- a/src/shared/worktree/launch-types.ts +++ b/src/shared/worktree/launch-types.ts @@ -28,12 +28,3 @@ export type WorktreeDefaultTabsLaunch = { tabs: OrcaDefaultTabTemplate[] runCommands: boolean } - -/** Where the repo setup script runs when a worktree is created. - * - 'new-tab': open a background tab titled "Setup" and leave focus on the first tab (default). - * - 'split-vertical': split the initial terminal pane with a vertical divider. - * - 'split-horizontal': split the initial terminal pane with a horizontal divider. */ -export type SetupScriptLaunchMode = 'split-vertical' | 'split-horizontal' | 'new-tab' - -/** Direction used when the setup script launch mode is a split. */ -export type SetupSplitDirection = 'vertical' | 'horizontal' diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts index 0fe9e5953b4..2478db8292b 100644 --- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -1,3 +1,4 @@ +import { getDefaultSettings } from '../../src/shared/constants' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume' @@ -14,7 +15,7 @@ import { resolveLegacyWorkerTerminalRecoveryAction, rollbackLegacyWorkerTerminalSurfaceInStore } from '@/hooks/legacy-worker-terminal-recovery-event' -import { useAppStore, type AppState } from '@/store' +import { useAppStore } from '@/store' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' @@ -155,16 +156,16 @@ function seedWorkspace(options: { helper?: boolean } = {}): void { sleepingAgentSessionsByPaneKey: {}, everActivatedWorktreeIds: new Set([CANARY_WORKTREE_ID]), settings: { + ...getDefaultSettings(''), ...initialAppStoreState.settings, agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' }, - setupScriptLaunchMode: 'new-tab' + agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' } }, markWorktreeVisited: vi.fn(), recordWorktreeVisit: vi.fn(), refreshGitHubForWorktreeIfStale: vi.fn(), revealWorktreeInSidebar: vi.fn() - } as Partial) + }) } function recordWorkingWorker() { diff --git a/tests/e2e/dead-terminal-repro.spec.ts b/tests/e2e/dead-terminal-repro.spec.ts index cd033492922..3f84bcf828d 100644 --- a/tests/e2e/dead-terminal-repro.spec.ts +++ b/tests/e2e/dead-terminal-repro.spec.ts @@ -1,5 +1,5 @@ /** - * Stress test for dead-terminal reproduction (setup-split flow). + * Stress test for dead-terminal reproduction (manual-split flow). * * Why @headful: the dead-terminal bug is a WebGL canvas staleness issue — after * wrapInSplit() reparents the existing pane's container, the WebGL canvas can @@ -22,7 +22,7 @@ import { } from './helpers/store' import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' import { - createAndActivateWorktreeWithSetup, + createAndActivateWorktreeWithManualSplit, removeWorktreeViaStore, waitForAllPanesToHaveContent, checkWebglState @@ -37,14 +37,6 @@ test.describe('Dead Terminal Reproduction @headful', () => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) - - await orcaPage.evaluate(async () => { - const state = window.__store?.getState() - if (!state) { - return - } - state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) - }) }) test.afterEach(async ({ orcaPage }) => { @@ -54,7 +46,7 @@ test.describe('Dead Terminal Reproduction @headful', () => { createdWorktreeIds.length = 0 }) - test('@headful setup-split flow does not produce dead terminals', async ({ orcaPage }) => { + test('@headful manual-split flow does not produce dead terminals', async ({ orcaPage }) => { test.setTimeout(120_000) const homeWorktreeId = await waitForActiveWorktree(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) @@ -62,7 +54,11 @@ test.describe('Dead Terminal Reproduction @headful', () => { for (let i = 0; i < STRESS_ITERATIONS; i++) { const direction = i % 2 === 0 ? 'vertical' : 'horizontal' - const newId = await createAndActivateWorktreeWithSetup(orcaPage, `setup-${i}`, direction) + const newId = await createAndActivateWorktreeWithManualSplit( + orcaPage, + `setup-${i}`, + direction + ) createdWorktreeIds.push(newId) await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) @@ -81,13 +77,13 @@ test.describe('Dead Terminal Reproduction @headful', () => { } }) - test('@headful setup-split then switch-back does not leave panes dead', async ({ orcaPage }) => { + test('@headful manual-split then switch-back does not leave panes dead', async ({ orcaPage }) => { test.setTimeout(120_000) const homeWorktreeId = await waitForActiveWorktree(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) for (let i = 0; i < STRESS_ITERATIONS; i++) { - const newId = await createAndActivateWorktreeWithSetup( + const newId = await createAndActivateWorktreeWithManualSplit( orcaPage, `switchback-${i}`, 'vertical' @@ -122,14 +118,18 @@ test.describe('Dead Terminal Reproduction @headful', () => { } }) - test('@headful rapid switching between many setup-split worktrees', async ({ orcaPage }) => { + test('@headful rapid switching between many manual-split worktrees', async ({ orcaPage }) => { test.setTimeout(120_000) const homeWorktreeId = await waitForActiveWorktree(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) const worktreeIds = [homeWorktreeId] for (let i = 0; i < 4; i++) { - const newId = await createAndActivateWorktreeWithSetup(orcaPage, `multi-${i}`, 'vertical') + const newId = await createAndActivateWorktreeWithManualSplit( + orcaPage, + `multi-${i}`, + 'vertical' + ) createdWorktreeIds.push(newId) worktreeIds.push(newId) diff --git a/tests/e2e/dead-terminal-stress.spec.ts b/tests/e2e/dead-terminal-stress.spec.ts index cd03cf231d6..18640256b7c 100644 --- a/tests/e2e/dead-terminal-stress.spec.ts +++ b/tests/e2e/dead-terminal-stress.spec.ts @@ -1,7 +1,7 @@ /** * Aggressive stress tests for dead-terminal reproduction. * - * These tests target specific failure vectors beyond the basic setup-split flow: + * These tests target specific failure vectors beyond the basic manual-split flow: * - Forced WebGL context loss (simulating Chromium memory pressure) * - Rapid switching during the ~200ms scheduleSplitScrollRestore window * @@ -18,7 +18,7 @@ import { } from './helpers/store' import { waitForActiveTerminalManager, waitForPaneCount } from './helpers/terminal' import { - createAndActivateWorktreeWithSetup, + createAndActivateWorktreeWithManualSplit, removeWorktreeViaStore, waitForAllPanesToHaveContent } from './helpers/dead-terminal' @@ -32,14 +32,6 @@ test.describe('Dead Terminal Stress @headful', () => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) - - await orcaPage.evaluate(async () => { - const state = window.__store?.getState() - if (!state) { - return - } - state.updateSettings({ setupScriptLaunchMode: 'split-vertical' }) - }) }) test.afterEach(async ({ orcaPage }) => { @@ -55,13 +47,17 @@ test.describe('Dead Terminal Stress @headful', () => { * pressure — especially with many worktrees open. The recovery path is: * onContextLoss → dispose WebGL → DOM fallback → rAF → fit + refresh. */ - test('@headful setup-split with forced WebGL context loss recovers', async ({ orcaPage }) => { + test('@headful manual-split with forced WebGL context loss recovers', async ({ orcaPage }) => { test.setTimeout(120_000) const homeWorktreeId = await waitForActiveWorktree(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) for (let i = 0; i < STRESS_ITERATIONS; i++) { - const newId = await createAndActivateWorktreeWithSetup(orcaPage, `ctxloss-${i}`, 'vertical') + const newId = await createAndActivateWorktreeWithManualSplit( + orcaPage, + `ctxloss-${i}`, + 'vertical' + ) createdWorktreeIds.push(newId) await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 10_000 }).toBe(newId) @@ -107,13 +103,17 @@ test.describe('Dead Terminal Stress @headful', () => { * race between wrapInSplit() reparenting, WebGL context creation during * resumeRendering(), and the scheduleSplitScrollRestore 200ms timer. */ - test('@headful rapid worktree switching during setup-split lifecycle', async ({ orcaPage }) => { + test('@headful rapid worktree switching during manual-split lifecycle', async ({ orcaPage }) => { test.setTimeout(120_000) const homeWorktreeId = await waitForActiveWorktree(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) for (let i = 0; i < 3; i++) { - const newId = await createAndActivateWorktreeWithSetup(orcaPage, `rapid-${i}`, 'vertical') + const newId = await createAndActivateWorktreeWithManualSplit( + orcaPage, + `rapid-${i}`, + 'vertical' + ) createdWorktreeIds.push(newId) // Switch away during the ~200ms scheduleSplitScrollRestore window diff --git a/tests/e2e/helpers/dead-terminal.ts b/tests/e2e/helpers/dead-terminal.ts index 2256124b77d..1e9d79950b0 100644 --- a/tests/e2e/helpers/dead-terminal.ts +++ b/tests/e2e/helpers/dead-terminal.ts @@ -1,126 +1,45 @@ -/** - * Shared helpers for dead-terminal reproduction tests. - * - * These helpers exercise the real production worktree-creation-with-setup-split - * flow and verify that terminal panes render content after the split. - */ - +/** Regression fixtures for manual split rendering, switching and WebGL recovery. */ import { expect } from '@playwright/test' -import { execFileSync } from 'node:child_process' -import { writeFileSync } from 'node:fs' -import path from 'node:path' import type { getActiveWorktreeId } from './store' +import { splitActiveTerminalPane, waitForActiveTerminalManager } from './terminal' type TestPage = Parameters[0] -const SETUP_HOOK_CONTENT = 'scripts:\n setup: echo SETUP_COMPLETE\n' - -async function ensureSetupHookCommitted(page: TestPage): Promise { - const activeWorktreePath = await page.evaluate(() => { - const state = window.__store?.getState() - if (!state?.activeWorktreeId) { - throw new Error('No active worktree') - } - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((wt) => wt.id === state.activeWorktreeId) - if (!activeWorktree) { - throw new Error('Active worktree not found in store') - } - return activeWorktree.path - }) - - writeFileSync(path.join(activeWorktreePath, 'orca.yaml'), SETUP_HOOK_CONTENT, 'utf-8') - execFileSync('git', ['add', 'orca.yaml'], { cwd: activeWorktreePath, stdio: 'pipe' }) - - try { - execFileSync('git', ['diff', '--cached', '--quiet'], { cwd: activeWorktreePath, stdio: 'pipe' }) - } catch { - // Why: worktree creation reads orca.yaml from the created worktree, so the - // temp repo must commit this hook before `git worktree add` copies it. - execFileSync('git', ['commit', '-m', 'Add e2e setup hook'], { - cwd: activeWorktreePath, - stdio: 'pipe' - }) - } -} - -/** - * Create a worktree with setup via the real IPC flow, then activate it - * replicating the exact activateAndRevealWorktree + ensureWorktreeHasInitialTerminal - * path. The setup split is queued on the tab BEFORE the TerminalPane mounts, - * matching production timing. - */ -export async function createAndActivateWorktreeWithSetup( +export async function createAndActivateWorktreeWithManualSplit( page: TestPage, suffix: string, direction: 'vertical' | 'horizontal' ): Promise { - await ensureSetupHookCommitted(page) - const name = `e2e-dead-term-${suffix}-${Date.now()}` - return page.evaluate( - async ({ worktreeName, direction }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - const activeWorktreeId = state.activeWorktreeId - if (!activeWorktreeId) { - throw new Error('No active worktree') - } - - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((wt) => wt.id === activeWorktreeId) - if (!activeWorktree) { - throw new Error('Active worktree not found in store') - } - - const result = await state.createWorktree( - activeWorktree.repoId, - worktreeName, - undefined, - 'run' - ) - await state.fetchWorktrees(activeWorktree.repoId) - const worktreeId = result.worktree.id - - if (activeWorktree.repoId !== state.activeRepoId) { - state.setActiveRepo(activeWorktree.repoId) - } - if (store.getState().activeView !== 'terminal') { - state.setActiveView('terminal') - } - state.setActiveWorktree(worktreeId) - - const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId) - if (renderableTabCount > 0) { - return worktreeId - } - - const tab = state.createTab(worktreeId, undefined, undefined, { + const worktreeId = await page.evaluate(async (worktreeName) => { + const store = window.__store + const state = store?.getState() + const activeWorktree = state?.getKnownWorktreeById(state.activeWorktreeId ?? '') + if (!store || !state || !activeWorktree) { + throw new Error('No active worktree') + } + const result = await state.createWorktree( + activeWorktree.repoId, + worktreeName, + undefined, + 'skip' + ) + await state.fetchWorktrees(activeWorktree.repoId) + state.setActiveRepo(activeWorktree.repoId) + state.setActiveView('terminal') + state.setActiveWorktree(result.worktree.id) + if (state.reconcileWorktreeTabModel(result.worktree.id).renderableTabCount === 0) { + const tab = state.createTab(result.worktree.id, undefined, undefined, { pendingActivationSpawn: true }) state.setActiveTab(tab.id) - - if (result.setup) { - const runnerPath = result.setup.runnerScriptPath - const command = `bash ${runnerPath}` - state.queueTabSetupSplit(tab.id, { - command, - env: result.setup.envVars, - direction - }) - } - - state.revealWorktreeInSidebar(worktreeId) - return worktreeId - }, - { worktreeName: name, direction } - ) + } + state.revealWorktreeInSidebar(result.worktree.id) + return result.worktree.id + }, name) + await waitForActiveTerminalManager(page) + await splitActiveTerminalPane(page, direction) + return worktreeId } export async function removeWorktreeViaStore(page: TestPage, worktreeId: string): Promise { diff --git a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts b/tests/e2e/helpers/host-split-and-sleep-journeys.ts similarity index 72% rename from tests/e2e/helpers/setup-split-and-sleep-journeys.ts rename to tests/e2e/helpers/host-split-and-sleep-journeys.ts index 9e120a44c57..71594c85294 100644 --- a/tests/e2e/helpers/setup-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/host-split-and-sleep-journeys.ts @@ -1,5 +1,5 @@ /** - * Journeys ported from the closed layout-core branch: STA-9417's host-created setup split, and an + * Host-created manual split restore, and an * agent pane slept through the sidebar so quit and relaunch resume it. */ @@ -18,38 +18,8 @@ import { splitActiveTerminalPane, waitForActiveTerminalManager } from './termina import { waitForBoundPanes, type FolderWorkspaceSetup } from './terminal-layout-journeys' import { bootstrapFirstLaunch, seededRepoPathOrSkip } from './terminal-restart-persistence' -/** - * Main reveals the split to the window only if the window's runtime graph already lists the first - * terminal. The natural repro lost that race; an idle e2e window wins it in ~80 ms, so hold the - * window busy from just after the first reveal until well past main's split. - */ -async function stallRendererOnFirstSetupTab(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store! - const unsubscribe = store.subscribe((state) => { - const revealed = Object.entries(state.tabsByWorktree).some( - ([worktreeId, tabs]) => worktreeId.endsWith('parity-setup') && (tabs ?? []).length > 0 - ) - if (!revealed) { - return - } - unsubscribe() - // After the reveal's own task, before the graph sync it scheduled. - setTimeout(() => { - const until = performance.now() + 3_000 - while (performance.now() < until) { - // Busy, as a loaded renderer is: no timer or IPC runs. - } - }, 0) - }) - }) -} - -/** - * STA-9417's shape: the CLI creates a worktree whose setup script runs in a split of its first - * terminal, with the window open on another worktree; the user then opens it for the first time. - */ -export async function addHostCreatedSetupSplitWorktree( +/** Create and split a host terminal, then leave it for the reactivation layout check. */ +export async function addHostCreatedSplitWorktree( page: Page, userDataDir: string ): Promise { @@ -61,13 +31,9 @@ export async function addHostCreatedSetupSplitWorktree( git('init', '-b', 'main') git('config', 'user.email', 'e2e@test.local') git('config', 'user.name', 'E2E Test') - writeFileSync(path.join(repoPath, 'orca.yaml'), 'scripts:\n setup: echo SETUP_COMPLETE\n') + writeFileSync(path.join(repoPath, 'README.md'), 'Host split fixture\n') git('add', '-A') git('commit', '-m', 'setup hook') - await page.evaluate(() => - window.__store!.getState().updateSettings({ setupScriptLaunchMode: 'split-vertical' }) - ) - await stallRendererOnFirstSetupTab(page) const client = new RuntimeClient(userDataDir, 30_000) const added = await client.call<{ repo: { id: string } }>('repo.add', { path: repoPath, @@ -78,8 +44,17 @@ export async function addHostCreatedSetupSplitWorktree( name: 'parity-setup', noParent: true, activate: false, - setupDecision: 'run' + setupDecision: 'skip' }) + const terminals = await client.call('terminal.list', { + worktree: `id:${created.result.worktree.id}` + }) + const primary = terminals.result.terminals[0] + if (!primary) { + throw new Error('Host did not create the primary terminal') + } + await client.call('terminal.split', { terminal: primary.handle, direction: 'vertical' }) + await activateWorkspaceByClick(page, worktreeId) const worktreePath = created.result.worktree.path return { worktreeId, @@ -91,8 +66,8 @@ export async function addHostCreatedSetupSplitWorktree( } } -/** Opens the setup worktree the way a user does; returns its id once main ran both terminals. */ -export async function openSetupSplitWorktree(page: Page, userDataDir: string): Promise { +/** Reopens the host-split worktree once both terminals are running. */ +export async function openHostSplitWorktree(page: Page, userDataDir: string): Promise { let setupWorktreeId: string | undefined await expect .poll(async () => { @@ -110,7 +85,7 @@ export async function openSetupSplitWorktree(page: Page, userDataDir: string): P }) .toBeDefined() const client = new RuntimeClient(userDataDir, 30_000) - // Main has spawned the first terminal and the setup split before the user opens the worktree. + // Main has spawned the first terminal and its manual split before the user opens the worktree. let hostPtyIds: string[] = [] await expect .poll(async () => { diff --git a/tests/e2e/live-background-terminal-mount-authority.spec.ts b/tests/e2e/live-background-terminal-mount-authority.spec.ts index 0e4df67740b..65823442837 100644 --- a/tests/e2e/live-background-terminal-mount-authority.spec.ts +++ b/tests/e2e/live-background-terminal-mount-authority.spec.ts @@ -556,7 +556,6 @@ test('adopts runtime-owned agent and Setup PTYs on first mount', async ({ agentCmdOverrides: { codex: command }, terminalWindowsShell: windowsShell, disabledTuiAgents: [], - setupScriptLaunchMode: 'new-tab', terminalHiddenViewParking: false }) return true diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index 64fc8baa28d..105fd3f5280 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -12,6 +12,7 @@ import { } from './helpers/store' import { sendToTerminal, + splitActiveTerminalPane, waitForActiveTerminalManager, waitForPaneCount, waitForPaneIdentitySnapshot @@ -146,7 +147,6 @@ async function addRealOrcaRepo(page: Page, repoPath: string): Promise { nextState.updateSettings({ defaultTuiAgent: 'codex', disabledTuiAgents: [], - setupScriptLaunchMode: 'split-vertical', terminalGpuAcceleration: 'on', theme: 'dark' }) @@ -575,7 +575,7 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { createdWorktreeIds.length = 0 }) - test('captures the real Orca repo setup-split Codex skill preview overpaint before any click', async ({ + test('captures the real Orca repo manual-split Codex skill preview overpaint before any click', async ({ electronApp, orcaPage }, testInfo) => { @@ -595,6 +595,7 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { await ensureTerminalVisible(orcaPage, 30_000) await waitForActiveTerminalManager(orcaPage, 30_000) + await splitActiveTerminalPane(orcaPage, 'vertical') await waitForPaneCount(orcaPage, 2, 60_000) await waitForPaneIdentitySnapshot(orcaPage, 2) const webglActive = await forceTerminalWebgl(orcaPage) @@ -602,6 +603,11 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { const leftPane = await focusLeftTerminalPane(orcaPage) const rightPane = await getRightTerminalPane(orcaPage) + await sendToTerminal( + orcaPage, + rightPane.ptyId, + 'for i in $(seq 1 120); do printf "Progress: split output %s\\n" "$i"; sleep 1; done\r' + ) expect(leftPane.hasWebgl).toBe(true) expect(rightPane.hasWebgl).toBe(true) expect(leftPane.proposed).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) diff --git a/tests/e2e/workspace-layout-oracle.spec.ts b/tests/e2e/workspace-layout-oracle.spec.ts index 3137451b49d..7d5f30af896 100644 --- a/tests/e2e/workspace-layout-oracle.spec.ts +++ b/tests/e2e/workspace-layout-oracle.spec.ts @@ -30,11 +30,11 @@ import { waitForBoundPanes } from './helpers/terminal-layout-journeys' import { - addHostCreatedSetupSplitWorktree, - openSetupSplitWorktree, + addHostCreatedSplitWorktree, + openHostSplitWorktree, sleepAgentPane, wakeByClick -} from './helpers/setup-split-and-sleep-journeys' +} from './helpers/host-split-and-sleep-journeys' import { runOracleScenario, type OracleRun } from './helpers/workspace-layout-oracle-session' import { RuntimeClient } from '../../src/cli/runtime/client' import { unexpectedFindings } from './workspace-layout-oracle-known-on-main' @@ -438,13 +438,13 @@ const SCENARIOS: Scenario[] = [ } }, { - // STA-9417: the CLI creates a worktree whose setup script runs in a split; first activation. - id: 'setup-split-first-activation', + // A host creates and manually splits a terminal, then the user returns to its saved layout. + id: 'host-split-reactivation', // Its own worktree's panes are checked in the journey; the seeded one is no longer shown. endMarkers: false, - setup: async (run) => addHostCreatedSetupSplitWorktree(run.page, run.userDataDir), + setup: async (run) => addHostCreatedSplitWorktree(run.page, run.userDataDir), journey: async (run) => { - const setupWorktreeId = await openSetupSplitWorktree(run.page, run.userDataDir) + const setupWorktreeId = await openHostSplitWorktree(run.page, run.userDataDir) run.worktreeIds.push(setupWorktreeId) await run.oracle.step('open setup worktree', { worktreeId: setupWorktreeId,