411 lines
18 KiB
Markdown
411 lines
18 KiB
Markdown
# APP-3832: Tech Spec — Vertical Tabs Hover Detail Sidecar
|
||
|
||
## Problem
|
||
|
||
APP-3832 adds a hover-only detail sidecar to the vertical tabs panel.
|
||
|
||
The product behavior is intentionally specific:
|
||
|
||
- in `View as = Panes`, hovering a supported pane row shows one pane-scoped detail card
|
||
- in `View as = Tabs`, hovering a supported representative row shows a tab-scoped sidecar with one pane section per visible pane in that tab
|
||
- the sidecar is floating, does not change focus, stays open while the pointer moves from the row into the sidecar, and becomes internally scrollable when tall
|
||
- v1 only supports terminal / agent terminal panes and code panes
|
||
|
||
Technically, the current vertical tabs implementation has the row hover primitives we need, but it has no concept of:
|
||
|
||
- a row-anchored overlay other than the settings popup / tab menus
|
||
- ephemeral hover-detail state that persists across the gap between a row and a sidecar
|
||
- a fixed-detail renderer that is independent of the existing row display settings
|
||
- tabs-mode eligibility rules that depend on the full set of visible panes in a tab
|
||
|
||
The implementation should add that behavior without disturbing the existing row renderers, click behavior, or synced settings flow.
|
||
|
||
## Relevant code
|
||
|
||
- `specs/APP-3832/PRODUCT.md` — agreed user-facing behavior for the hover detail sidecar
|
||
- `specs/APP-3828/PRODUCT.md` — current `View as = Panes / Tabs` behavior that determines row granularity
|
||
- `specs/APP-3828/TECH.md` — existing implementation pattern for vertical-tabs display granularity
|
||
- `app/src/workspace/view/vertical_tabs.rs (246-344)` — `VerticalTabsPanelState`, row mouse-state ownership, and other panel-local UI state
|
||
- `app/src/workspace/view/vertical_tabs.rs (458-557)` — `matching_tab_indices`; useful context for the current pane-vs-tab granularity flow
|
||
- `app/src/workspace/view/vertical_tabs.rs (630-1045)` — `render_groups` and `render_tab_group`, where row lists are built for both `Panes` and `Tabs`
|
||
- `app/src/workspace/view/vertical_tabs.rs (1020-1277)` — `render_pane_row_element`, the current row hover / click wrapper
|
||
- `app/src/workspace/view/vertical_tabs.rs (1320-1710)` — `TypedPane`, `PaneProps::new`, code-pane title/path derivation, and badge helpers
|
||
- `app/src/workspace/view/vertical_tabs.rs (1858-2340)` — terminal metadata rendering helpers and badge interactions that the sidecar should reuse semantically
|
||
- `app/src/workspace/view.rs (17784-17839)` — workspace action handling for vertical-tabs settings
|
||
- `app/src/workspace/view.rs (19201-19399)` — `Workspace::render`, which already hosts workspace-root overlays like the settings popup and tab menus
|
||
- `app/src/safe_triangle.rs` — generic safe-triangle logic for keeping hover sidecars stable during diagonal cursor movement
|
||
- `app/src/menu.rs (1852-1860, 2171-2367)` — current safe-triangle integration for menus and submenus
|
||
- `app/src/terminal/profile_model_selector.rs (1040-1078)` — concrete pattern for reading a sidecar rect from the previous frame and feeding it into safe-triangle state
|
||
- `app/src/workspace/view/vertical_tabs_tests.rs` — current home for pure vertical-tabs helper tests
|
||
|
||
## Current state
|
||
|
||
### Panel-local state
|
||
|
||
`VerticalTabsPanelState` already owns:
|
||
|
||
- the panel scroll and resize handles
|
||
- per-tab-group hover state
|
||
- per-pane-row hover state
|
||
- per-pane badge hover state
|
||
- settings-popup hover state and popup visibility
|
||
|
||
It does not currently track:
|
||
|
||
- which row is the current detail-sidecar source
|
||
- whether a sidecar itself is hovered
|
||
- a scroll state for a sidecar
|
||
- safe-triangle state for row-to-sidecar transitions
|
||
|
||
### Row rendering and granularity
|
||
|
||
`render_tab_group` already centralizes the pane ids that become visible rows:
|
||
|
||
- `Panes` mode renders all visible pane ids
|
||
- `Tabs` mode renders one representative pane id via `pane_ids_for_display_granularity(...)`
|
||
|
||
Each rendered row already has a stable `MouseStateHandle`, but rows are not wrapped in `SavePosition`, so there is no stable anchor id for a row-relative overlay.
|
||
|
||
### Overlay placement
|
||
|
||
The vertical tabs panel itself is rendered inside `Workspace::render_panels`, but floating overlays that must escape local panel bounds are rendered from the workspace root `Stack` in `Workspace::render`. This is how the vertical-tabs settings popup and tab context menus are currently placed.
|
||
|
||
That makes the workspace root the right place to render the hover detail sidecar as well.
|
||
|
||
### Existing row data extraction
|
||
|
||
The current row renderers already know how to derive most of the underlying metadata we need:
|
||
|
||
- terminal title / conversation title fallback logic
|
||
- working directory and git branch
|
||
- diff-stats and PR badge data
|
||
- code-pane filename / parent-path split
|
||
- code dirty state
|
||
- pane kind labels and icons
|
||
|
||
However, the row renderers are the wrong level of reuse for the sidecar itself because:
|
||
|
||
- they are intentionally clipped and density-dependent
|
||
- they are coupled to `Pane title as`, `Additional metadata`, and `Show`
|
||
- the sidecar layout is fixed and should not rearrange around those settings
|
||
|
||
The sidecar should therefore reuse the same data sources, not the same element tree.
|
||
|
||
### Safe-triangle precedent
|
||
|
||
The repo already has a safe-triangle implementation for hover menus that open sidecars or submenus. The existing pattern is:
|
||
|
||
- keep ephemeral hover state outside of synced settings / workspace snapshotting
|
||
- record the sidecar rect from the previous frame
|
||
- suppress intermediate hover changes while the cursor moves diagonally toward that rect
|
||
|
||
That is the right interaction primitive for this feature.
|
||
|
||
## Proposed changes
|
||
|
||
### 1. Add panel-local detail-overlay state
|
||
|
||
Extend `VerticalTabsPanelState` with state for the hover detail overlay:
|
||
|
||
- `detail_scroll_state: ClippedScrollStateHandle` — internal scrolling for tall sidecars
|
||
- `detail_sidecar_mouse_state: MouseStateHandle` — hover tracking for the sidecar itself
|
||
- `detail_overlay_state: Arc<Mutex<VerticalTabsDetailOverlayState>>` — ephemeral hover-detail state shared by row and sidecar callbacks
|
||
|
||
Introduce a small internal state type in `vertical_tabs.rs`:
|
||
|
||
```rust path=null start=null
|
||
struct VerticalTabsDetailOverlayState {
|
||
active_target: Option<VerticalTabsDetailTarget>,
|
||
safe_triangle: SafeTriangle,
|
||
}
|
||
|
||
enum VerticalTabsDetailTarget {
|
||
Pane {
|
||
pane_group_id: EntityId,
|
||
pane_id: PaneId,
|
||
},
|
||
Tab {
|
||
pane_group_id: EntityId,
|
||
source_pane_id: PaneId,
|
||
},
|
||
}
|
||
```
|
||
|
||
Use panel-local ephemeral state rather than new synced settings or persisted workspace actions. The sidecar is transient UI state, more like menu hover than workspace configuration.
|
||
|
||
### 2. Add helpers for detail-sidecar eligibility and anchoring
|
||
|
||
Add pure helpers in `vertical_tabs.rs` for:
|
||
|
||
- deciding whether a `TypedPane` is supported by the v1 sidecar
|
||
- converting a hovered rendered row into a `VerticalTabsDetailTarget`
|
||
- resolving the pane ids that a target should render as sidecar sections
|
||
- producing a stable save-position id for a rendered row
|
||
|
||
Suggested helper shape:
|
||
|
||
```rust path=null start=null
|
||
fn vtab_pane_row_position_id(pane_group_id: EntityId, pane_id: PaneId) -> String
|
||
|
||
fn supports_vertical_tabs_detail_sidecar(typed: &TypedPane<'_>) -> bool
|
||
|
||
fn detail_target_for_hovered_row(
|
||
pane_group_id: EntityId,
|
||
pane_id: PaneId,
|
||
granularity: VerticalTabsDisplayGranularity,
|
||
) -> VerticalTabsDetailTarget
|
||
|
||
fn pane_ids_for_detail_target(
|
||
pane_group: &PaneGroup,
|
||
target: &VerticalTabsDetailTarget,
|
||
app: &AppContext,
|
||
) -> Option<Vec<PaneId>>
|
||
```
|
||
|
||
Behavior:
|
||
|
||
- `Panes` mode:
|
||
- supported pane row => pane-scoped target
|
||
- unsupported pane row => no sidecar
|
||
- `Tabs` mode:
|
||
- representative row => tab-scoped target
|
||
- resolve all visible panes in the hovered tab
|
||
- if any visible pane is unsupported, return `None` so the whole tab has no sidecar in v1
|
||
|
||
This keeps the mixed-tab gating rule centralized and testable.
|
||
|
||
### 3. Save row positions and attach hover callbacks at the row wrapper
|
||
|
||
Update `render_pane_row_element` so the final row element is wrapped in `SavePosition` using the new row-position helper.
|
||
|
||
Add a hover callback there rather than in individual compact / expanded row renderers. That keeps the hover-detail behavior shared across all row variants.
|
||
|
||
The row wrapper should:
|
||
|
||
- derive the appropriate `VerticalTabsDetailTarget` for that row
|
||
- update `detail_overlay_state.active_target` on supported hover-in
|
||
- ignore unsupported rows
|
||
- update safe-triangle state with the current pointer position
|
||
- use `with_skip_synthetic_hover_out()` so overlay insertion does not immediately force a synthetic close
|
||
|
||
This approach preserves the current row click / double-click behavior and avoids duplicating hover-detail wiring in multiple render paths.
|
||
|
||
### 4. Render the sidecar from `Workspace::render`, not inside the panel surface
|
||
|
||
Add a new helper in `vertical_tabs.rs`:
|
||
|
||
```rust path=null start=null
|
||
pub(super) fn render_detail_sidecar(
|
||
state: &VerticalTabsPanelState,
|
||
workspace: &Workspace,
|
||
app: &AppContext,
|
||
) -> Option<(String, Box<dyn Element>)>
|
||
```
|
||
|
||
This helper should:
|
||
|
||
- read `detail_overlay_state.active_target`
|
||
- validate that the referenced pane group / pane(s) still exist
|
||
- resolve the pane ids that should become sections
|
||
- build the sidecar element if the target is still valid
|
||
- return the source row position id plus the sidecar element
|
||
|
||
Render the returned sidecar from the workspace-root `Stack` in `Workspace::render`, alongside the existing settings popup and tab menus. This avoids clipping by the vertical-tabs panel container and matches the existing overlay pattern in this codepath.
|
||
|
||
Position the sidecar with `OffsetPositioning::offset_from_save_position_element(...)` using the hovered row’s save-position id and `PositionedElementOffsetBounds::WindowByPosition`.
|
||
|
||
Also give the sidecar its own `SavePosition` id so its rect can be read on subsequent frames for the safe triangle.
|
||
|
||
### 5. Build a dedicated detail renderer on top of existing metadata sources
|
||
|
||
Add a dedicated sidecar renderer instead of trying to stretch the existing row renderers.
|
||
|
||
Introduce a small data model for the detail content:
|
||
|
||
```rust path=null start=null
|
||
enum VerticalTabsDetailSectionData {
|
||
Terminal(TerminalDetailSectionData),
|
||
Code(CodeDetailSectionData),
|
||
}
|
||
```
|
||
|
||
Back it with helper builders that reuse the same underlying sources as the row code:
|
||
|
||
- terminal title / conversation fallback logic from the existing terminal helpers
|
||
- `TerminalView` for working directory, branch, diff stats, PR link, and agent status
|
||
- `PaneProps::new` / `TypedPane` for pane kind labels and code-pane title/path derivation
|
||
- `TypedPane::badge(app)` / `CodePane` dirty checks for unsaved state
|
||
|
||
Do not make the sidecar renderer depend on:
|
||
|
||
- `VerticalTabsViewMode`
|
||
- `VerticalTabsPrimaryInfo`
|
||
- `VerticalTabsCompactSubtitle`
|
||
- `vertical_tabs_show_*`
|
||
|
||
Those settings shape rows; the sidecar is a fixed detail view.
|
||
|
||
### 6. Sidecar layout and scrolling
|
||
|
||
Render the sidecar as:
|
||
|
||
- fixed-width outer card
|
||
- bounded-height container
|
||
- internal scrollable content using `ClippedScrollable::vertical(...)` or `NewScrollable::vertical(...)`
|
||
- overlayed scrollbar styling consistent with existing panel/menu scrollables
|
||
|
||
In `Tabs` mode:
|
||
|
||
- render one section per resolved pane id
|
||
- insert dividers between sections
|
||
|
||
In `Panes` mode:
|
||
|
||
- render a single section with no internal divider treatment
|
||
|
||
Use the workspace window bounds to keep the sidecar on-screen. `WindowByPosition` plus a bounded max height is sufficient for v1; there is no need for a separate left/right flip behavior because the vertical tabs panel already lives on the left side of the workspace.
|
||
|
||
### 7. Keep the sidecar open across row-to-sidecar cursor movement
|
||
|
||
Reuse the existing safe-triangle pattern rather than inventing a new hover heuristic.
|
||
|
||
Implementation approach:
|
||
|
||
- the sidecar root is wrapped in `Hoverable::new(detail_sidecar_mouse_state, ...)`
|
||
- on row hover changes, update the `SafeTriangle` with the latest pointer position
|
||
- on subsequent hover events, suppress replacing or clearing the active target while the cursor is moving through the safe triangle toward the current sidecar
|
||
- like `ProfileModelSelector`, read the sidecar rect from the previous frame and feed it back into `SafeTriangle::set_target_rect(...)`
|
||
|
||
This keeps the interaction logic close to the existing menu/sidecar behavior already used elsewhere in the app.
|
||
|
||
### 8. Do not add new workspace actions unless implementation proves they are needed
|
||
|
||
The initial design should keep the hover-detail state entirely inside `VerticalTabsPanelState` via shared ephemeral state handles.
|
||
|
||
That avoids:
|
||
|
||
- new `WorkspaceAction` variants for non-persistent hover state
|
||
- extra `handle_action` branches in `Workspace::handle_action`
|
||
- confusion about whether the sidecar is part of saved workspace state
|
||
|
||
If the actual UI framework constraints force action-based updates later, that can be introduced during implementation, but it should not be the default plan.
|
||
|
||
## End-to-end flow
|
||
|
||
1. The user hovers a rendered vertical-tabs row.
|
||
2. The row wrapper in `render_pane_row_element` derives a `VerticalTabsDetailTarget` from:
|
||
- the row’s pane id
|
||
- the row’s pane group id
|
||
- the current `VerticalTabsDisplayGranularity`
|
||
3. If the hovered row is unsupported, nothing happens.
|
||
4. If the row is supported, the row hover callback stores the target in `detail_overlay_state.active_target`.
|
||
5. On the next render, `Workspace::render` asks `render_detail_sidecar(...)` for an overlay.
|
||
6. That helper:
|
||
- validates the target
|
||
- resolves the pane ids to show
|
||
- rejects mixed-support tabs in `Tabs` mode
|
||
- builds the sidecar sections from the current pane data
|
||
7. The workspace root `Stack` positions the sidecar to the right of the hovered row using the row’s save-position id.
|
||
8. The user moves the pointer from the row into the sidecar.
|
||
9. The safe triangle suppresses intermediate hover changes, so the sidecar stays open.
|
||
10. If the sidecar becomes tall, its content scrolls internally.
|
||
11. If the pointer leaves both the source row and the sidecar, the overlay state is cleared and the sidecar disappears.
|
||
|
||
## Risks and mitigations
|
||
|
||
### Overlay-induced synthetic hover churn
|
||
|
||
Risk:
|
||
|
||
- inserting a floating overlay can trigger synthetic hover-out events on the source row, causing flicker
|
||
|
||
Mitigation:
|
||
|
||
- attach hover handling at the row wrapper
|
||
- use `with_skip_synthetic_hover_out()`
|
||
- keep the last active target in ephemeral overlay state instead of deriving visibility strictly from the current row hover bit
|
||
|
||
### Mixed-support tabs in `Tabs` mode
|
||
|
||
Risk:
|
||
|
||
- the product rule for mixed tabs is easy to accidentally implement as “render only supported panes”
|
||
|
||
Mitigation:
|
||
|
||
- centralize the rule in `pane_ids_for_detail_target(...)`
|
||
- make the helper return `None` for any tab that includes unsupported visible panes
|
||
- add explicit tests for mixed tabs
|
||
|
||
### Stale hover target after close / focus / reorder changes
|
||
|
||
Risk:
|
||
|
||
- the hovered source pane or tab may disappear while the sidecar is open
|
||
|
||
Mitigation:
|
||
|
||
- validate the target on every render before building the sidecar
|
||
- clear the target if the pane group or source pane no longer exists
|
||
- resolve tab sections from live `visible_pane_ids()` each render rather than caching pane lists in the target
|
||
|
||
### Duplication of terminal / code metadata logic
|
||
|
||
Risk:
|
||
|
||
- the sidecar could fork the row metadata logic and drift over time
|
||
|
||
Mitigation:
|
||
|
||
- reuse existing helper functions and model reads wherever possible
|
||
- keep new data-extraction helpers in `vertical_tabs.rs`, close to the row rendering code they parallel
|
||
- avoid re-encoding fallback rules in multiple places
|
||
|
||
### Root-overlay vs panel-overlay confusion
|
||
|
||
Risk:
|
||
|
||
- `render_vertical_tabs_panel` already contains local overlay structure for the settings popup, while `Workspace::render` also renders the popup globally
|
||
|
||
Mitigation:
|
||
|
||
- treat `Workspace::render` as the source of truth for the new sidecar overlay
|
||
- keep the sidecar rooted at the workspace stack from the start
|
||
- do not introduce another panel-local overlay path for this feature
|
||
|
||
## Testing and validation
|
||
|
||
### Unit tests
|
||
|
||
Add pure tests in `app/src/workspace/view/vertical_tabs_tests.rs` for:
|
||
|
||
- `supports_vertical_tabs_detail_sidecar(...)`
|
||
- terminal panes supported
|
||
- code panes supported
|
||
- unsupported pane types rejected
|
||
- `pane_ids_for_detail_target(...)`
|
||
- panes mode returns just the hovered pane
|
||
- tabs mode returns all visible panes when every pane is supported
|
||
- tabs mode returns `None` when any visible pane is unsupported
|
||
- stale / missing source pane returns `None`
|
||
|
||
If needed, add small pure tests around row-position-id helpers or target resolution helpers, but prioritize the eligibility logic above.
|
||
|
||
### Manual validation
|
||
|
||
- hover a supported terminal row in `Panes` mode and verify a single pane-scoped sidecar appears
|
||
- hover a supported code row in `Panes` mode and verify a single code sidecar appears
|
||
- hover a representative row in `Tabs` mode for a tab with several supported panes and verify one section per visible pane
|
||
- hover a representative row in `Tabs` mode for a mixed-support tab and verify no sidecar appears
|
||
- move the cursor diagonally from the row into the sidecar and verify it does not flicker closed
|
||
- click diff-stats and PR badges inside the sidecar and verify the existing actions still fire
|
||
- hover a large multi-pane tab and verify the sidecar becomes internally scrollable rather than extending off-screen
|
||
- switch `Density`, `Pane title as`, `Additional metadata`, and `Show`, then verify the sidecar layout stays fixed
|
||
- close or mutate the hovered tab while the sidecar is open and verify the overlay disappears cleanly without stale content
|
||
|
||
## Follow-ups
|
||
|
||
- Support additional pane types once product behavior is defined for their detail sections.
|
||
- Add keyboard-accessible detail affordances in a follow-up ticket if the hover-only behavior proves valuable.
|
||
- If more hover sidecars are added elsewhere in Warp, consider extracting a reusable “row + sidecar + safe triangle” helper instead of keeping the logic local to vertical tabs.
|