Files

12 KiB

APP-3651: Tech Spec — Vertical Tabs Pane Row Layout Iteration

Problem

The vertical tabs panel pane rows need restructuring to improve information hierarchy (see specs/APP-3651/PRODUCT.md). The primary rendering functions for terminal and non-terminal pane rows, the tab group header collapse button, and text clipping direction all need to change within the same file.

Relevant code

  • app/src/workspace/view/vertical_tabs.rs — all pane row rendering; VerticalTabsPanelState, PaneProps, TypedPane, render_pane_row, render_terminal_row_content, render_terminal_primary_line, render_terminal_secondary_line, render_group_header, render_kind_badge
  • app/src/terminal/view/tab_metadata.rsterminal_title_from_shell(), display_working_directory(), current_git_branch()
  • app/src/terminal/view/pane_impl.rs:962-973selected_conversation_status(), selected_conversation_display_title(), is_ambient_agent_session()
  • app/src/terminal/model/blocks.rs:1708BlockList::blocks() returns &Vec<Block>
  • app/src/terminal/model/block.rs:2143Block::command_to_string(), Block::finished(), BlockState
  • app/src/code/view.rs:222-231CodeView::tab_group (private), CodeView::set_title (sets PaneConfiguration title/secondary)
  • app/src/code/icon.rs:11icon_from_file_path(path, appearance) -> Option<Box<dyn Element>>
  • app/src/pane_group/pane/code_pane.rs:53CodePane::file_view() returns ViewHandle<CodeView>
  • app/src/workspace/action.rs:99,227-230WorkspaceAction::CloseTab, ToggleVerticalTabsGroupCollapsed, ToggleAllVerticalTabsGroupsCollapsed
  • ui/src/text_layout.rs:455-460ClipConfig::start() already exists

Current state

Terminal pane rows

render_terminal_row_content builds three lines:

  1. Primary (render_terminal_primary_line): working directory + git branch, main text color
  2. Secondary (render_terminal_secondary_line): conversation title/status, or terminal title if it differs from working directory; sub text color. Returns None if terminal title matches working directory.
  3. Tertiary (render_terminal_tertiary_line): kind badge (Terminal/Oz icon + label) + right badges (diff stats, PR)

Non-terminal pane rows

render_pane_row builds the content in the else branch (line 739-774):

  1. Title row (main text, ClipConfig::end())
  2. Optional subtitle row (sub text)
  3. Meta row: render_kind_badge(icon, kind_label) on left, optional render_row_badge(badge) on right

Tab group headers

render_group_header renders a collapse chevron (ChevronDown/ChevronRight) that dispatches WorkspaceAction::ToggleVerticalTabsGroupCollapsed. The is_collapsed state in VerticalTabsPanelState::collapsed_tab_groups controls whether pane rows are rendered.

ClipConfig

All text currently uses ClipConfig::end(). ClipConfig::start() exists and fades from the leading edge.

Last completed command

TerminalModelBlockList::blocks()Vec<Block>. Each Block has command_to_string() -> String and state (public via finished()). There is no existing accessor on TerminalView for the last completed command. The model is behind self.model.lock() on TerminalView.

Code pane tab count

CodeView::tab_group is a private Vec<TabData>. No public accessor for tab_group.len() exists. CodeView::set_title already sets secondary title to (+N) format when tab_group.len() > 1.

Proposed changes

1. Add last_completed_command_text to TerminalView

app/src/terminal/view/tab_metadata.rs — add a new method:

pub fn last_completed_command_text(&self) -> Option<String> {
    let model = self.model.lock();
    model.block_list().blocks().iter().rev().find_map(|block| {
        if block.finished()
            && !block.is_background()
            && !block.is_static()
        {
            let cmd = block.command_to_string();
            if cmd.trim().is_empty() { None } else { Some(cmd) }
        } else {
            None
        }
    })
}

This iterates backwards through blocks, finding the last finished non-background, non-static block with a non-empty command. The lock scope is contained to this method.

2. Add tab_count to CodeView

app/src/code/view.rs — add a one-line public method on CodeView:

pub fn tab_count(&self) -> usize {
    self.tab_group.len()
}

3. Restructure render_terminal_row_content

app/src/workspace/view/vertical_tabs.rs — rewrite render_terminal_row_content (line 969):

The new structure:

  1. Primary line: Determine content using the precedence rules from the product spec:

    • If conversation_display_title is Some: render conversation status + title (reuse existing status element rendering)
    • Else if terminal_title.trim() != working_directory.trim(): render terminal title in monospace font
    • Else if last_completed_command_text() returns Some: render command in monospace font
    • Else: render "New session" in UI font

    All variants use main text color.

  2. Secondary line: Always render the old primary line content (working directory + git branch) but in sub text color and using ClipConfig::start() for the working directory.

  3. Tertiary line: Unchanged (render_terminal_tertiary_line).

The existing render_terminal_primary_line and render_terminal_secondary_line functions are replaced/rewritten to match the new line assignments. render_terminal_tertiary_line stays as-is.

4. Restructure non-terminal pane rows

app/src/workspace/view/vertical_tabs.rs — rewrite the else branch in render_pane_row (line 739):

New structure:

  1. Primary row: Flex::row with kind icon (12px) + title text. For code panes, resolve the icon via crate::code::icon_from_file_path on the title string (which is the file path), falling back to WarpIcon::Code2.to_warpui_icon(sub_text_color). For other types, use TypedPane::icon().to_warpui_icon(sub_text_color).

  2. Secondary row (if non-empty subtitle): subtitle in sub text color.

