Skip to content

fix(native-chat): an empty workspace opens the default agent as a chat when chat is the default view - #23693

Merged
brennanb2025 merged 3 commits into
mainfrom
brennanb2025/empty-workspace-default-chat
Sep 29, 2026
Merged

brennanb2025 merged 3 commits into
mainfrom
brennanb2025/empty-workspace-default-chat

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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:

  • chat is not your default view
  • your default agent is Blank Terminal
  • creating a workspace, including a Blank Terminal pick
  • a workspace opened by the CLI or the phone
  • Orca moving you somewhere on its own, e.g. after you delete the workspace you were viewing
  • the fallback after an agent you picked could not start
  • repo default-tab templates, setup and issue-command terminals, and app startup

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:

  • Only a deliberate open asks for the chat. Activation options gain 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 a seedUserDefaultSurface seeding option, and the delayed reseed that runs after the agent inventory check carries it too.
  • The seeding step chooses the surface. When that option is set, the seeding step calls openDefaultAgentChatInEmptyWorkspace before 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 same launchAgentInNewTab funnel as the tab bar. It does this only when the planner's answer is a chat; otherwise the shell is seeded.
  • Agent detection may still be loading. On the delayed reseed that every desktop click goes through, a host whose agent list has not loaded yet (for example, an SSH host just reconnected) gets up to 5 s to finish detection. During that wait the workspace is held for its own opener, so the startup seeder does not put a shell there first. The most recent open of the workspace decides what is seeded. A failed or slow detection still gets a shell.

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 launchAgentInNewTab with a plan from planAgentSessionLaunch keeps 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:

  • The chat starts its agent as soon as it opens, just as the tab bar's agent menu does, so moving through several empty workspaces with the keyboard starts an agent in each (each used to get a shell).
  • If your default agent is not installed on that host, the next detected agent opens instead, the same fallback the new-agent-tab shortcut uses.
  • On SSH, Claude and Codex open as a terminal-backed chat, which starts the agent on the remote host. Agents whose chat needs a locally readable transcript keep the shell there.

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 main workspace has no tabs, then click main in the sidebar.

Before (main) After (this PR)
Before: clicking the empty workspace opens a bare Terminal 1 shell After: clicking the empty workspace opens a Codex chat with the message composer
A bare shell ("Terminal 1") opens, even though Codex was detected and chat is the default view. A Codex chat opens ("Start a chat with Codex", message composer ready).

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):

Opened on purpose: chat Everything else: shell
Sidebar click on an empty worktree opens a Claude chat
Sidebar click on an empty worktree opens a Claude chat.
Default agent Blank Terminal: the click opens Terminal 1
Default agent set to Blank Terminal: Terminal 1.
Empty folder workspace opens a Claude chat
An empty folder workspace (not git) opens a chat too.
Open agent tabs in chat turned off: Terminal 1
"Open agent tabs in chat" off: Terminal 1.
Jump palette pick of an empty worktree opens a Claude chat
Picking an empty workspace in the jump palette opens a chat.
New workspace created with Blank Terminal picked opens Terminal 1
Creating a workspace with Blank Terminal picked: Terminal 1.
After closing the last chat and coming back, a new chat opens
Closing the last chat, leaving and coming back opens a new chat.
Deleting the active workspace moves to an empty one, which gets Terminal 1
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

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and 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

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…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.
@brennanb2025
brennanb2025 marked this pull request as ready for review September 28, 2026 20:46
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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 configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 27df3612-7358-4f17-8735-8bd70b73ddef

📥 Commits

Reviewing files that changed from the base of the PR and between d89c995 and 01b4ba2.

📒 Files selected for processing (7)
  • src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
  • src/renderer/src/components/use-terminal-watcher-effects.ts
  • src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts
  • src/renderer/src/lib/empty-workspace-default-agent-chat.ts
  • src/renderer/src/lib/empty-workspace-default-surface-claims.ts
  • src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts
  • src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
  • src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts
  • src/renderer/src/lib/empty-workspace-default-surface-claims.ts
  • src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts
  • src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Workspace-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 01b4b

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 Review

