Files
galaxy/specs/harryalbert/APP-3865/TECH.md
T

20 KiB
Raw Blame History

TECH.md — Configurable Toolbar Chips (Header Toolbar)

Product spec: specs/harryalbert/APP-3865/PRODUCT.md

Problem

The header bar (in both horizontal and vertical tabs mode) renders panel toggle buttons in a hardcoded order and side assignment. We need to make these configurable (reorderable, moveable between sides, hideable) using the same ChipConfigurator pattern already used for the agent input footer. Critically, the side a button is placed on determines which side its panel opens on and the order determines the panel stacking order.

The entire feature is gated behind FeatureFlag::ConfigurableToolbar (enabled by default in all release builds).

Relevant code

Panel rendering (two-layer system that needs unification)

  • app/src/workspace/view.rs:16993render_panels: outer layer composing VTabs | theme chooser | Main | right panels.
  • app/src/workspace/view.rs:16545render_banner_and_active_tab: inner layer composing LeftPanel(tools) | Terminal | RightPanel(code review).
  • app/src/workspace/view/vertical_tabs.rs:1120render_vertical_tabs_panel: renders the vertical tabs sidebar with a Resizable wrapper and DragBarSide::Right.

Panel toggle state

  • app/src/pane_group/mod.rs:843PaneGroup::left_panel_open: bool (tools panel)
  • app/src/pane_group/mod.rs:845PaneGroup::right_panel_open: bool (code review)
  • app/src/workspace/view.rsWorkspace::vertical_tabs_panel_open: bool
  • app/src/workspace/view.rsWorkspaceState::is_agent_management_view_open: bool (replaces main content)
  • app/src/workspace/view.rsWorkspaceState::is_notification_mailbox_open: bool (popover)

Panel toggle logic

  • app/src/workspace/view.rs:6711toggle_left_panel: tools panel open/close
  • app/src/workspace/view.rs:6947toggle_right_panel: code review open/close
  • app/src/workspace/view.rs:6669toggle_vertical_tabs_panel
  • app/src/workspace/view.rs:18256ToggleNotificationMailbox handler

Notification toast positioning

  • app/src/workspace/view.rs:20265 — mailbox popover anchors to NOTIFICATIONS_MAILBOX_POSITION_ID (follows button naturally).
  • app/src/workspace/view.rs:20342 — notification toasts anchor to TAB_BAR_POSITION_ID at BottomRight. This needs to become dynamic.

Toolbar button rendering (current hardcoded layout)

  • app/src/workspace/view.rs:15417render_tab_bar_contents: left-side buttons at 1552215535, right-side via add_right_side_tab_bar_controls at 15543.
  • app/src/workspace/view.rs:15639add_right_side_tab_bar_controls

Existing chip configurator infrastructure

  • app/src/chip_configurator/mod.rsChipConfigurator with LeftRightZones layout.
  • app/src/ai/blocklist/agent_view/agent_input_footer/editor.rsAgentToolbarEditorModal (template for our modal).
  • app/src/terminal/session_settings.rs:82ToolbarChipSelection trait, AgentToolbarChipSelection enum.
  • app/src/terminal/session_settings.rs:277agent_footer_chip_selection setting.

Onboarding

  • app/src/settings/onboarding.rs:62apply_ui_customization_settings

Current state

Panel rendering: two-layer system

The workspace renders panels in two separate composition steps:

Outer layer (render_panels, line 16993):

Flex::row: [ VTabs panel | theme chooser(left) | Main content | resource center/AI assistant(right) ]

Inner layer (render_banner_and_active_tab, line 16545):

Flex::row: [ LeftPanelView(tools) | terminal_content | RightPanelView(code review) ]

The vertical tabs panel sits OUTSIDE the left/right panel pair, at a different level. Tools panel and code review are inside render_banner_and_active_tab. This two-layer split exists for a reason:

Per-tab vs workspace-level state: The inner layer panels (tools, code review) have per-tab open state — PaneGroup::left_panel_open and PaneGroup::right_panel_open are on PaneGroup, and each tab has its own PaneGroup. The tools panel can be open in tab 1 and closed in tab 2. The outer layer panels (vertical tabs, theme chooser, resource center) have workspace-level state that persists across tab switches.

To support arbitrary ordering, we need all configurable panels at the same composable level while preserving the per-tab open state for tools panel and code review. The WorkspaceState::is_left_panel_open() method (workspace/util.rs:206) only checks theme chooser state; is_right_panel_open() (util.rs:202) only checks resource center / AI assistant. These are workspace-global concerns separate from the configurable toolbar panels.

Panel types and their current rendering

