Repository navigation
fix(native-chat): an empty workspace opens the default agent as a chat when chat is the default view - #23693
Conversation
…t when chat is the default view With "open agent tabs in chat" on, clicking a workspace with no tabs still seeded a bare shell. The seeding path now opens the user's default agent through the shared launch funnel when that launch routes to a chat, and keeps the shell otherwise. A Blank Terminal pick at create time is now passed to activation as agent: null so it still gets a shell.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (7)
🚧 Files skipped from review as they are similar to previous changes (5)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review. 📝 WalkthroughWalkthroughWorkspace-opening actions now mark activation as user-open and pass that intent through worktree and folder-workspace activation. When the configured default is native chat, the host is known, and an enabled default agent has a non-terminal launch plan, empty-workspace seeding can launch that agent. Gated reseeding waits for pending host-agent detection and tracks pending default surfaces to prevent competing terminal seeds. Renderer test mocks now specify English. Priority: ➖ Normal Merge Risk: 🔵 Low · up to When the configured default agent is disabled and host detection is still loading, opening an empty workspace can be delayed by up to five seconds before it falls back to a shell. The delay is bounded and narrow, so merging is reasonable with owner awareness. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The new behavior can start an agent when a user opens an empty workspace, so its launch controls merit review. The examined paths preserve the user's surface choice and existing launch routing; no security defect was established. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
ℹ️ No critical issues — two test-coverage gaps worth closing.
Reviewed changes
- Empty-workspace seeding — a new
openDefaultAgentChatInEmptyWorkspaceresolves the default agent, plans the launch route, rejects onlyterminal-tui, and launches through the existinglaunchAgentInNewTabfunnel so the chat/terminal decision stays in the one planner. - Activation plumbing —
activationSeedsUserDefaultSurfacetreats an absentagent(no pick) as "seed the default surface" and an explicitnull(Blank Terminal) as "keep the shell"; the flag is threaded through both the direct seed and the deferred gate/reseed intent, with!opts.initialCwdas an extra guard. - Seed gate — runs after
applyDefaultTerminalTabs, requires!hasExplicitLaunchWorkandactivateCreatedTabs !== false; host-owned workspaces still short-circuit onhostAuthority === 'live'. - Launch funnel — new
pendingActivationSpawnarg forwarded tocreateTab; the create flow now always passesagent: preparedRequest.agentso a Blank Terminal pick stays distinguishable from no pick. - Tests — new chat-seeding and activation tests, caller-census updates, and the launch-draft cases moved unchanged to a new file to stay under the 800-line cap.
I traced the reachability of the seed (only shouldAutoCreate, which needs hostAuthority === 'none') and confirmed no import cycle from the launch funnel back into seeding.
Technical details
# Coverage gaps in the new default-chat tests
## Affected sites
- `src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts:87-94` — runs without `window.api`, so `canInspectAgentActivationInventory()` is false and only the direct seed path is exercised.
- `src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts:72-77` — only `terminal-tui` (reject) and `structured-native-chat` (the mock's route) are pinned.
## Required outcome
- The deferred gate path (`gateAndReseedEmptyWorkspace` → `reseedGatedEmptyWorkspace` forwarding `seedUserDefaultSurface`) should have at least one test, since a drop of the flag there would currently pass the suite.
- The `legacy-native-chat` route — what chat-default users without `experimentalStructuredNativeChat` actually get, and what the manual test exercised — should be covered.
## Open questions for the human
- None; both are additive.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| it('does not start an agent that could only open as a terminal here', () => { | ||
| seedSettings({}) | ||
| mocks.planAgentSessionLaunch.mockReturnValue({ route: 'terminal-tui' }) | ||
|
|
||
| expect(openDefaultAgentChatInEmptyWorkspace('wt-1')).toBeNull() | ||
| expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() |
There was a problem hiding this comment.
The accepted non-structured route is never exercised. With seedSettings({}) (no experimentalStructuredNativeChat) the real planner would return legacy-native-chat, yet the mock hard-codes structured-native-chat, so only the structured branch is pinned — the terminal-backed chat most chat-default users actually get, and the one the PR's manual test used, has no case here.
Technical details
# Cover the legacy-native-chat route
## Affected sites
- `src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts:72-77` — only `terminal-tui` and `structured-native-chat` are covered
## Required outcome
- Add a case where `planAgentSessionLaunch` returns `{ route: 'legacy-native-chat' }` and assert the launch still happens (the guard is `route === 'terminal-tui'`).
|
… agent chat Round-1 review of the empty-workspace default chat: activation treated any call with no agent and no caller surface as user navigation, so CLI/phone creates, fallbacks after a failed agent launch, fork fallbacks, and the move to a neighbour after a delete all opened an agent chat nobody picked. - Opt in instead: activation options gain navigationIntent: 'user-open', set only by user navigation (sidebar row, keyboard cycling, Cmd+J, the workspace digit shortcut, back/forward, open-parent, jump-to-workspace, and the open-attached-workspace / space-manager actions). Every other activation keeps today's shell, so the Blank Terminal pass-through in the create flow is no longer needed and is reverted. - The Electron gated reseed now waits (bounded, 5 s) for the workspace host's agent list when it has not loaded, holding a per-workspace claim that the passive seed and other reseeds respect, then re-checks the active workspace, host, and emptiness. Host resolution reuses the detection target key, so an unresolved owner stays unknown. - Tests cover the gated path for worktrees and folders, non-opted activations, history navigation, the detection wait and its failure, and the passive seed deferring to the claim.
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 3974ba89-e8db-4997-b0f6-b9560d3f4b35
📒 Files selected for processing (34)
src/renderer/src/components/WorktreeJumpPalette.test.tsxsrc/renderer/src/components/activity/activity-thread-actions.test.tssrc/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsxsrc/renderer/src/components/pull-request-page/page/surface.tsxsrc/renderer/src/components/sidebar/sleep-worktree-activation-race.test.tssrc/renderer/src/components/sidebar/use-worktree-context-menu-commands.tssrc/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsxsrc/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsxsrc/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.tssrc/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.tssrc/renderer/src/components/status-bar/workspace-space-manager-table.tsxsrc/renderer/src/components/task-page-workspace-composer-boundary.test.tssrc/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsxsrc/renderer/src/components/use-task-page-workspace-actions.tssrc/renderer/src/components/use-terminal-watcher-effects.tssrc/renderer/src/components/use-worktree-jump-palette-selection-actions.tssrc/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.tssrc/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.tssrc/renderer/src/lib/empty-workspace-default-agent-chat.test.tssrc/renderer/src/lib/empty-workspace-default-agent-chat.tssrc/renderer/src/lib/empty-workspace-default-surface-claims.tssrc/renderer/src/lib/linear-issue-workspace-open.test.tssrc/renderer/src/lib/linear-issue-workspace-open.tssrc/renderer/src/lib/sidebar-worktree-activation.test.tssrc/renderer/src/lib/sidebar-worktree-activation.tssrc/renderer/src/lib/worktree-activation-default-agent-chat.test.tssrc/renderer/src/lib/worktree-activation-gated-empty-reseed.tssrc/renderer/src/lib/worktree-activation-nav-registration.tssrc/renderer/src/lib/worktree-activation-store-contract.tssrc/renderer/src/lib/worktree-activation-surface-selection.tssrc/renderer/src/lib/worktree-activation.tssrc/renderer/src/lib/worktree-initial-terminal-seeding.tssrc/renderer/src/lib/worktree-jump-navigation.test.tssrc/renderer/src/lib/worktree-jump-navigation.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/renderer/src/lib/worktree-activation-store-contract.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.
…atest open Round-2 review of the empty-workspace default chat: while a user open waited for the host's agent list, a second activation of the same workspace was dropped. Opening A from the sidebar, moving to B, then pressing Back to A during the wait left A active with no chat and no shell: the wait reseeded with the first open's host id, which no longer matched, and the passive seed had already marked A handled. - The pending wait now stores the latest activation's intent; a later activation (user open or plain) replaces it, and the wait seeds with it. - The passive seed no longer marks a workspace handled while a wait owns it, so leaving mid-wait and returning seeds a shell instead of nothing. - The wait and the open share one check, so a Blank Terminal default (or chat not being the default view) no longer waits up to 5 s before its shell.
There was a problem hiding this comment.
Important
The opt-in rework is the right call, but the new surface claim is taken too late relative to the passive watcher, so a second (or pre-registered) activation can let a shell seed and silently suppress the default chat.
Reviewed changes
- Opt-in seeding — activation gained
navigationIntent: 'user-open';activationSeedsUserDefaultSurfacenow requires it, and the Blank Terminal pass-through in the create flow was reverted, so CLI/phone creates, failed-launch fallbacks, forks, and post-delete moves keep the shell. - Detection wait — a bounded (5 s) wait for the workspace host's agent list plus a per-workspace surface claim that the passive seed and other reseeds respect; host resolution reuses the detection-target key so an unresolved owner stays unknown.
- Tests — gated reseed coverage for worktrees and folders, non-opted activations, history navigation, the detection wait and its failure, and the passive seed deferring to the claim; caller censuses updated for
navigationIntent.
The gated-path coverage gap from the prior review is addressed.
ℹ️ Deliberate user opens that still seed a shell
Making seeding opt-in leaves several existing "user clicks a workspace" surfaces without navigationIntent, so they seed a bare shell where the sidebar and palettes now seed the default chat. None regress main (all seeded a shell before), but they are the same shape as the callers this PR did opt in:
src/renderer/src/components/status-bar/use-resource-usage-actions.ts:98,108— the resource-usage tree's worktree-row click.src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx:328— the cleanup candidate's "View" action (cleanup candidates are typically empty).src/renderer/src/lib/workspace-port-actions.ts:99— the ports popover's "go to workspace".src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts:109— the vault session menu's "Jump to Worktree".
If these are intentionally out of scope, this is fine; otherwise they need the same navigationIntent: 'user-open' for the "every navigation entry point" goal to hold.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| return | ||
| } | ||
| // Why: the default agent depends on the host's list; seeding before it loads locks in a shell. | ||
| if (!claimEmptyWorkspaceDefaultSurface(workspaceKey)) { |
There was a problem hiding this comment.
The claim is acquired inside the gate's .then, but the passive watcher registers its own callback on the same shared gate promise and only reads the claim when that callback runs. If the watcher's callback is registered before this one — the workspace was already active and gated, or a second activation overwrites latestReseedIntentByGate after the watcher's callback — the watcher seeds a bare shell first, and this reseed then bails on renderableTabCount === 1, so the configured default chat never opens. That is the exact "shell instead of the chat" case the claim exists to prevent.
Technical details
# Take the surface claim before the gate resolves
## Affected sites
- `src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts:42` — claim acquired inside the gate `.then`, after the shared promise can already run the watcher's callback
- `src/renderer/src/components/use-terminal-watcher-effects.ts:234-252` — reads `isEmptyWorkspaceDefaultSurfacePending`, then `createTab`s a shell when the claim is not yet set
- `src/renderer/src/lib/worktree-agent-activation-gate.ts:254-278` — `inFlightByWorktreeId` hands every caller the same gate promise
- `src/renderer/src/lib/worktree-initial-terminal-seeding.ts:198-238` — a non-zero `renderableTabCount` returns before the `seedUserDefaultSurface` branch, so the chat is dropped once the watcher's shell exists
## Required outcome
- A deliberate user open of an empty workspace must not let any other seeder create a shell between gate resolution and this reseed's chat launch, regardless of callback registration order.
## Suggested approach (optional)
- Claim synchronously at the top of `gateAndReseedEmptyWorkspace` (or in `activateAndRevealWorktree`/`activateAndRevealFolderWorkspace`) when `seedUserDefaultSurface` is set, and release when the gate settles without seeding a chat so `settle` can reseed.| const workspacesAwaitingDefaultSurface = new Set<string>() | ||
|
|
||
| /** False when another reseed already owns this workspace's default surface. */ | ||
| export function claimEmptyWorkspaceDefaultSurface(workspaceKey: string): boolean { |
There was a problem hiding this comment.
The claim is keyed by workspace string, but reseedGatedEmptyWorkspace bails when the active execution host no longer matches the intent's. If the same worktree id is opened on another host during the 5 s wait, the claim blocks that host's own reseed and the first activation's settle is skipped, leaving the now-active workspace with no tab until the next activation. Consider scoping the claim (or the wait) to the host it belongs to.
Technical details
# Scope the claim to the host it belongs to
## Affected sites
- `src/renderer/src/lib/empty-workspace-default-surface-claims.ts:6-12` — key is `workspaceKey` only
- `src/renderer/src/lib/worktree-initial-terminal-seeding.ts:55-62` — reseed bails on `activeWorkspaceExecutionHostId !== executionHostId`
- `src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts:42-50` — `settle` releases, then reseeds with the original intent
## Required outcome
- A host switch during the detection wait must not strand the newly active workspace without a surface.
## Open questions for the human (optional)
- Are duplicate workspace ids across hosts reachable in the sidebar/palette flows this PR targets?| return false | ||
| } | ||
| const target = defaultChatDetectionTarget(state, worktreeId) | ||
| return target !== undefined && readDetectedAgents(state, target) === null |
There was a problem hiding this comment.
This predicate returns true whenever the host list is null, without checking whether any agent could actually open — pickTuiAgent is only consulted later, in openDefaultAgentChat. With a Blank Terminal default (or all agents disabled) on a host whose detection has not loaded, a user open claims the surface and waits up to 5 s before falling through to the shell they always wanted.
Technical details
# Only wait when a chat could actually open
## Affected sites
- `src/renderer/src/lib/empty-workspace-default-agent-chat.ts:40-47` — predicate ignores `pickTuiAgent`
- `src/renderer/src/lib/empty-workspace-default-agent-chat.ts:82-89` — the opener is what rejects `'blank'` / all-disabled defaults
## Required outcome
- Skip the detection wait when the configured default (after disabled filtering) can never yield an agent, so the shell seeds immediately.
## Suggested approach (optional)
- Have `emptyWorkspaceDefaultChatAwaitsDetection` return false when `pickTuiAgent(settings.defaultTuiAgent, [], settings.disabledTuiAgents)` is null.
Review status: ready for your look (not merged, not marked anything)The problem: with chat as the default view, clicking a workspace that has no tabs opened a bare "Terminal 1" shell instead of a chat, so you had to switch it to chat yourself. What changes for you: opening an empty workspace yourself (sidebar click, jump palette, keyboard switching, the workspace number shortcut, back/forward) now opens your default agent as a chat. Everything else still opens a shell as before: chat not your default view, Blank Terminal as your default agent, creating a workspace, CLI or phone opens, and Orca moving you on its own (for example after a delete). What review changed (2 rounds, then a narrow check of the last fix; head
Deferred (P3, not fixed):
Behaviour to know about (matches the tab bar's agent menu, but here it happens on a plain click):
Verified:
Not verified:
|
…3745, #23783, #22971, #22565, #22846, #23724, #23721, #23693, #23810) into #23059 #23671 (group chat rows by the turn that produced them) is ported onto #23059's turn-scope model, so there is one grouping mechanism, not two: - A host that states each row's turn scope groups by that scope, as before. #23671's journal-order derivation (rows between one turn record and the next belong to it) moves from the timing selector into src/shared/native-chat-turn-grouping.ts and runs only for a host that states no scope, replacing the positional fallback there. - #23671's anchor rules live in structuredAgentTurnAnchors, which membership, settled timing and the live clock all read: a turn whose opener key nothing resolves yet takes the send still in flight ahead of its record (Codex before its echo), else its own record. - The live turn is one key again. #23671 made row liveness follow the owning turn, so the bar key and the liveness key are always the same; nativeChatTurnMembership now returns only liveTurnKey (barTurnKey, activeTurnOpenedBy and turnKeysByItemId are gone). - A turn with no user bubble draws its bar above its first row (nativeChatTurnBarRows, desktop and mobile), and a turn's diff rollup goes under its last row. - The Codex full-history restore writes each record ahead of its items; that also fixes the derived turn scope on #23059's journal. #23026 (Stop from the moment a message is sent) is ported onto #23059's command turns: the cancel paths ask the translator for a command's provider turn instead of the removed compaction tracker, and a Stop that names no turn still ends a running /compact through its child. performCancel moved to structured-agent-session-turns-cancel.ts to keep the turns module under the line limit. #22090's awaitingInput replaces showTurnStatus/showLiveTurnActivity; the waiting-behind-/compact rows are kept.
Measured from the post-stage-3 main: 794 commits, 22 conflicting files, only 2 of them in the PTY delivery subsystem. The wall this staged plan existed to isolate is behind us; this stage is identity, usage accounting and renderer surface. Three upstream commits produce most of the conflicts: `9700030689` (headless renderer delivery), `82412dab8b` (profile state in SQLite), `d443320af2` (terminal handoff removal), plus `d2dbe2c385` and `f1463683ee` below. ## The two decisions this merge turned on **OpenCode field meanings: adopt upstream's.** The fork's distinguishing behaviour was counting cache writes in token totals. Upstream `f1463683ee` now does that itself — its `scanner.test` expects 1775, which is the fork's number. What remained was bucketing: upstream has `inputTokens` = uncached+read+write, `cachedInputTokens` = read+write, `cacheWriteInputTokens` for the write subset. Keeping the fork's narrower meanings would have left four cleanly-merged upstream files silently wrong, one of them producing a negative telemetry `input_tokens` that the schema rejects — and the divergence would be invisible to typecheck, since the field names match and only the meanings differ. All five cluster files are therefore byte-identical to `b601b98644`, and every asserted total in `cache-write-tokens.test.ts` is unchanged (1120, 2000, 3125): the feature is preserved, only its composition moved. Fork files adapted: `snapshot-rollups.ts`, `usage-provider-normalization.ts` (restored to upstream — otherwise cache is counted as new input and writes are counted twice), `usage-overview-model.test.ts`, `cache-write-tokens.test.ts`. **`agent-state-rules` URL: keep the fork repo.** Upstream stablyai#24387 downloads rules from `${MAIN_RELEASE_REPO}`, which the fork rebranded to `sj900211/orcinus`. A 404 is non-fatal — the updater warns and serves the bundled rules — so the fork keeps pointing at itself rather than fetching runtime data from a repository it does not control. Upstream tests that pinned `stablyai/orca` had a premise that is false by a ratified decision (`release-channel.ts:27`), so their expectations were corrected, following the precedent of rebrand commit `222baedec6`. Each edited assertion carries a `// Why` marker so a future sync does not revert it. This is correcting a stale premise, not weakening a guard: the tests still pin how the URL is built. ## The Windows CLI launcher was replaced wholesale Upstream `d2dbe2c385` deleted `OrcaCliLauncher.cs` and shipped a Rust crate. The fork's `Orcinus.exe` fix (hidden quest ③) is ported into `native/windows-cli-launcher/src/main.rs` at the four call sites; the `.cs` is removed, which a cross-host test now requires because its MSIL image was vendor-flagged (stablyai#23383). The three `Orca CLI` strings and every `ORCA_*` environment name stay — the CLI is still named `orca`. Without this port the packaged `orca` CLI fails on every Orcinus Windows install, which is the precondition for all orchestration work in this repo. `.github/workflows/orcinus-cd.yml` gains a Rust toolchain step on the Windows leg. Deferred by decision: rebranding the launcher's `build.rs` metadata and icon (cosmetic, and the three pieces must move together). ## Defects found that no conflict marker could show Six files merged cleanly and were still wrong: - `pty/session.ts` family — carried from stage 3's pattern, handled there. - `usage-web-client-fallback.test.ts` lost two slice imports upstream narrowed. - `ui-slice-preference-actions.ts` breached `max-lines` at 302 (fork +10, upstream +12). Resolved without a disable by moving the fork's number-format action to a sibling module; the file is now identical to upstream. - `startup-window-role-reconnect.ts` and `folder-workspace-initial-terminal.ts` are fork-only extractions made for `max-lines`; upstream rewrote the originals, so stablyai#24203 and stablyai#23693 were ported into them by hand. - Upstream's new Reset Terminal bypassed owner-window routing; it now routes. - Upstream's new `terminal-intentional-stop-exit.test.ts` saw zero `pty:exit` sends until given a routed main window. - `satellite-open-interception.ts` would have raised a satellite window on a background CLI open, breaking the "change nothing on screen" guarantee stablyai#24244 added. ## Review An independent critic returned REJECT with **no production defect**: every fork behaviour present, both decisions correctly applied, every gate reproducible. The rejection was for 14 tests in 6 files that the gate's directory scope never ran — all of them new upstream tests pinning upstream identity, the same class the `agent-state-rules` decision had ruled on and which it had been applied to too narrowly. All six are fixed, test-only, each with a `// Why` marker. The critic also corrected a claim in the implementation report: the fork already carries upstream's `agent-state-rules-publish.yml`, so live rule updates need only an `agent-state-rules` environment and a `publish-next` dispatch, not new machinery. `sftp-upload.test.ts` deserves a note: upstream's new cases assert a direct `createWriteStream`, but the fork uploads atomically to `.orcinus-part-<uuid>` and publishes by rename (`sftp-upload.ts:61-63`). The fork's contract stands; the expectations moved to it. One of those cases is `skipIf(win32)` and could not be observed here, but fork CI runs on ubuntu, so it was fixed from the code. ## Gates Cold typecheck (after deleting `config/*.tsbuildinfo`): 0 errors, measured twice by different workers. oxlint: 0 warnings, 0 errors across 29,465 files. verify-orcinus-identity: OK, `upstreamRefs=0`. Unsigned smoke: OK, `icon=orcinus.icns`. Fork contract tests: 24/24. Windows CLI launcher tests: the Rust build actually runs here and passes. Attribution, using the critic's harness across three trees (this merge, main, and `b601b98644`): a 1,236-file fork-adjacent sweep and a 54-file identity sweep went from 14 merge-only failures to 1. That one is the release-tag census entry for the fork-only `verify-orcinus-release-assets.mjs`: the census builds its keys with `join()`, so on win32 they carry backslashes. Six upstream sibling cases fail the same way on the upstream baseline, a direct POSIX evaluation of the key logic passes, and a negative control confirms the entry is detected. The census's upstream key logic was deliberately left alone. Known and not addressed here: `updater-prerelease-feed.ts:156` matches only `stablyai/orca` asset URLs — present on `main`, not merge-caused, and invisible to the identity verifier. The release path has not been exercised with this merge (`build:orcad` moved from bun to node); an `orcinus-cd` dry run is advisable before the next fork release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

ELI5
If you told Orca that new agent tabs should open as chat, clicking a workspace that has no tabs still opened a plain terminal, and you had to turn it into a chat yourself. Now opening an empty workspace yourself opens your default agent as a chat.
What Changed
The problem: with chat as the default view (experimental chat on and "open agent tabs in chat" on), every empty workspace still got its first tab from
ensureWorktreeHasInitialTerminal, and when nothing else was requested that tab was always a bare shell. The "open agent tabs in chat" setting only ever applied to tabs you opened from the agent menu, so it never reached this path.Before: clicking an empty workspace in the sidebar (or reaching it from the workspace palette, keyboard switching, the workspace number shortcut, or back/forward) opened "Terminal 1", even though chat is your default view.
After: the same actions open your default agent as a chat. Everything else still opens a shell exactly as before:
Scope: desktop only, and only when you open an existing workspace that has no tabs. Workspaces that already have tabs are unaffected. Paired and remote-owned workspaces are unaffected, because their host still owns their tabs.
How it works:
navigationIntent: 'user-open'. Only the navigation entry points set it: sidebar row, keyboard workspace cycling, Cmd+J pick, the workspace number shortcut, back/forward, "Open parent", jump-to-workspace, and the open-workspace actions on the task, PR, GitHub-item and Linear pages and in the space manager. Activation turns it into aseedUserDefaultSurfaceseeding option, and the delayed reseed that runs after the agent inventory check carries it too.openDefaultAgentChatInEmptyWorkspacebefore creating a shell. It picks the default agent from the agents detected on that workspace's own host (the same host resolution the tab bar uses), asks the shared launch planner how that agent would open there, and launches it through the samelaunchAgentInNewTabfunnel as the tab bar. It does this only when the planner's answer is a chat; otherwise the shell is seeded.Why
Why opt in: a first version inferred "the user made no choice" from any activation that named no agent. That also caught CLI and phone creates, fallbacks after a failed agent launch, and automatic moves, so each of those started an agent nobody asked for. With opt-in, a navigation path that someone forgets to mark still gets today's shell; it can never start an unrequested agent.
Why the seeding step owns the choice: it is the only point that knows a tab is really about to be created, after the host-ownership, closed-last-tab, emptiness and template checks. Deciding one level up would mean repeating those checks.
Why reuse the tab bar's funnel: launching through
launchAgentInNewTabwith a plan fromplanAgentSessionLaunchkeeps the chat-versus-terminal decision in the one planner that already makes it, so this path cannot drift from what the tab bar opens for the same agent.Why wait for detection: without the wait, the first empty workspace opened on a freshly connected SSH host got a shell and kept it, while later ones got a chat.
Rejected: starting the default agent in every empty workspace regardless of view mode. With terminal as the default view, that swaps a shell for an agent process you did not ask for. That request is #21260 and is not addressed here.
Known behaviour worth knowing:
Linked Issue
Reported in Discord: https://discord.com/channels/1492228674081263786/1553645627504722041/1553645627504722041
Related: #21260 (the same click opening the default agent as a terminal program; not addressed here).
Visual Proof
Same fresh profile and steps in both: chat as the default view (experimental chat on, "open agent tabs in chat" on), Codex as the default agent, one project whose
mainworkspace has no tabs, then clickmainin the sidebar.Final head, background test build with an isolated profile on a second Mac, chat as the default view, Claude as the default agent (not signed in on purpose, so each shot judges only which surface opened):
Sidebar click on an empty worktree opens a Claude chat.
Default agent set to Blank Terminal: Terminal 1.
An empty folder workspace (not git) opens a chat too.
"Open agent tabs in chat" off: Terminal 1.
Picking an empty workspace in the jump palette opens a chat.
Creating a workspace with Blank Terminal picked: Terminal 1.
Closing the last chat, leaving and coming back opens a new chat.
Orca moving you after a delete: Terminal 1, no chat.
Testing
empty-workspace-default-agent-chat.test.ts: launches the default agent as a chat; does nothing with chat-by-default off, with a Blank Terminal default, when the planner routes to the terminal program, or when the host is unresolved; reads the SSH host's agent list; bounds the detection wait; skips the wait for a Blank Terminal default.worktree-activation-default-agent-chat.test.ts: the delayed reseed (the desktop path) opens the chat for worktrees and folders; activations without the navigation intent seed a shell; back/forward opts in; the wait for detection, its failure, and re-checking the active workspace afterwards; a reopen with a different host during the wait still seeds; a plain activation during the wait seeds a shell.initial-terminal-structured-launch.test.tsx: the startup seeder defers to a pending open, and seeds on return to a workspace left during the wait.Each new test fails with its production change removed.
Caller censuses updated for the new launch-funnel caller.
Web typecheck, oxlint and oxfmt on changed files pass.
Manual, macOS, final head: all of the cases above on a second Mac, plus no workspace ever showed a chat and a shell together, and nothing was added in the 10 s after each open. Not exercised live: the Cmd+J keystroke itself (the same palette was opened from the sidebar Search button), keyboard cycling, number shortcuts, back/forward, the up-to-5 s detection wait, CLI/phone opens, and a signed-in chat.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
SSH: the default agent comes from the agents detected on the workspace's own host, and the planner refuses chat routes that cannot run there, so an SSH workspace keeps the shell unless a chat can open there.
Folder workspaces: they follow the same path.
Platforms: no platform-specific code.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)