Security architecture risk: 🔵 Low · up to 01b4b

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
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The newly reachable agent launch is limited by the examined path to an eligible, deliberately opened empty workspace and its resolved agent-detection target; external routes into activation remain unverified.

Trust Boundaries and Controls

  • observed — An explicit surface choice takes precedence over default seeding, while a selected default agent still passes through host-specific detection and the existing launch funnel.

Resilience and Maintainability Implications

  • observed — Pending detection defers competing reseeding, and the eventual reseed checks that the workspace remains active and, when specified, on the same execution host.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 48 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: opening the default agent as chat in empty workspaces when native chat is the default view.
Description check ✅ Passed The description is complete and well aligned with the template. It explains the user impact, implementation, rationale, scope, linked context, visual proof, testing, platform and SSH considerations, a…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — two test-coverage gaps worth closing.

Reviewed changes

  • Empty-workspace seeding — a new openDefaultAgentChatInEmptyWorkspace resolves the default agent, plans the launch route, rejects only terminal-tui, and launches through the existing launchAgentInNewTab funnel so the chat/terminal decision stays in the one planner.
  • Activation plumbing — activationSeedsUserDefaultSurface treats an absent agent (no pick) as "seed the default surface" and an explicit null (Blank Terminal) as "keep the shell"; the flag is threaded through both the direct seed and the deferred gate/reseed intent, with !opts.initialCwd as an extra guard.
  • Seed gate — runs after applyDefaultTerminalTabs, requires !hasExplicitLaunchWork and activateCreatedTabs !== false; host-owned workspaces still short-circuit on hostAuthority === 'live'.
  • Launch funnel — new pendingActivationSpawn arg forwarded to createTab; the create flow now always passes agent: preparedRequest.agent so 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +72 to +77
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'`).

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adds navigation intent tracking to workspace activation calls.

The PR appears safe to merge; no new actionable issue was established.

Summary

This PR opens the default agent as a chat when a user deliberately opens an empty workspace and chat is the default view.

  • Carries navigation intent through workspace activation and delayed reseeding.
  • Preserves shell seeding for Blank Terminal, non-chat routes, and activations without a user-open intent.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Open empty workspace] --> B{User-open intent with no surface choice?}
  B -- No --> S[Seed shell]
  B -- Yes --> G[Check workspace activation gate]
  G --> D{Default chat needs agent detection?}
  D -- Yes --> W[Wait for detection or timeout]
  D -- No --> P[Plan default-agent launch]
  W --> P
  P --> C{Chat route available?}
  C -- Yes --> L[Launch chat]
  C -- No --> S
Loading

Reviews (3) · Last reviewed commit: "fix(native-chat): a reopen during the ag..."

… 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 3974ba89-e8db-4997-b0f6-b9560d3f4b35

📥 Commits

Reviewing files that changed from the base of the PR and between f3ef62d and d89c995.