Each panel type has distinct rendering and state management:

  • Vertical tabs (render_vertical_tabs_panel): Resizable wrapper with DragBarSide::Right. Self-contained rendering in vertical_tabs.rs. Open state: Workspace::vertical_tabs_panel_open.
  • Tools panel (LeftPanelView): rendered via ChildView::new(&self.left_panel_view). Has its own Resizable wrapper inside LeftPanelView. Open state: PaneGroup::left_panel_open.
  • Code review (RightPanelView): rendered via ChildView::new(&self.right_panel_view). Has its own Resizable wrapper. Open state: PaneGroup::right_panel_open. Can be maximized.
  • Agent management: replaces the terminal content area entirely (ChildView::new(&self.agent_management_view) at line 16555). Not a side panel.
  • Notifications mailbox: popover overlay anchored to NOTIFICATIONS_MAILBOX_POSITION_ID. Not a side panel.

Toolbar button rendering

In the vertical tabs branch of render_tab_bar_contents (line 15522):

  • Left side: tabs panel toggle, tools panel toggle, agent management toggle
  • Right side (via add_right_side_tab_bar_controls): code review toggle, notifications mailbox, avatar

These are hardcoded in order and side.

Proposed changes

1. New HeaderToolbarItemKind enum

New file: app/src/workspace/header_toolbar_item.rs

#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum HeaderToolbarItemKind {
    TabsPanel,
    ToolsPanel,
    AgentManagement,
    CodeReview,
    NotificationsMailbox,
}

Methods:

  • display_label(&self) -> &str — human-readable name for the configurator.
  • icon(&self) -> Icon — icon for the configurator chip.
  • is_available(app: &AppContext) -> bool — checks feature flags and prerequisite settings.
  • default_left() -> Vec<Self>[TabsPanel, ToolsPanel, AgentManagement].
  • default_right() -> Vec<Self>[CodeReview, NotificationsMailbox].
  • all_items() -> Vec<Self> — all variants (availability filtering done at call site).
  • is_panel(&self) -> bool — true for items that open a side panel (TabsPanel, ToolsPanel, CodeReview). False for AgentManagement (replaces content) and NotificationsMailbox (popover).

2. New setting: HeaderToolbarChipSelection

In app/src/workspace/tab_settings.rs:

#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum HeaderToolbarChipSelection {
    #[default]
    Default,
    Custom {
        left: Vec<HeaderToolbarItemKind>,
        right: Vec<HeaderToolbarItemKind>,
    },
}

With left_items(), right_items() methods that resolve Default to hardcoded defaults. Add to TabSettings under appearance.tabs hierarchy.

3. Flatten the panel rendering into a single composition step

This is the core architectural change. Currently panels render at two levels. We unify them.

Replace the two-layer system with a single render_configurable_panels method:

In render_banner_and_active_tab (or a new unified method), build the panel row dynamically:

let config = resolved toolbar config;
let left_items = config.left_items filtered by is_available;
let right_items = config.right_items filtered by is_available;

Flex::row:
  for item in left_items where item.is_panel() && item.is_open():
    render_panel_for_item(item, PanelPosition::Left)
  Main content (terminal or agent management)
  for item in right_items where item.is_panel() && item.is_open():
    render_panel_for_item(item, PanelPosition::Right)

Where render_panel_for_item dispatches to:

  • TabsPanelrender_vertical_tabs_panel (with DragBarSide flipped if on right)
  • ToolsPanelChildView::new(&self.left_panel_view) (with drag bar side adjusted)
  • CodeReviewChildView::new(&self.right_panel_view) (with drag bar side adjusted)

This moves vertical tabs out of render_panels and tools/code review out of render_banner_and_active_tab, into a unified composition.

Preserving per-tab state: The unified rendering must still read tools panel open state from PaneGroup::left_panel_open and code review from PaneGroup::right_panel_open (per-tab), while reading vertical tabs from Workspace::vertical_tabs_panel_open (workspace-level). The rendering loop checks the appropriate open state per item type, regardless of which side it's configured on.

Drag bar side: When a panel is on the left, its drag bar (resize handle) should be on the right (DragBarSide::Right). When on the right, it should be on the left (DragBarSide::Left). The vertical tabs panel currently hardcodes DragBarSide::Right (vertical_tabs.rs:1174). Tools panel and code review panel handle this internally in their respective views — these will need a set_panel_position method or constructor parameter.

4. Adapt panel toggle logic to be side-aware

Currently toggle_left_panel and toggle_right_panel are tightly coupled to the left/right concept. We need toggle methods that are panel-type-aware rather than side-aware.

Add a method that resolves which side a panel is on from the config:

fn panel_side(&self, item: HeaderToolbarItemKind, app: &AppContext) -> Option<PanelPosition> {
    let config = TabSettings::as_ref(app).header_toolbar_chip_selection.value();
    if config.left_items().contains(&item) { Some(PanelPosition::Left) }
    else if config.right_items().contains(&item) { Some(PanelPosition::Right) }
    else { None }
}

