Skip to content

fix(workspace): keep the workspace selected when its last tab closes - #12142

Open
AnddyAgudelo wants to merge 1 commit into
stablyai:mainfrom
AnddyAgudelo:fix/keep-workspace-selected-when-tabs-close
Open

AnddyAgudelo wants to merge 1 commit into
stablyai:mainfrom
AnddyAgudelo:fix/keep-workspace-selected-when-tabs-close

Conversation

@AnddyAgudelo

@AnddyAgudelo AnddyAgudelo commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #11699.

Closing every tab in a workspace deselected the workspace itself. The file explorer fell back to "Select a workspace to browse files" and the Git panel emptied, so browsing files or using source control required re-clicking the workspace and opening a terminal — even when the user only wanted to read a file.

FileExplorer gates on activeWorktreeId, and seven separate close paths were clearing it once the last surface went away:

Site Path
store/slices/tabs.ts unified tab model (closeUnifiedTab)
store/slices/editor.ts ×2 closeFile and closeAllFiles
components/terminal/terminal-tab-actions.ts last terminal tab
components/Terminal.tsx last browser tab
components/tab-group/useTabGroupWorkspaceModel.ts leaveWorktreeIfEmpty (split groups)
components/terminal-pane/TerminalPaneOverlayLayer.tsx second leaveWorktreeIfEmpty

All seven now leave activeWorktreeId intact and clear only the per-surface actives (activeTabId, activeBrowserTabId, activeFileId). The two leaveWorktreeIfEmpty hooks are removed outright, along with the now-dead skipEmptyCheck option.

Why the deselection existed, and why removing it is safe now

This intentionally supersedes PR #677 ("return to Orca landing screen after closing last terminal"). That behavior existed for a good reason, recorded in tests/e2e/tab-close-navigation.spec.ts:

…require that when a worktree's last visible surface closes, the app clears activeWorktreeId instead of leaving a selected worktree with nothing to render. Any regression here shows up as a blank workspace.

That was accurate — the pane body renders only activeTab-driven content, so at zero tabs it was a tab strip over empty space. Keeping the workspace selected therefore required giving the pane something to show, so this PR adds TabGroupEmptyState: the hero icon, a short explanation that the sidebar still works, and a direct New terminal action with its platform shortcut.

Sleeping a workspace is now the route back to Landing. Deliberate deselections are untouched: sleep, worktree delete, repo removal, and folder-workspace delete.

Empty-state visibility is derived, never timed

A group also sits at zero tabs before its first tab arrives — ensureWorktreeRootGroup creates the root group first, and a runtime-owned worktree then waits on the host's session-tab mirror for however long the link takes. Showing the empty state during that wait would be wrong.

Visibility is workspaceSessionReady && tabCount === 0 && !awaitingFirstHostTabs, where awaitingFirstHostTabs is true only while a runtime-owned worktree has no published snapshot epoch. No timer and no mount-local state — a reparent or reload can't strand the pane with no way to open a terminal. Because the epoch lives in module-local maps and a zero-tab snapshot moves no store slice, web-session-tabs-sync.ts gained an explicit publication subscription consumed via useSyncExternalStore; without it the predicate would be correct but the UI would never re-ask.

Screenshots

Light-mode full window — the workspace stays highlighted in the sidebar and the file explorer stays populated, which is the reported bug going away. Plus light/dark detail shots of the empty state.

Testing

  • pnpm lint
  • pnpm typecheck
  • pnpm test
  • pnpm build
  • Added or updated high-quality tests that would catch regressions

New/updated coverage:

  • tab-group-empty-state-visibility.test.ts — the visibility predicate, including a remote worktree resting at zero tabs reading as empty rather than perpetually pending, and remount-invariance.
  • tab-group-empty-state-reactivity.test.tsx — renders the real hook and publishes a zero-tab snapshot through the real sync path, asserting the empty state appears with no other state change. Pure-predicate tests can't catch a missing subscription; this one can.
  • useTabGroupWorkspaceModel.focus.test.ts — last terminal tab, last browser tab, sole group closed, and two-group split where only the emptied pane collapses.
  • tabs.test.ts, store-cascades.test.ts, editor.test.ts, terminal-tab-actions-kill-all.test.ts — existing assertions that encoded the old deselection are retitled and inverted, each with a Why: pointing at this issue. Also a remote/host-owned close case.
  • tests/e2e/tab-close-navigation.spec.ts — the last-surface test now asserts the workspace stays selected and the empty state offers a terminal. Its header documented fix: return to Orca landing screen after closing last terminal #677's rationale as current truth and has been rewritten.