📒 Files selected for processing (34)
  • src/renderer/src/components/WorktreeJumpPalette.test.tsx
  • src/renderer/src/components/activity/activity-thread-actions.test.ts
  • src/renderer/src/components/github-item-dialog/open-dialog/github-item-dialog.tsx
  • src/renderer/src/components/pull-request-page/page/surface.tsx
  • src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts
  • src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts
  • src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.focus.test.tsx
  • src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.host-identity.test.tsx
  • src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts
  • src/renderer/src/components/status-bar/workspace-space-manager-source-boundary.test.ts
  • src/renderer/src/components/status-bar/workspace-space-manager-table.tsx
  • src/renderer/src/components/task-page-workspace-composer-boundary.test.ts
  • src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
  • src/renderer/src/components/use-task-page-workspace-actions.ts
  • src/renderer/src/components/use-terminal-watcher-effects.ts
  • src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts
  • src/renderer/src/hooks/ipc-events/workspace-shortcut-ipc-bridge.ts
  • src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts
  • src/renderer/src/lib/empty-workspace-default-agent-chat.test.ts
  • src/renderer/src/lib/empty-workspace-default-agent-chat.ts
  • src/renderer/src/lib/empty-workspace-default-surface-claims.ts
  • src/renderer/src/lib/linear-issue-workspace-open.test.ts
  • src/renderer/src/lib/linear-issue-workspace-open.ts
  • src/renderer/src/lib/sidebar-worktree-activation.test.ts
  • src/renderer/src/lib/sidebar-worktree-activation.ts
  • src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts
  • src/renderer/src/lib/worktree-activation-gated-empty-reseed.ts
  • src/renderer/src/lib/worktree-activation-nav-registration.ts
  • src/renderer/src/lib/worktree-activation-store-contract.ts
  • src/renderer/src/lib/worktree-activation-surface-selection.ts
  • src/renderer/src/lib/worktree-activation.ts
  • src/renderer/src/lib/worktree-initial-terminal-seeding.ts
  • src/renderer/src/lib/worktree-jump-navigation.test.ts
  • src/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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'; activationSeedsUserDefaultSurface now 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

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 01b4ba2ac51):

  • Fixed, P2: agents nobody asked for. The first version treated any activation that named no agent as "the user opened this with no pick". That also caught a phone create with Blank picked, orca worktree create --activate without --agent, the fallback after a work-item or session-fork agent failed to start, and the automatic move after deleting a workspace, so each opened the default agent's chat. Now only real navigation opts in (navigationIntent: 'user-open'), and anything that forgets to opt in keeps today's shell. This also removed the create-flow Blank Terminal pass-through and the test-file split it needed.
  • Fixed, P2: SSH first open got a shell and kept it. Nothing on this path asked the SSH host which agents it has, and that list is cleared on every disconnect. The desktop path now waits up to 5 s for that host's agent list, using the same host lookup the tab bar uses, then opens a chat or a shell.
  • Fixed, P2, introduced by the fix above: opening A, switching to B, then pressing Back to A during that wait could leave A active with nothing in it. The latest open of a workspace now decides what it gets, and the startup seeder re-checks when you come back.
  • Fixed, P3: a Blank Terminal default waited for agent detection it did not need. Tests now cover the path the desktop app actually takes; the original tests only covered a branch the app skips. Folder workspaces now have tests too.

Deferred (P3, not fixed):

  • On an SSH worktree whose remote state has not synced yet, whether you get a chat or a shell depends on which finishes first, the sync or agent detection.
  • Coming back to a workspace during the wait by a path other than an activation, after its host changed, can still leave it empty. This is very narrow.
  • Seeded chats report launch_source: 'unknown', the same value dashboard launches use; giving them their own value is a telemetry schema change.
  • Small duplication: the detected-agents selector and the per-host detection dispatch repeat existing code.

Behaviour to know about (matches the tab bar's agent menu, but here it happens on a plain click):

  • The chat starts its agent right away, so moving through several empty workspaces with the keyboard starts an agent in each.
  • If you are not signed in, the chat shows its sign-in error on that click.
  • If your default agent is not installed on that host, the next detected agent opens instead.

Verified:

  • Two full review rounds plus a narrow check of the last fix; each new test fails with its fix removed.
  • Readiness checklist on the final diff: 8 of 8 items pass, with no P0 to P2 candidates.
  • Web typecheck clean; targeted tests pass (1,314 across 121 related test files).
  • Live QA on the final head on a second Mac, with a background test build and an isolated profile. Chat opened for a sidebar click, an empty folder workspace, a jump-palette pick, and on returning after closing the last chat. A shell opened for a Blank Terminal default, chat turned off, a create with Blank Terminal picked, and the automatic move after a delete. No workspace showed a chat and a shell together. Screenshots are in the PR description.

Not verified:

  • The Cmd+J keystroke itself; the same palette was opened from the sidebar Search button.
  • Keyboard cycling, the number shortcuts and back/forward, live (unit tests only).
  • The 5 s detection wait, live.
  • CLI and phone opens, live.
  • A signed-in chat session.
  • A real SSH host.
  • Grok's weekly quota was used up, so a Claude seat ran the live QA instead.

@brennanb2025
brennanb2025 merged commit 33f661b into main Sep 29, 2026
32 of 41 checks passed
brennanb2025 added a commit that referenced this pull request Sep 29, 2026
…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.
sj900211 added a commit to sj900211/orcinus that referenced this pull request Oct 13, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant