15 KiB
APP-3828: Tech Spec — Vertical Tabs View as Panes / Tabs
Problem
APP-3828 adds a new View as control to the vertical tabs options popup. The product behavior is intentionally narrow:
Panespreserves the current pane-centric renderingTabsrenders one representative row per tab- the representative row is the existing row UI for that tab’s active pane (
Focused session) - the current compact / expanded density controls and pane-row display controls remain in place
Technically, the current vertical tabs implementation has no abstraction for “row granularity.” It assumes that every visible pane in a PaneGroup should be rendered and searched independently. The new feature therefore needs a low-risk way to:
- add a new synced setting
- wire a new popup control into existing action / settings flow
- centralize the decision of “which pane ids should this tab render/search right now?”
- keep the existing row renderers unchanged as much as possible
Relevant code
specs/APP-3828/PRODUCT.md— agreed user-facing behavior for this featureapp/src/workspace/tab_settings.rs (171-276)— current synced vertical-tabs settings (VerticalTabsViewMode,VerticalTabsPrimaryInfo,VerticalTabsCompactSubtitle)app/src/workspace/action.rs (237-241)— existing vertical-tabs popup actionsapp/src/workspace/action.rs (740-741)—should_save_app_state_on_actioncoverage for the current vertical-tabs setting actionsapp/src/workspace/view.rs (17761-17810)— workspace-side action handlers that updateTabSettingsapp/src/workspace/view/vertical_tabs.rs (246-344)—VerticalTabsPanelStateand popup mouse-state ownershipapp/src/workspace/view/vertical_tabs.rs (458-483)—matching_tab_indices, which currently assumes every visible pane can make a tab searchableapp/src/workspace/view/vertical_tabs.rs (630-799)—render_groups, including current search filtering behaviorapp/src/workspace/view/vertical_tabs.rs (800-1034)—render_tab_group, which currently renders one row per visible paneapp/src/workspace/view/vertical_tabs.rs (1569-1664)—PaneProps::newand query matching helpers used by render/searchapp/src/workspace/view/vertical_tabs.rs (2380-2664)—render_settings_popup, including the current top-of-popup density segmented control and the existing secondary controlsapp/src/workspace/view/vertical_tabs.rs (2845-3033)—render_compact_pane_row; compact density reusesPanePropsapp/src/workspace/view/vertical_tabs.rs (1160-1250)—render_pane_row; expanded density reusesPanePropsapp/src/pane_group/mod.rs:1981—PaneGroup::focused_pane_id, the current source of truth for the pane last focused within a tabapp/src/pane_group/mod.rs (4566-4571)—PaneGroup::display_title, which already derives tab-level display state from the focused paneapp/src/workspace/view/vertical_tabs_tests.rs (1-196)— current unit-test home for vertical-tabs pure logicapp/src/workspace/action_tests.rs (1-36)— current tests for vertical-tabs action persistence behavior
Current state
Settings and actions
Vertical-tabs display preferences already follow a consistent pattern:
- the setting enum lives in
TabSettings - the enum is registered with
implement_setting_for_enum! - the popup dispatches a
WorkspaceAction::* Workspace::handle_actionwrites the new value intoTabSettingsshould_save_app_state_on_actionreturnsfalse, because persistence is handled by the settings framework rather than workspace snapshotting
This pattern currently exists for:
VerticalTabsViewMode= compact vs expanded densityVerticalTabsPrimaryInfoVerticalTabsCompactSubtitle
Popup structure
render_settings_popup currently starts with the compact / expanded segmented control, then renders the existing pane-row controls below it. There is no concept of a top-level “what does each row represent?” setting.
Row rendering
render_tab_group obtains visible_pane_ids() from the PaneGroup, builds PaneProps for each one, and then delegates to either:
render_compact_pane_rowrender_pane_row
Those row renderers already contain the exact UI we want to reuse in Tabs mode.
Search behavior
The current search flow is duplicated in two places:
matching_tab_indicesdecides which tabs are included in keyboard navigation / search result bookkeepingrender_groupscomputesmatching_idsto decide which rows to render while searching
Both paths iterate all visible_pane_ids() and check each pane independently with PaneProps::new plus pane_matches_query.
Tab-level “active pane” state
For this feature, the correct tab representative is not active_session_id() because tabs may be backed by non-terminal panes. The right primitive is PaneGroup::focused_pane_id():
- it works for any pane type
- it already tracks the pane that would be focused when the tab becomes active again
PaneGroup::display_title()already treats the focused pane as the tab-level source of truth
That makes focused_pane_id() the right backing state for “Focused session” in Tabs mode.
Proposed changes
1. Add a new synced setting for row granularity
Add a new enum in app/src/workspace/tab_settings.rs:
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsDisplayGranularity {
#[default]
Panes,
Tabs,
}
Register it in TabSettings with the same sync / hierarchy behavior as the existing vertical-tabs settings:
SupportedPlatforms::ALLSyncToCloud::Globally(RespectUserSyncSetting::Yes)hierarchy: "appearance.tabs"
Deliberately do not rename the existing VerticalTabsViewMode enum in this ticket. It already means compact vs expanded density in code, and renaming it would create avoidable churn across unrelated logic. The popup can relabel that control to Density without touching the backing enum name yet.
2. Add a workspace action for the new setting
Add WorkspaceAction::SetVerticalTabsDisplayGranularity(VerticalTabsDisplayGranularity) beside the existing vertical-tabs setting actions.
Handle it in Workspace::handle_action exactly like the other setting writes:
- read the enum payload
- call
settings.vertical_tabs_display_granularity.set_value(...) ctx.notify()
Update should_save_app_state_on_action so this action returns false, and add a matching unit test in action_tests.rs.
3. Extend popup-local state for the new segmented control
Add two MouseStateHandles to VerticalTabsPanelState for the new control:
- one for the
Panessegment - one for the
Tabssegment
Keep the existing compact_segment_mouse_state / expanded_segment_mouse_state fields unchanged so the density control continues to work without refactoring unrelated code.
4. Restructure render_settings_popup
Update render_settings_popup so the popup composition becomes:
View asheader- text segmented control for
Panes/Tabs - divider
Densityheader- existing compact / expanded icon segmented control
- existing pane-row display sections (
Pane title as,Additional metadata,Show) in their current conditional behavior
Implementation details:
- add a new helper for a text-labeled segment control rather than forcing the existing icon helper to serve both roles
- keep the popup width fixed unless the new control proves to clip; the current 200px width is likely enough for
Panes/Tabs - leave the popup open after clicking
PanesorTabs, matching the existing “update in place” behavior of the other controls - do not hide
Pane title as,Additional metadata, orShowwhenTabsis selected; the representative row still uses pane-row rendering, so those controls continue to apply in both granularities - keep the existing conditional logic that is already based on density (
Additional metadataonly in compact,Showonly in expanded)
The current compact / expanded segmented control should keep writing VerticalTabsViewMode; only the user-facing label changes to Density.
5. Centralize pane-id selection by granularity
Add a small helper in vertical_tabs.rs that decides which pane ids a tab should expose for rendering and search:
fn pane_ids_for_display_granularity(
visible_pane_ids: &[PaneId],
focused_pane_id: PaneId,
granularity: VerticalTabsDisplayGranularity,
) -> Vec<PaneId>
Behavior:
Panesreturns all visible pane ids in existing orderTabsreturns exactly one pane id:focused_pane_idif it is present invisible_pane_ids- otherwise the first visible pane as a defensive fallback
- otherwise an empty vec if the tab has no visible panes
This helper should be pure and small enough to unit test in vertical_tabs_tests.rs.
6. Use the helper in both render and search paths
Read the new setting once in each relevant call path and replace direct iteration of visible_pane_ids() with pane_ids_for_display_granularity(...).
Affected paths:
matching_tab_indices- the search branch inside
render_groups - the row-building path inside
render_tab_group
This keeps the meaning of Tabs mode consistent everywhere:
- a tab renders only its representative row
- search only considers that representative row
- tabs hidden by search are determined by the same representative row
This is the most important structural change in the ticket. It is also intentionally narrow: the existing row building stays pane-based, but the set of pane ids fed into it changes.
7. Keep PaneProps and row renderers unchanged
Do not introduce a new “tab row” prop type in this ticket.
Instead, continue to build a normal PaneProps from the chosen representative PaneId and reuse:
render_pane_rowrender_compact_pane_rowrender_pane_row_element
This preserves:
- row click behavior (
FocusPane) - existing metadata and badge rules
- compact / expanded density behavior
- selection and hover styling
- future compatibility with any pane-row improvements already in flight on this branch
8. Use focused_pane_id() as the representative source of truth
In render_tab_group and search helpers, compute the representative pane from:
pane_group.visible_pane_ids()pane_group.focused_pane_id(app)
Do not use active_session_id():
- it is terminal-only
- it would fail for code / notebook / workflow tabs
Using focused_pane_id() also ensures the representative row updates automatically when focus changes within a split tab, because that state is already maintained by PaneGroup::focus_pane.
9. Keep header and action-belt behavior untouched
render_tab_group currently owns more than just the rows: it also owns the group container, optional custom-title header, hover background, and overlay action belt. This ticket should not fork that structure for Tabs mode.
Only the row list inside the body changes. Everything else in render_tab_group stays as-is.
That matches the product scope and lowers regression risk around rename, tab actions, and drag behavior.
End-to-end flow
- The user opens the vertical-tabs popup from the settings icon.
render_settings_popupreadsvertical_tabs_display_granularityand showsPanesselected by default.- The user clicks
Tabs. WorkspaceAction::SetVerticalTabsDisplayGranularity(Tabs)is dispatched.Workspace::handle_actionwrites the synced setting throughTabSettingsand callsctx.notify().- On re-render,
render_groupsandrender_tab_groupboth read the new setting. - For each tab:
- the code gets
visible_pane_ids() - gets
focused_pane_id() - runs
pane_ids_for_display_granularity(...) - receives either all panes (
Panes) or exactly one representative pane (Tabs)
- the code gets
- That representative pane is passed through
PaneProps::newand then through the existing compact / expanded row renderer. - Clicking the representative row still dispatches
FocusPane, which activates the tab and focuses that pane. - If the user changes focus within a split tab,
PaneGroup::focus_paneupdatesfocused_pane_id(), and the next render shows a different representative row automatically.
Risks and mitigations
Ambiguity between existing VerticalTabsViewMode and the new product “View as”
Risk:
- the current code uses
VerticalTabsViewModeto mean density, while the product copy now usesView asto mean pane-vs-tab granularity
Mitigation:
- introduce a separately named enum (
VerticalTabsDisplayGranularity) instead of overloadingVerticalTabsViewMode - relabel the existing control in UI only
Stale focused pane not present in visible panes
Risk:
- during close / restore edge cases,
focused_pane_id()might not be present in the visible pane list momentarily
Mitigation:
pane_ids_for_display_granularity(...)falls back to the first visible pane- the helper returns an empty vec only when the tab truly has no visible panes
Search/render drift
Risk:
- Tabs mode could render one pane but still search across all panes if the two code paths diverge
Mitigation:
- use the same granularity helper in
matching_tab_indices,render_groups, andrender_tab_group - keep pane-id selection in one place
Popup churn beyond scope
Risk:
- the Figma exploration also shows Tabs-only secondary controls, which could tempt additional conditional popup logic
Mitigation:
- explicitly keep this ticket to the top-level
View ascontrol only - do not add a Tabs-only
Default nameorSummarysection in this implementation - do not hide the existing pane-row controls when
Tabsis selected; they remain relevant because the representative row is still a pane row
Testing and validation
Unit tests
In app/src/workspace/view/vertical_tabs_tests.rs:
- add tests for
pane_ids_for_display_granularity(...)Panesreturns all visible panes in orderTabsreturns the focused pane when presentTabsfalls back to the first visible pane when the focused pane is absent- empty visible list returns empty
In app/src/workspace/action_tests.rs:
- add a test that
SetVerticalTabsDisplayGranularity(...)does not save workspace state
Manual validation
- verify popup layout now shows
View asfirst andDensityabove the existing compact / expanded toggle - verify
Panesremains default and current behavior is unchanged - verify a multi-pane tab shows one representative row in
Tabsmode - verify switching focus within a split tab changes the representative row
- verify compact and expanded densities both work in
Tabsmode - verify existing
Pane title as,Additional metadata, andShowpreferences remain visible in the popup inTabsmode and still affect the representative row as expected - verify search in
Tabsmode only matches the representative row, not hidden non-active panes - verify the setting persists across relaunch
Follow-ups
- Rename
VerticalTabsViewModeto something density-specific in code if we want terminology to match the product UI more closely. This is not necessary for APP-3828. - Add the future Tabs-only naming control (
Focused sessionvsSummary) in a separate ticket once product behavior is finalized. At that point, pane-row-specific controls can become conditional on the focused-session path rather than always being shown forTabs.