Remove the meta row (Flex::row with render_kind_badge + render_row_badge). The render_kind_badge and render_row_badge functions are no longer called from non-terminal pane rows (they are still used by terminal pane tertiary lines).

5. Code pane multi-tab subtitle

app/src/workspace/view/vertical_tabs.rs — in TypedPane::Code handling within the non-terminal branch:

Add a method or inline logic on TypedPane to expose the code pane tab count:

fn code_tab_count(&self, app: &AppContext) -> Option<usize> {
    match self {
        TypedPane::Code(code_pane) => {
            let count = code_pane.file_view(app).as_ref(app).tab_count();
            (count > 1).then_some(count)
        }
        _ => None,
    }
}

When rendering the subtitle for code panes, if code_tab_count returns Some(count), override the subtitle with format!("and {} more", count - 1) regardless of what PaneConfiguration.title_secondary() contains.

6. Path clipping changes

In the new secondary line for terminal panes (render_terminal_secondary_line), change the working directory Text from ClipConfig::end() to ClipConfig::start(). Git branch text remains ClipConfig::end().

In the non-terminal primary row, change the title Text from ClipConfig::end() to ClipConfig::start() when the pane type is Code (file paths). For non-path titles (Notebook, Settings, etc.), keep ClipConfig::end().

7. Replace collapse chevron with close button

app/src/workspace/view/vertical_tabs.rs — in render_group_header (line 564):

Replace the collapse button construction:

  • Change icon from chevron_icon (ChevronDown/ChevronRight) to a constant WarpIcon::X (or UiIcon::X).
  • Change the click handler from dispatching WorkspaceAction::ToggleVerticalTabsGroupCollapsed to WorkspaceAction::CloseTab(tab_index).
  • Remove the is_collapsed parameter from GroupHeaderProps.

8. Remove collapse state

app/src/workspace/view/vertical_tabs.rs:

  • Remove collapsed_tab_groups: HashSet<EntityId> from VerticalTabsPanelState.
  • Remove toggle_group_collapsed, toggle_all_groups_collapsed, is_group_collapsed methods.
  • Remove the collapse: MouseStateHandle from PaneGroupStateHandles — rename the field to close for clarity.
  • Remove the is_collapsed check in render_tab_group that conditionally skips rendering pane rows.

app/src/workspace/action.rs:

  • Remove ToggleVerticalTabsGroupCollapsed and ToggleAllVerticalTabsGroupsCollapsed variants from WorkspaceAction.

app/src/workspace/view.rs:

  • Remove the match arms for ToggleVerticalTabsGroupCollapsed and ToggleAllVerticalTabsGroupsCollapsed (lines 16759-16764).
  • Remove toggle_vertical_tabs_group_collapsed and toggle_all_vertical_tabs_groups_collapsed methods (lines 6125-6139).

End-to-end flow

Terminal pane row rendering

  1. render_pane_row is called for each visible pane in a tab group.
  2. For terminal panes, render_terminal_row_content is called with the TerminalView reference.
  3. It calls terminal_title_from_shell(), display_working_directory(), selected_conversation_display_title(), and the new last_completed_command_text().
  4. The primary line function applies the precedence rules and returns the appropriate element.
  5. The secondary line always renders working directory + git branch in sub text color with ClipConfig::start().
  6. The tertiary line renders unchanged.

Close button

  1. User clicks X on a tab group header.
  2. The click handler dispatches WorkspaceAction::CloseTab(tab_index).
  3. The existing workspace close-tab logic handles teardown, undo grace period, etc.

Risks and mitigations

Lock contention for last_completed_command_text: The method acquires self.model.lock() and iterates blocks. This runs on the render path. Mitigation: the iteration is backwards and short-circuits on the first match, so it's fast in practice. The lock is already acquired in terminal_title_from_shell() on the same render path, so this is consistent with existing patterns.

Removing collapse state: Any external callers of ToggleVerticalTabsGroupCollapsed or ToggleAllVerticalTabsGroupsCollapsed will fail to compile. Mitigation: grep confirms these are only dispatched from vertical_tabs.rs and handled in view.rs — no external callers.

Code pane tab count: CodeView::tab_count() requires reading through CodePane::file_view(app).as_ref(app), which is the same pattern already used by TypedPane::badge(). No additional risk.

Testing and validation

All changes are in rendering code with no persistence or protocol changes. Validation is primarily manual:

  • Build and run with cargo run, enable vertical tabs, and verify each success criterion from the product spec.
  • Verify terminal panes with: default shell (should show "New session"), after running a command (should show command text), running vim (should show "vim"), agent conversation (should show conversation title + status).
  • Verify code panes with: single .rs file (Rust icon), single .txt file (Code2 icon), multiple tabs (language icon + "and X more").
  • Verify path clipping by narrowing the panel.
  • Verify close button closes the tab.
  • Verify no compilation errors or clippy warnings from removed collapse state.

Follow-ups

  • The "compact" single-row mode shown in the Figma mock is deferred.
  • Consider caching last_completed_command_text if profiling shows the block iteration is a hot path (unlikely given reverse iteration with early exit).
  • The render_kind_badge and render_row_badge functions can be cleaned up if they become unused after future terminal row changes.