Verified fail-on-main: the four behavior test files produce 7 failures against main's source and pass on this branch.

AI Review Report

Reviewed across five rounds, each verified against the code rather than accepted on assertion.

  • Cross-platform (macOS/Linux/Windows): no metaKey hardcoding. The shortcut chip renders through useShortcutKeyDetails + ShortcutKeyCombo, which resolve the platform internally — the screenshots show Ctrl + T with the Linux/Windows + separator; macOS renders adjacent glyphs. No new path handling.
  • SSH / remote / local: the first design used a 250ms settle timer, which would have flashed the empty state mid-hydration on any link slower than the timeout — rejected. The second used a mount-local latch, which a reload would reset and strand a remote workspace with no recovery affordance; shouldBootstrapInitialWebRuntimeTerminal's guard is closure-local and consumed once, so a remote workspace resting at zero tabs is a real state, and this fix makes it a common one. Both replaced by the derived predicate above. terminal-tab-actions.ts's host-authoritative branch returns before the last-tab logic and is covered by its own test.
  • Folder workspaces: the empty state's action routes through openNewTerminalTabInActiveWorkspace, the same action the tab bar's + uses, which already handles folder-workspace keys and remote runtimes. No new assumption that a workspace is a git worktree.
  • Git providers: copy says "Git", matching the source-control sidebar tab. Nothing GitHub-specific.
  • Performance: no new polling or timers. One useSyncExternalStore subscription per mounted tab group, notified only when a session-tab snapshot is recorded or cleared.
  • UI quality: docs/STYLEGUIDE.md throughout — size-7 hero icon (its documented empty-state use), muted-foreground, outline/sm button, lucide SquareTerminal, no invented values. Rendered and inspected in both themes; the shortcut chip was regrouped after review because it read as a floating element rather than the button's shortcut.
  • Caught in review: the E2E suite is not part of pnpm test. Running it revealed the fix was incomplete — the two editor.ts sites were still deselecting, so closing the last editor reproduced the original bug on a path no unit test covered.

Security Audit

No new IPC surface, no command execution, no path handling, no auth or credential handling, no network calls, and no new dependencies. Changes are renderer-local state transitions plus one in-process listener set (publicationListeners), whose unsubscribe is returned to useSyncExternalStore and cleared on all three teardown paths, so listeners can't accumulate across worktree or environment churn. The empty state renders static translated copy — no user-controlled interpolation. No follow-up needed.

Notes

  • Reverses PR fix: return to Orca landing screen after closing last terminal #677 deliberately; sleeping a workspace is now the route back to Landing.
  • One narrow, pre-existing limitation left alone: if a runtime-owned host never publishes a snapshot at all (unreachable remote), the pane stays blank. That is a disconnected-runtime state surfaced by TerminalRemoteRuntimeReconnectBanner / TerminalSshReconnectOverlay; the empty state was deliberately not made to double as a connectivity error surface.
  • A throwaway Playwright spec produced the screenshots and was intentionally left out of the commit — its behavioral assertion is already covered by tab-close-navigation.spec.ts, and it added ~19s to CI for evidence capture.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Closing the final terminal, browser, or editor surface now preserves the active worktree and clears surface selections. Tab-group and terminal close paths no longer deactivate empty worktrees. A new empty tab-group component provides translated content, shortcut labels, and a “New terminal” action. Runtime-owned worktrees wait for host tab synchronization before showing the state. Tests cover store behavior, close flows, synchronization, rendering, and end-to-end navigation.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preserving workspace selection after the last tab closes.
Description check ✅ Passed The description covers the required summary, screenshots, testing, AI review, security audit, and notes sections with sufficient detail.
Linked Issues check ✅ Passed The changes preserve active workspace selection and keep file browsing and Git access available after all terminal or agent tabs close, satisfying issue #11699.
Out of Scope Changes check ✅ Passed The changes remain within scope by updating related close paths, adding the required empty state, and extending regression coverage.

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.

@greptile-apps

greptile-apps Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes issue #11699, where closing every tab in a workspace inadvertently deselected the workspace itself — collapsing the file explorer and Git panel until the user re-clicked the workspace. The fix removes seven setActiveWorktree(null) call sites across terminal, browser, editor, and split-group close paths, and introduces TabGroupEmptyState so the now-zero-tab pane still renders something useful.

  • State change: worktreeBecameEmpty no longer clears activeWorktreeId/activeWorkspaceKey; only per-surface actives (activeTabId, activeBrowserTabId, activeFileId) are reset when all tabs close.
  • Empty state: TabGroupEmptyState is shown via a derived useTabGroupEmptyStateVisible hook that suppresses display for runtime-owned worktrees still awaiting their first host snapshot, using useSyncExternalStore against a new module-local publication listener set so a zero-tab remote snapshot (which moves no store slice) still triggers a re-render.
  • E2E / unit coverage: The old test asserting activeWorktreeId === null after closing the last surface is inverted; seven other test assertions encoding the old deselection are retitled and flipped, each annotated with Why: issue #11699.

Confidence Score: 5/5

Safe to merge. All seven deselection sites are patched consistently, the empty-state visibility predicate is purely derived (no timers, no mount-local state), and the publication subscription is correctly unsubscribed by useSyncExternalStore on unmount.

The change is surgical — it removes setActiveWorktree(null) from seven well-identified call sites and adds a visibility guard that prevents the empty state from appearing while a remote worktree is still hydrating. Each modified close path has a matching test that was previously asserting the old deselection and is now asserting the new workspace-stays-selected invariant. The useSyncExternalStore subscription covers the zero-tab remote-snapshot case that store-slice selectors cannot observe. No untested code paths were found, and no deliberate deselection paths (sleep, worktree delete, repo removal) were altered.

Files Needing Attention: No files require special attention. The most structurally novel file is tab-group-empty-state-visibility.ts, which is fully covered by both a pure-predicate test and a reactivity test that exercises the real sync path.

Important Files Changed

Filename Overview
src/renderer/src/store/slices/tabs.ts Renames shouldDeactivateWorktree to worktreeBecameEmpty and removes activeWorktreeId/activeWorkspaceKey/activeWorkspaceExecutionHostId from the zero-tab clear path, keeping workspace selection intact while still clearing per-surface actives.
src/renderer/src/store/slices/editor.ts Both closeFile and closeAllFiles no longer clear activeWorktreeId when the last editor closes; only activeBrowserTabId and per-surface state is cleared.
src/renderer/src/components/tab-group/tab-group-empty-state-visibility.ts New module implementing shouldShowTabGroupEmptyState, isAwaitingFirstHostTabs, and useTabGroupEmptyStateVisible hook using useSyncExternalStore against the publication epoch store.
src/renderer/src/components/tab-group/TabGroupEmptyState.tsx New empty-state component shown when all tabs are closed; renders a New terminal button with platform-resolved shortcut chip via useShortcutKeyDetails.
src/renderer/src/components/tab-group/TabGroupPanel.tsx Wires useTabGroupEmptyStateVisible(worktreeId, model.groupTabs.length) and conditionally renders TabGroupEmptyState.
src/renderer/src/runtime/web-session-tabs-sync.ts Adds publicationListeners Set, subscribeWebSessionTabsPublication, and notifyWebSessionTabsPublication covering all epoch-change paths with correct unsubscribe semantics.
src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts Removes leaveWorktreeIfEmpty callback and skipEmptyCheck option from closeItem/closeGroup; setActiveWorktree is no longer consumed.
src/renderer/src/components/terminal/terminal-tab-actions.ts Removes the final setActiveWorktree(null) fallback when no browser tab follows the last terminal.
src/renderer/src/components/Terminal.tsx Browser-tab close path now calls setActiveTabType('terminal') instead of setActiveWorktree(null) when no other surface exists.
src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx Removes the second leaveWorktreeIfEmpty implementation and stops passing it as a prop to TerminalOverlaySlot.
tests/e2e/tab-close-navigation.spec.ts E2E test inverted to assert workspace stays selected and empty state is visible after closing the last surface.