The existing ToggleLeftPanel / ToggleRightPanel workspace actions continue to work — they just consult the config to determine which side the panel actually renders on. The PaneGroup::left_panel_open / right_panel_open booleans track open state independently of side placement.

5. Notification toast positioning

Currently notification toasts anchor at PositionedElementAnchor::BottomRight of TAB_BAR_POSITION_ID (view.rs:20350). When the mailbox button is on the left, this should become BottomLeft.

In the toast rendering code, read the toolbar config to determine which side the mailbox is on, and adjust the anchor accordingly.

6. Shared chip-editor modal rendering + HeaderToolbarEditorModal

The AgentToolbarEditorModal (agent_input_footer/editor.rs) and the new HeaderToolbarEditorModal (workspace/header_toolbar_editor.rs) render an identical modal UI (title, chip sections with left/right drop zones, restore-default link, cancel/save buttons, blur overlay). The duplication is purely in rendering — the domain logic (which settings to read/write, action types, event types) is genuinely different.

New file app/src/chip_configurator/modal_shell.rs provides:

  • ChipEditorModalConfig<A> — struct carrying everything that varies: title, available-section label, is_at_defaults, is_dirty, action values (cancel, save, reset, activate), chip_action_wrapper, and three MouseStateHandles.
  • render_chip_editor_modal<A>(configurator, config, appearance) -> Box<dyn Element> — free function that renders the full modal (blur overlay, centered card, title, chip sections, buttons). Generic over the action type A.

Each modal view keeps its own ChipConfigurator, MouseStateHandles, is_dirty, and domain state. Their View::render builds a ChipEditorModalConfig and calls render_chip_editor_modal. Their TypedActionView::handle_action handles save/open/reset with domain-specific logic.

HeaderToolbarEditorModal specifics:

  • On open(): reads HeaderToolbarChipSelection from TabSettings, builds ConfigurableItem list via ControlItemRenderer::new_with_label_and_icon.
  • On save(): writes back to TabSettings::header_toolbar_chip_selection. Syncs show_code_review_button and show_agent_notifications.
  • On reset_default(): restores default layout.

7. Integrate modal into Workspace

  • Add header_toolbar_editor_modal: ViewHandle<HeaderToolbarEditorModal> field.
  • Add WorkspaceAction::OpenHeaderToolbarEditor.
  • Add is_header_toolbar_editor_open to WorkspaceState.
  • Render in the overlay stack.

8. Refactor render_tab_bar_contents to read from settings

Replace hardcoded button rendering in the vertical tabs branch with a loop:

let config = TabSettings::as_ref(ctx).header_toolbar_chip_selection.value();
for item in config.resolved_left(ctx) {
    tab_bar.add_child(self.render_header_toolbar_button(item, appearance, ctx));
}
// ... search bar ...
for item in config.resolved_right(ctx) {
    right_controls.add_child(self.render_header_toolbar_button(item, appearance, ctx));
}

render_header_toolbar_button dispatches to existing per-item render methods.

9. Right-click context menu

Wrap toolbar items/area with an EventHandler that intercepts right-click and opens a Menu with a single "Edit toolbar" entry dispatching WorkspaceAction::OpenHeaderToolbarEditor.

10. Settings page entry point

Add an "Edit toolbar" button widget in appearance_page.rs under the "Tabs" category, dispatching WorkspaceAction::OpenHeaderToolbarEditor.

11. Show/hide setting sync on save

When saving:

  • CodeReview → sync TabSettings::show_code_review_button
  • NotificationsMailbox → sync AISettings::show_agent_notifications
  • ToolsPanel → composite (no single toggle to sync)
  • TabsPanel, AgentManagement → no existing individual show/hide setting

12. Side-aware UI element flipping

When panels move sides, all overlay/popup UI elements associated with them must flip to point toward the center of the screen. This is implemented via helpers that read HeaderToolbarChipSelection from TabSettings at render time.

tools_panel_menu_direction(app) -> MenuDirection (drive/items/item.rs): Returns MenuDirection::Right when the tools panel is on the left, MenuDirection::Left when on the right. Used by Warp Drive items, conversation list items, and the sorting button.

tabs_panel_side(app) -> PanelPosition (workspace/view.rs): Returns the side the tabs panel button is on. Used by the detail sidecar, action buttons, and right-click menu.

is_mailbox_on_left(app) -> bool (workspace/view.rs): Returns whether the mailbox button is configured on the left. Used for mailbox popover and toast anchoring.

