Files

323 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# APP-3742: Tech Spec — Circular Pane Icons and Metadata Slot Rules
## Problem
The vertical tabs panel currently renders each pane row with a small 12px inline icon next to the title text. All pane types look similar at a glance. The product spec (PRODUCT.md in this directory) calls for:
1. A prominent circular "avatar" icon per pane row in both compact and expanded modes, with branded variants for agent sessions.
2. Deterministic per-pane-type rules for what data goes in each text slot (title, description, metadata).
3. A `Branch` variant added to the "Pane title as" setting.
4. An unread-activity dot for agent terminal panes, using existing notification infrastructure.
## Relevant code
**Primary file — all rendering logic lives here:**
- `app/src/workspace/view/vertical_tabs.rs` — the entire vertical tabs rendering module
**Key types in vertical_tabs.rs:**
- `TypedPane` enum (line 1022) — resolved pane type with access to typed data (TerminalPane, CodePane, etc.)
- `PaneProps` struct (line 199) — props bag assembled per pane row, containing title, subtitle, typed pane, etc.
- `PaneProps::new()` (line 1123) — constructs props from `PaneConfiguration` title/secondary
- `TerminalPrimaryLineData` enum (line 219) — determines the terminal title content and font
- `terminal_primary_line_data()` (line 1284) — priority logic for terminal title resolution
- `TerminalKindBadgeState` struct (line 245) — tracks whether terminal is Oz, ambient, or CLI agent
**Rendering entry points:**
- `render_pane_row()` (line 981) — expanded mode row
- `render_compact_pane_row()` (line 2376) — compact mode row
- `render_pane_row_element()` (line 96) — shared wrapper (hover, click, background, border)
- `render_terminal_row_content()` (line 1394) — expanded terminal 3-line content
- `render_non_terminal_primary_row()` (line 1861) — non-terminal title with inline icon
**Setting:**
- `app/src/workspace/tab_settings.rs:186-200``VerticalTabsPrimaryInfo` enum (currently `Command`, `WorkingDirectory`) and its synced setting registration
**Notification infrastructure (for unread dot):**
- `app/src/ai/agent_management/notifications/item.rs``NotificationItems` with `is_read` per item, `mark_all_terminal_view_items_as_read()`, `items_filtered(Unread)`
- `app/src/ai/agent_management/agent_management_model.rs:27``AgentNotificationsModel` singleton, emits `AgentManagementEvent::NotificationUpdated`
**Conversation status (for status badge icons):**
- `app/src/ai/agent/conversation.rs:3491``ConversationStatus` enum (`InProgress`, `Success`, `Error`, `Cancelled`, `Blocked`)
- `app/src/ai/agent/conversation.rs:3531``status_icon_and_color()` returns `(Icon, ColorU)` per status
- `app/src/ai/conversation_status_ui.rs:14``render_status_element()` helper
**CLI agent icons:**
- `vertical_tabs.rs:1918``cli_agent_warp_icon()` maps `CLIAgent` to branded `WarpIcon`
**Language file icons:**
- `app/src/code/mod.rs``icon_from_file_path()` returns language-specific icon element
- Used in `vertical_tabs.rs:1846` by `resolve_non_terminal_icon()`
**Pane configuration:**
- `app/src/pane_group/pane/mod.rs:681``PaneConfiguration` struct with `title`, `title_secondary`
## Current state
### Expanded mode layout
`render_pane_row()` builds a `Flex::column` with 23 child lines, wrapped in `render_pane_row_element()` for hover/click/background. Terminal panes get 3 lines (primary, secondary, tertiary); non-terminal panes get 12 lines (title + optional subtitle). There is no circular icon — the 12px icon is inlined into the title row via `render_non_terminal_primary_row()`.
### Compact mode layout
`render_compact_pane_row()` renders a single primary row. For terminals it delegates to `render_terminal_primary_line_for_view()` (with a 16px prefix icon); for non-terminals to `render_non_terminal_primary_row()` (with a 12px inline icon). No circular icon.
### Terminal title resolution
`terminal_primary_line_data()` returns the title text with priority:
1. CLI agent title
2. Oz conversation title (with status)
3. Terminal title if it differs from working directory
4. Last completed command
5. "New session" fallback
Per PRODUCT.md, items 35 collapse to just "terminal title" (no differing-from-WD check, no command fallback, no "New session").
### "Pane title as" setting
`VerticalTabsPrimaryInfo` has `Command` and `WorkingDirectory` variants. Used in `render_terminal_row_content()` (expanded) and `render_compact_pane_row()` (compact) to swap which data goes in line 1 vs line 2. Registered as a synced cloud setting.
### Unread tracking
`AgentNotificationsModel` stores `NotificationItems` with `is_read: bool` per item, keyed by `terminal_view_id`. `mark_all_terminal_view_items_as_read()` clears unread state for a terminal. The model emits `AgentManagementEvent::NotificationUpdated` on changes. Currently only consumed by the notification mailbox UI — the vertical tabs panel does not subscribe to it.
## Proposed changes
### 1. Circular icon rendering
Add a new function `render_pane_circle_icon()` in `vertical_tabs.rs` that returns a `Box<dyn Element>` — a `Stack` containing:
- A circular `Container` (background + `CornerRadius::with_all(Radius::Pixels(CIRCLE_RADIUS))`) holding the inner icon.
- An optional positioned status badge overlay in the bottom-right.
**Variants dispatched by a new enum:**
```rust
enum CircleIconVariant<'a> {
/// Neutral circle: fg_overlay_2 background, 16px type icon
Neutral { icon: WarpIcon },
/// Oz agent: dark background, 10px Oz icon, status badge
OzAgent { status: Option<&'a ConversationStatus>, is_ambient: bool },
/// CLI agent: brand-colored background, 10px agent icon, status badge
CLIAgent { agent: CLIAgent, status: Option<&'a ConversationStatus> },
/// Language-specific file icon in neutral circle
LanguageFile { icon_element: Box<dyn Element> },
}
```
The badge uses `status_icon_and_color()` from `ConversationStatus`, rendered at 9px inside a 12px cutout container. The cutout ring uses the panel background color.
**Integration:** Both `render_pane_row()` and `render_compact_pane_row()` will prepend the circle icon to the left of the text column using `Flex::row().with_child(circle_icon).with_child(text_column)`.
### 2. Refactor row content into a unified slot model
Replace the current divergent code paths for terminal vs non-terminal with a single `PaneRowSlots` struct:
```rust
struct PaneRowSlots {
circle_icon: Box<dyn Element>,
title: Box<dyn Element>,
/// Indicator shown inline with title (unread dot, unsaved dot)
title_indicator: Option<Box<dyn Element>>,
/// Line 2: shown in both compact (10px muted) and expanded (12px sub-text)
subtitle: Option<Box<dyn Element>>,
/// Line 3: shown only in expanded mode
metadata: Option<Box<dyn Element>>,
}
```
A new function `resolve_pane_row_slots()` takes `PaneProps`, the view mode, the "Pane title as" setting, and `&AppContext`, and returns `PaneRowSlots`. This function centralizes all per-pane-type logic from the product spec:
- For terminals: resolves icon variant, title text (per simplified priority), subtitle/description/metadata per the "Pane title as" setting.
- For code panes: resolves language icon, filename, path, "and N more", unsaved dot.
- For notebooks, settings, and other types: maps `PaneConfiguration` title/secondary.
Two rendering functions consume `PaneRowSlots`:
- `render_compact_row_from_slots()` — circle icon + title row (with indicator) + subtitle
- `render_expanded_row_from_slots()` — circle icon + title row + description + metadata
Both delegate to `render_pane_row_element()` for the shared hover/click wrapper.
### 3. Terminal title priority simplification
Update `terminal_primary_line_data()` to remove fallbacks 35 and replace with:
```rust
fn terminal_primary_line_data(...) -> TerminalPrimaryLineData {
if let Some(cli_agent_title) = cli_agent_title {
return TerminalPrimaryLineData::StatusText { text: cli_agent_title, status: cli_agent_status };
}
if let Some(conversation_title) = conversation_display_title {
return TerminalPrimaryLineData::StatusText { text: conversation_title, status: conversation_status };
}
TerminalPrimaryLineData::Text {
text: terminal_title.trim().to_string(),
font: TerminalPrimaryLineFont::Monospace,
}
}
```
The `StatusText` variant no longer renders an inline status icon prefix in the title — conversation status is shown exclusively via the circular icon's status badge.
### 4. "Pane title as" — add `Branch` variant
Add `Branch` to `VerticalTabsPrimaryInfo`:
```rust
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsPrimaryInfo {
#[default]
Command,
WorkingDirectory,
Branch,
}
```
Because the enum is already registered via `implement_setting_for_enum!` with `SyncToCloud::Globally`, the new variant is automatically synced. Existing users with persisted `Command` or `WorkingDirectory` are unaffected; `Branch` only activates when explicitly selected.
In `resolve_pane_row_slots()`, when building terminal expanded slots:
| Setting | Line 1 (title) | Line 2 (description) | Line 3 metadata left |
|------------------|----------------------|---------------------------|--------------------------|
| Command | command/conversation | working directory | git branch |
| WorkingDirectory | working directory | command/conversation | git branch |
| Branch | git branch | working directory | (omit — already in title)|
Compact mode ignores this setting (per PRODUCT.md).
Update the settings popup (`render_settings_popup()` and `render_primary_info_option()`) to add the third option with label "Branch".
### 5. Unread-activity dot
Add a query method to `NotificationItems`:
```rust
pub(crate) fn has_unread_for_terminal_view(&self, terminal_view_id: EntityId) -> bool {
self.items.iter().any(|item| item.terminal_view_id == terminal_view_id && !item.is_read)
}
```
In `resolve_pane_row_slots()`, for terminal panes with an agent session, query `AgentNotificationsModel::as_ref(app).notifications().has_unread_for_terminal_view(terminal_view.id())`. If true, set `title_indicator` to a `CircleFilled` icon element.
The existing `mark_all_terminal_view_items_as_read()` is already called when a terminal pane gains focus (via `AgentNotificationsModel::mark_items_from_terminal_view_read`). To trigger re-renders, subscribe the `VerticalTabsPanelState` (or the workspace) to `AgentManagementEvent::NotificationUpdated` and call `ctx.notify()`.
### 6. Unsaved-changes dot
Already partially implemented via `TypedPane::badge()` which returns `Some("Unsaved")` for dirty code panes. Reuse this in `resolve_pane_row_slots()`: when `badge()` returns `Some(_)`, set `title_indicator` to the same `CircleFilled` icon.
### 7. Settings popup label update
In `render_settings_popup()`, rename the "Show first" header text to "Pane title as". Add a third `render_primary_info_option()` call for `VerticalTabsPrimaryInfo::Branch` with label "Branch".
## End-to-end flow
**Rendering a terminal pane row (expanded, "Pane title as: Command"):**
1. `render_tab_group()` iterates pane IDs, builds `PaneProps` via `PaneProps::new()`.
2. `PaneProps::new()` reads `PaneConfiguration` title/secondary, resolves `TypedPane`.
3. `resolve_pane_row_slots()` is called:
- Detects `TypedPane::Terminal(terminal_pane)`.
- Reads `TerminalView` from pane → gets working directory, git branch, conversation status, CLI agent session.
- Builds `CircleIconVariant::OzAgent { status, is_ambient: false }` (or CLI/Neutral depending on session type).
- Calls `render_pane_circle_icon()` → circle icon element with status badge.
- Calls `terminal_primary_line_data()` → title text.
- Checks `has_unread_for_terminal_view()` → sets `title_indicator` if unread.
- Sets `subtitle` = working directory text element.
- Sets `metadata` = git branch + diff stats badge + PR badge row.
4. `render_expanded_row_from_slots()` assembles: `Flex::row(circle_icon, Flex::column(title_row, subtitle, metadata))`.
5. `render_pane_row_element()` wraps in hover/click/background.
**Unread dot lifecycle:**
1. Agent finishes a task → `AgentNotificationsModel` creates a `NotificationItem` with `is_read: false`.
2. Model emits `AgentManagementEvent::NotificationUpdated`.
3. Workspace (or vertical tabs panel via subscription) receives event → calls `ctx.notify()`.
4. `resolve_pane_row_slots()` re-runs → `has_unread_for_terminal_view()` returns true → dot shown.
5. User clicks the pane row → `WorkspaceAction::FocusPane` → terminal gains focus → `mark_items_from_terminal_view_read()` clears unread → event emitted → dot disappears on next render.
## Risks and mitigations
**Risk: Circular icon adds vertical height to compact rows.** The circle is ~25px tall. Compact rows currently have 8px top/bottom padding. If the circle makes rows taller than intended, adjust vertical padding or use `CrossAxisAlignment::Center` in the horizontal flex to let the icon vertically center without forcing extra height.
**Risk: `has_unread_for_terminal_view()` is O(n) over all notifications.** With the 100-item cap on `NotificationItems`, this is negligible. No mitigation needed.
**Risk: Adding `Branch` to `VerticalTabsPrimaryInfo` is a persisted enum change.** New variant is additive — serde deserialization of `"Command"` or `"WorkingDirectory"` from cloud still works. A user on an older client seeing a `"Branch"` value from cloud sync would fail to deserialize and fall back to the default (`Command`). This is acceptable — older clients simply ignore the new variant.
## Testing and validation
- **Unit tests for `terminal_primary_line_data()`:** Update existing tests in `vertical_tabs_tests.rs` to verify the simplified 3-step priority (CLI agent → conversation → terminal title) and ensure the old fallbacks (last command, "New session") are removed.
- **Unit test for `has_unread_for_terminal_view()`:** Add to `item_tests.rs` — create items with different `terminal_view_id` and `is_read` states, verify query correctness.
- **Visual validation:** Per the validation section in PRODUCT.md — open a mix of pane types, verify circle icons, slot content, indicators, and "Pane title as" setting behavior.
- **Presubmit:** `cargo clippy` and `cargo fmt` must pass. Run `cargo nextest run -p warp` for the workspace tests.
### 8. "Additional metadata" setting for compact subtitle
**New enum in `tab_settings.rs`:**
```rust
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsCompactSubtitle {
#[default]
Branch,
WorkingDirectory,
Command,
}
```
Registered via `implement_setting_for_enum!` with `SyncToCloud::Globally`, same as the other vertical tabs settings. Added to the `TabSettings` group.
**Resolving the effective subtitle:**
The persisted `VerticalTabsCompactSubtitle` value may be incompatible with the current `VerticalTabsPrimaryInfo` (e.g., user has `Branch` as both title and subtitle). A helper function `resolve_compact_subtitle()` maps the combination:
```rust
fn resolve_compact_subtitle(
primary: VerticalTabsPrimaryInfo,
subtitle_pref: VerticalTabsCompactSubtitle,
) -> VerticalTabsCompactSubtitle {
// If the subtitle preference is the same category as the title, fall back to default.
let is_conflict = matches!(
(primary, subtitle_pref),
(VerticalTabsPrimaryInfo::Command, VerticalTabsCompactSubtitle::Command)
| (VerticalTabsPrimaryInfo::WorkingDirectory, VerticalTabsCompactSubtitle::WorkingDirectory)
| (VerticalTabsPrimaryInfo::Branch, VerticalTabsCompactSubtitle::Branch)
);
if is_conflict {
default_compact_subtitle(primary)
} else {
subtitle_pref
}
}
fn default_compact_subtitle(primary: VerticalTabsPrimaryInfo) -> VerticalTabsCompactSubtitle {
match primary {
VerticalTabsPrimaryInfo::Command => VerticalTabsCompactSubtitle::Branch,
VerticalTabsPrimaryInfo::WorkingDirectory => VerticalTabsCompactSubtitle::Branch,
VerticalTabsPrimaryInfo::Branch => VerticalTabsCompactSubtitle::Command,
}
}
```
**New action:** `WorkspaceAction::SetVerticalTabsCompactSubtitle(VerticalTabsCompactSubtitle)` — mirrors the existing `SetVerticalTabsPrimaryInfo` pattern.
**Settings popup changes:**
In `render_settings_popup()`, when `current_mode == VerticalTabsViewMode::Compact`, add an "Additional metadata" section between the "Pane title as" options and the divider. The section shows:
- A header label "Additional metadata" (same style as "Pane title as")
- Two selectable options — the two metadata categories not used as the title
- Each option dispatches `SetVerticalTabsCompactSubtitle`
The option labels and values depend on `current_primary_info`:
- Command title → options: "Branch" (Branch), "Working Directory" (WorkingDirectory)
- WorkingDirectory title → options: "Branch" (Branch), "Command / Conversation" (Command)
- Branch title → options: "Command / Conversation" (Command), "Working Directory" (WorkingDirectory)
Two new `MouseStateHandle` fields are needed in `VerticalTabsPanelState` for the two option hover states.
**Compact rendering changes:**
In `render_compact_pane_row()`, read `VerticalTabsCompactSubtitle` from settings and pass through `resolve_compact_subtitle()`. Use the resolved value to determine the subtitle element for terminal panes.
## Follow-ups
- **Animated status badge:** The Figma mock's `clock_loader` icon implies rotation animation. The current `Icon::ClockLoader` is static. Adding animation is a separate task.
- **Group-by modes:** The settings popup mock shows "Group by" options. These will change how tab groups are constructed and are a separate feature.