Sequence Diagram

sequenceDiagram
    participant User
    participant CloseHandler as Close Handler
    participant Store as Zustand Store
    participant TabGroupPanel as TabGroupPanel
    participant VisibilityHook as useTabGroupEmptyStateVisible
    participant SyncSource as web-session-tabs-sync

    User->>CloseHandler: Close last tab
    CloseHandler->>Store: closeTab / closeFile / closeBrowserTab
    Note over Store: worktreeBecameEmpty = true
    Note over Store: Clears activeTabId, activeFileId, activeBrowserTabId
    Note over Store: Does NOT clear activeWorktreeId
    Store-->>TabGroupPanel: "groupTabs.length = 0"
    TabGroupPanel->>VisibilityHook: useTabGroupEmptyStateVisible(worktreeId, 0)
    VisibilityHook->>Store: workspaceSessionReady?
    VisibilityHook->>SyncSource: isAwaitingFirstHostTabs?
    alt Local worktree
        SyncSource-->>VisibilityHook: false
        VisibilityHook-->>TabGroupPanel: "emptyStateVisible = true"
        TabGroupPanel-->>User: TabGroupEmptyState + New Terminal button
    else Remote worktree awaiting first snapshot
        SyncSource-->>VisibilityHook: true
        VisibilityHook-->>TabGroupPanel: "emptyStateVisible = false"
        Note over SyncSource: Host publishes zero-tab snapshot
        SyncSource->>SyncSource: notifyWebSessionTabsPublication()
        SyncSource-->>VisibilityHook: epoch now set
        VisibilityHook-->>TabGroupPanel: "emptyStateVisible = true"
        TabGroupPanel-->>User: TabGroupEmptyState + New Terminal button
    end
Loading

Reviews (2): Last reviewed commit: "fix(workspace): keep the workspace selec..." | Re-trigger Greptile

Comment on lines +2348 to 2349
const worktreeBecameEmpty =
browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Same simulator-tab gap exists in closeAllFiles. browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0 returns true when only simulator unified tabs remain. The consequence is the same as in closeFile — activeBrowserTabId is unnecessarily set to null (already null with no browser tabs) — and is benign today, but for correctness the check should include simulators.

Suggested change
const worktreeBecameEmpty =
browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0
const simulatorTabsForWorktree = (s.unifiedTabsByWorktree[activeWorktreeId] ?? []).filter(
(item) => item.contentType === 'simulator'
)
const worktreeBecameEmpty =
browserTabsForWorktree.length === 0 &&
terminalTabsForWorktree.length === 0 &&
simulatorTabsForWorktree.length === 0

@AnddyAgudelo

Copy link
Copy Markdown
Contributor Author
tab-group-empty-state-dark-detail tab-group-empty-state-dark-window tab-group-empty-state-light-detail tab-group-empty-state-light-window

Closing every terminal/agent/browser/editor tab deselected the workspace,
which emptied the file explorer ("Select a workspace to browse files") and
the Git panel until the user re-opened the workspace and spawned a terminal.

Seven last-tab-close paths now leave `activeWorktreeId` intact: the unified
tab slice, editor.ts's two `shouldDeactivateWorktree` branches (closeFile and
closeAllFiles), the legacy terminal and browser close handlers, and the two
`leaveWorktreeIfEmpty` split-group hooks (now removed outright).

The zero-tab pane body previously rendered nothing, so tab groups gain a
quiet empty state with a direct "New terminal" action and its platform
shortcut. Its visibility is derived from observable state, never a clock and
never mount-local state: `ensureWorktreeRootGroup` creates the root group
before its first tab, and a runtime-owned worktree then waits on the host
session-tab mirror, so the empty state is suppressed for exactly that wait.
That wait ends on a publication epoch held in module-local maps, which move
no store slice when the snapshot carries zero tabs, so `web-session-tabs-sync`
publishes an explicit subscription consumed via useSyncExternalStore.

This supersedes PR stablyai#677 ("return to Orca landing screen after closing last
terminal"). stablyai#677 deselected precisely because a selected workspace with no
surfaces rendered blank; the empty state removes that failure mode, so the
landing fallback is no longer needed. Its E2E guard in
tab-close-navigation.spec.ts now asserts the new contract, and sleeping a
workspace is the route back to Landing.