Flipped elements:

  • Notification mailbox popover (view.rs): Anchors BottomLeft/TopLeft when on left, BottomRight/TopRight when on right.
  • Notification toasts (view.rs): Same side-aware anchoring.
  • Tabs panel detail sidecar (vertical_tabs.rs): detail_sidecar_offset_and_max_height accepts PanelPosition and flips TopRight→TopLeft / BottomRight→BottomLeft (and negates the horizontal gap) when on the right.
  • Vertical tabs action buttons (vertical_tabs.rs): Overlay position flips from TopRight to TopLeft.
  • Vertical tabs right-click menu (view.rs): Anchors flip from BottomLeft/TopLeft to BottomRight/TopRight.
  • Conversation list tooltips (conversation_list/item.rs): tooltip_opens_right field on ItemProps flips MiddleRight→MiddleLeft.
  • Conversation list kebab button + menu (conversation_list/item.rs): Button position flips from TopRight to TopLeft; MenuDirection flips.
  • Warp Drive item overflow button (drive/items/item.rs): When overflow_on_left, the button renders as a Stack overlay at MiddleLeft (flush with edge) instead of appending to the flex row. This avoids pushing item content.
  • Warp Drive hover previews and dialogs (drive/index.rs): add_row_overlay_to_stack flips the X-axis anchor pair from (Right, Left) to (Left, Right) and negates the pixel offset.
  • Panel borders (left_panel.rs, right_panel.rs): Border side and DragBarSide are driven by self.panel_position.
  • Vertical tabs panel (vertical_tabs.rs): Border and drag bar side are driven by the side parameter.

End-to-end flow

Toolbar rendering:

  1. render_tab_bar_contents reads HeaderToolbarChipSelection from TabSettings.
  2. For each configured left item (filtered by is_available), renders the corresponding button.
  3. Search bar renders in the middle.
  4. For each configured right item, renders the corresponding button.

Panel rendering:

  1. Unified panel composition reads the same config.
  2. For each left panel item that is open, renders the panel on the left of main content, in order.
  3. Main content (terminal or agent management) renders in the middle.
  4. For each right panel item that is open, renders the panel on the right, in order.

Configuration flow:

  1. User right-clicks toolbar → context menu → "Edit toolbar".
  2. WorkspaceAction::OpenHeaderToolbarEditor opens the modal.
  3. Modal reads current config, populates ChipConfigurator.
  4. User drags items, clicks save.
  5. Modal writes Custom { left, right } to TabSettings::header_toolbar_chip_selection.
  6. Toolbar and panels re-render from the new config.

Risks and mitigations

Risk: Flattening the two-layer panel system is a significant refactor that touches the core workspace layout. Mitigation: Both the vertical and horizontal tab paths use the same config-driven rendering. The default config produces identical behavior to the previous hardcoded layout, so existing users see no change unless they customize.

Risk: Resizable panel drag bars need to flip direction based on side. Mitigation: The Resizable element already accepts DragBarSide. Vertical tabs hardcodes Right at vertical_tabs.rs:1174; tools and code review panels handle it internally. We pass the side through.

Risk: PaneGroup::left_panel_open / right_panel_open naming becomes confusing when panels can be on either side. Mitigation: Keep the field names as-is (they track the tools panel and code review open state respectively, and are per-tab). Rename can be a follow-up. The side a panel renders on is determined by the toolbar config, not by which boolean tracks its state.

Risk: Flattening the panel layers might break the per-tab vs workspace-level open state distinction. Mitigation: The unified rendering explicitly reads per-tab state (PaneGroup::left_panel_open / right_panel_open) for tools and code review, and workspace-level state (Workspace::vertical_tabs_panel_open) for the tabs panel. The existing WorkspaceState::is_left_panel_open() / is_right_panel_open() methods (which only concern theme chooser and resource center) remain unchanged since those panels are not configurable.

Risk: Show/hide setting desync if user toggles settings directly outside the configurator. Mitigation: Toolbar rendering always filters by is_available(). The configurator's open() re-evaluates availability each time.

Testing and validation

  • Unit tests: HeaderToolbarChipSelection serialization, resolution, availability filtering.
  • Manual testing:
    • Move code review to left → verify it opens left of main content.
    • Move tabs panel to right → verify sidebar opens right of main content.
    • Configure left as [CodeReview, TabsPanel], open both → verify code review renders left of tabs.
    • Move notifications to left → verify toasts appear on left side.
    • Save config, restart, verify persistence.

Follow-ups

  • Rename PaneGroup::left_panel_open / right_panel_open to panel-type-specific names (e.g. tools_panel_open, code_review_panel_open).
  • Consider a dedicated show_agent_management boolean setting.
  • Flip additional secondary elements when on the right side: Warp Drive sorting button menu, create-new button menu, section header dialog positioning.
  • Vertical tabs settings popup positioning when the tabs panel is on the right side.
  • New-session dropdown menu and sidecar positioning when the tabs panel is on the right side.