Fixes stablyai#11699

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AnddyAgudelo
AnddyAgudelo force-pushed the fix/keep-workspace-selected-when-tabs-close branch from 9c35739 to c38d94c Compare August 3, 2026 03:09

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

🧹 Nitpick comments (1)
src/renderer/src/components/tab-group/TabGroupEmptyState.tsx (1)

7-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce the component comment.

This comment repeats behavior that TabGroupEmptyState makes clear. Remove it, or replace it with one concise line that documents only the non-obvious retention requirement.

As per coding guidelines, “Comments must be concise, limited to non-obvious information, and preferably one line.”

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3723790-356e-4615-a312-1079045aa558

📥 Commits

Reviewing files that changed from the base of the PR and between 9c35739058f226c33eb0a4b9852c151ce4ca3d81 and c38d94c.

📒 Files selected for processing (23)
  • src/renderer/src/components/Terminal.tsx
  • src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
  • src/renderer/src/components/tab-group/TabGroupEmptyState.test.tsx
  • src/renderer/src/components/tab-group/TabGroupEmptyState.tsx
  • src/renderer/src/components/tab-group/TabGroupPanel.tsx
  • src/renderer/src/components/tab-group/tab-group-empty-state-reactivity.test.tsx
  • src/renderer/src/components/tab-group/tab-group-empty-state-visibility.test.ts
  • src/renderer/src/components/tab-group/tab-group-empty-state-visibility.ts
  • src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts
  • src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
  • src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx
  • src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx
  • src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
  • src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts
  • src/renderer/src/components/terminal/terminal-tab-actions.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/runtime/web-session-tabs-sync.ts
  • src/renderer/src/store/slices/editor.test.ts
  • src/renderer/src/store/slices/editor.ts
  • src/renderer/src/store/slices/store-cascades.test.ts
  • src/renderer/src/store/slices/tabs.test.ts
  • src/renderer/src/store/slices/tabs.ts
  • tests/e2e/tab-close-navigation.spec.ts
💤 Files with no reviewable changes (2)
  • src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx
  • src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx
🚧 Files skipped from review as they are similar to previous changes (20)
  • src/renderer/src/components/shared/kill-all-terminal-surfaces.ts
  • src/renderer/src/components/Terminal.tsx
  • src/renderer/src/store/slices/tabs.test.ts
  • src/renderer/src/store/slices/editor.test.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/components/tab-group/TabGroupPanel.tsx
  • tests/e2e/tab-close-navigation.spec.ts
  • src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
  • src/renderer/src/store/slices/tabs.ts
  • src/renderer/src/components/tab-group/tab-group-empty-state-visibility.ts
  • src/renderer/src/runtime/web-session-tabs-sync.ts
  • src/renderer/src/components/tab-group/tab-group-empty-state-reactivity.test.tsx
  • src/renderer/src/components/tab-group/TabGroupEmptyState.test.tsx
  • src/renderer/src/store/slices/editor.ts
  • src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx
  • src/renderer/src/components/terminal/terminal-tab-actions.ts
  • src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts
  • src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts
  • src/renderer/src/components/tab-group/tab-group-empty-state-visibility.test.ts
  • src/renderer/src/store/slices/store-cascades.test.ts

@AmethystLiang

Copy link
Copy Markdown
Contributor

Thank you so much, @AnddyAgudelo, for the care and depth of this contribution: tracing the close paths, explaining the original Landing behavior, handling remote readiness, and adding regression coverage and screenshots. We referred to your empty-state implementation and analysis while comparing the community work with the launch UI that has now landed in #32182.

I have added a named acknowledgment and a link to this PR in #32182. The empty-pane action screen has landed there, but your main behavior change—keeping a workspace selected after its last tab closes—has not. #11699 and #17682 still track that separate requirement, and #32182’s description now states that distinction explicitly.

Thank you again for the implementation and unusually thorough investigation. We appreciate the work you put into making this behavior understandable and testable.

This branch has not been deployed

No deployments
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.

[Bug]: Closing all terminals clears center panel file browser; must re-open workspace to browse files

4 participants