Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# APP-1915: Copy URL / Copy path in AI response right-click context menu
## Summary
When a user right-clicks a hyperlink rendered inside an AI response, add a "Copy URL" (for web URLs) or "Copy path" (for file paths) item to the existing AI block context menu, grouped with the other Copy items. The full AI block context menu must still be shown — the link-specific item is an addition, not a replacement.
## Problem
AI responses often contain links (URLs and file paths). Today there is no quick way to copy a link target from the context menu; users have to manually select the link text. Terminal grid links already offer "Copy URL" / "Copy path" on right-click, so AI responses are an outlier.
A previous change on `oz-agent/copy-url-in-ai-response-context-menu` added the affordance but replaced the entire AI block right-click context menu with a one-item "Copy URL" menu, regressing every other right-click action (Share session, Copy, Copy prompt, Copy output as Markdown, Save as prompt, Share conversation, Fork…, Rewind…, Copy debugging link/ID, Split pane…, Close pane) whenever the cursor happened to be over a link. That is the bug this spec addresses.
## Non-goals
- Adding "Show in Finder", "Open in Warp", or "Open in editor" items for file-path links in AI responses. Scope is limited to copying the target.
- Changing the terminal grid link context menu.
- Adding a separate slimmed-down link-only menu (the earlier attempt). The existing AI block menu is kept intact.
- Changing how hyperlinks are detected or rendered inside AI responses.
## Figma
Figma: none provided. The existing AI block context menu (see screenshots attached to APP-1915) is the baseline; the only visible change is an additional "Copy URL" or "Copy path" item inserted next to the other Copy items.
## Behavior
1. Right-clicking a URL hyperlink inside an AI response shows the full existing AI block context menu (Share session, Copy, Copy prompt, Copy output as Markdown, Save as prompt, Share conversation, Copy conversation text, Fork…, Rewind…, Copy debugging link, Copy conversation ID, Split pane…, Close pane) with no items removed and no other items reordered.
2. When the cursor is over a URL hyperlink, a "Copy URL" item is inserted into the menu immediately after "Copy output as Markdown" and before any conditional "Copy command" / "Copy git branch" items. Selecting it writes the hovered URL string verbatim to the clipboard (the URL target, not the displayed link text).
3. When the cursor is over a file-path hyperlink, a "Copy path" item is inserted in the same position as "Copy URL" (immediately after "Copy output as Markdown"). Selecting it writes the absolute path of the hovered file to the clipboard.
4. "Copy path" is only available on builds that have the `local_fs` feature enabled, matching the existing file-path link behavior elsewhere in the app. On builds without `local_fs`, no "Copy path" item appears and the rest of the menu is unchanged.
5. At most one link-specific item is inserted per menu: "Copy URL" xor "Copy path", never both, and never duplicated for overlapping link regions.
6. The order of Copy items within the AI block menu is stable: Copy → Copy prompt → Copy output as Markdown → (Copy URL or Copy path, when on a link) → Copy command (when applicable) → Copy git branch (when applicable). Non-Copy items retain their existing relative order.
7. Right-clicking anywhere inside an AI response where the cursor is not on a hyperlink shows the existing AI block context menu unchanged — no link-specific items, no reorderings, no omissions.
8. Right-clicking inside an AI response while a text selection is active shows the existing selection-oriented menu (Copy, Insert into input, optionally Ask Warp AI / Attach as agent mode context). No "Copy URL" or "Copy path" item is added in this case, even if the selection overlaps a link — the user's primary intent is the selection.
9. Right-clicking a link in the terminal grid (outside AI responses) is unchanged: it continues to show the existing grid link context menu (Copy URL / Copy path / Show in Finder / Open in Warp / Open in editor).
10. Right-click never crashes or panics when there is no hovered link at the time the menu is requested; the link-specific item is simply omitted.
11. The link-specific item is computed from the hover state at the moment the menu is opened. If the hovered link changes or disappears while the menu is open, the already-shown menu is not mutated; the next right-click recomputes from the new hover state.
+119
View File
@@ -0,0 +1,119 @@
# APP-1915: Tech Spec
## Context
See `PRODUCT.md` for user-visible behavior. The feature branch was rewritten before this spec was finalized, so the diff against `master` is additive only — it introduces the hover-aware plumbing rather than removing a buggy short-circuit.
Implementation anchors in the current code:
- `app/src/ai/blocklist/block.rs (3910-3936)` — new `AIBlock::hovered_rich_content_link`. Reads `detected_links_state.currently_hovered_link_location` and maps the underlying `DetectedLinkType` into a `RichContentLink` (`Url` or, under `#[cfg(feature = "local_fs")]`, `FilePath { absolute_path, line_and_column_num, target_override }`).
- `app/src/terminal/view.rs:14252` — new `TerminalView::hovered_rich_content_link_for_view`, a thin wrapper that resolves the `EntityId` back to the `AIBlock` handle and delegates to `AIBlock::hovered_rich_content_link`.
- `app/src/terminal/view.rs (14262-14803)``context_menu_items`. The existing top match at `(14269-14340)` continues to handle the terminal grid `highlighted_link` path unchanged. The `RichContentBlockRightClick` branch at `(14712-14803)` is the AI block path; this is where the hovered link is computed and threaded through.
- `app/src/terminal/view.rs (15514-15655)``ai_block_copying_menu_items`, which builds the Copy group (Copy → Copy prompt → Copy output as Markdown → conditional Copy command / Copy git branch → Save as prompt → Share conversation → Copy conversation text). The link-specific item is inserted immediately after "Copy output as Markdown" and before the conditional Copy command / git branch items.
- `RichContentLink` enum (in `view.rs`): `Url(String)` and `#[cfg(feature = "local_fs")] FilePath { absolute_path, line_and_column_num, target_override }`. `ContextMenuAction::CopyUrl { url_content }` is reused for both variants — for `FilePath`, the absolute path is copied via `to_string_lossy().into_owned()`.
There are two callers of `ai_block_copying_menu_items` in `view.rs`, both updated to accept the new `Option<RichContentLink>` parameter:
- `view.rs:14723` — right-click on an AI block (`BlockListMenuSource::RichContentBlockRightClick`). Passes the computed `Some(link)` when the cursor is over a hyperlink, `None` otherwise.
- `view.rs:15716``open_ai_block_overflow_context_menu`, triggered by the three-dot overflow button on an AI block. This surface has no hovered-link concept, so it always passes `None`.
`RichContentTextRightClick` (selection-active right-click in an AI block) intentionally does not participate: it is handled by a different arm in `context_menu_items` and builds the selection-oriented menu, per `PRODUCT.md` Behavior 8.
## Proposed changes
1. **Add `AIBlock::hovered_rich_content_link`** (`app/src/ai/blocklist/block.rs`). Returns `Option<RichContentLink>` by reading the already-maintained `detected_links_state.currently_hovered_link_location` and mapping the underlying `DetectedLinkType` into the `RichContentLink` variants the terminal view already understands.
2. **Add `TerminalView::hovered_rich_content_link_for_view`** (`app/src/terminal/view.rs`). Resolves the `EntityId` to the AI block handle via the existing `ai_block_handle_by_view_id` helper and delegates to `AIBlock::hovered_rich_content_link`.
3. **Add an `Option<RichContentLink>` parameter to `ai_block_copying_menu_items`.** When `Some`, push exactly one additional `MenuItem` immediately after "Copy output as Markdown" (before the conditional "Copy command" / "Copy git branch"):
```rust path=null start=null
if let Some(link) = hovered_link {
match link {
RichContentLink::Url(url) => items.push(
MenuItemFields::new("Copy URL")
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyUrl { url_content: url },
))
.into_item(),
),
#[cfg(feature = "local_fs")]
RichContentLink::FilePath { absolute_path, .. } => items.push(
MenuItemFields::new("Copy path")
.with_on_select_action(TerminalAction::ContextMenu(
ContextMenuAction::CopyUrl {
url_content: absolute_path.to_string_lossy().into_owned(),
},
))
.into_item(),
),
}
}
```
4. **Update both callers of `ai_block_copying_menu_items`:**
- `view.rs:14723` in the `RichContentBlockRightClick` branch — compute the hovered link once and pass it through:
```rust path=null start=null
let hovered_link = self.hovered_rich_content_link_for_view(*rich_content_view_id, ctx);
items.extend(self.ai_block_copying_menu_items(
*rich_content_view_id,
ai_metadata.conversation_id,
hovered_link.clone(),
&model,
ctx,
));
```
- `view.rs:15716` in `open_ai_block_overflow_context_menu` — always pass `None` (the overflow button has no hover context).
5. **Intentionally skip `RichContentTextRightClick`.** That branch fires only when a text selection is active (see `block_list_element.rs (1417-1428)`) and `PRODUCT.md` Behavior 8 keeps the selection-oriented menu unchanged.
## End-to-end flow
```mermaid
sequenceDiagram
participant User
participant BlockList as BlockListElement
participant View as TerminalView
participant AIBlock as AIBlock
participant Clipboard
User->>BlockList: Right-click on URL inside AI response
BlockList->>View: BlockListMenuSource::RichContentBlockRightClick
View->>View: hovered_rich_content_link_for_view()
View->>AIBlock: hovered_rich_content_link()
AIBlock-->>View: Some(RichContentLink::Url(url))
View->>View: ai_block_copying_menu_items(..., Some(link), ...)
Note over View: Inserts "Copy URL" after "Copy output as Markdown"
View-->>User: Full AI block menu + Copy URL
User->>View: Click "Copy URL"
View->>Clipboard: Write url
```
## Risks and mitigations
1. **Menu ordering regressions.** Insertion is strictly after "Copy output as Markdown" and before "Copy command" / "Copy git branch"; `PRODUCT.md` Behavior 6 pins the order. Manual validation confirms it.
2. **`local_fs` feature gating.** The "Copy path" branch stays behind `#[cfg(feature = "local_fs")]` to match the existing `RichContentLink::FilePath` variant. Covered by `PRODUCT.md` Behavior 4.
3. **Selection path.** `RichContentTextRightClick` does not receive the new item. Intentional per `PRODUCT.md` Behavior 8; a link-specific path during selection can be added as a follow-up if needed.
## Testing and validation
Each `PRODUCT.md` Behavior invariant maps to a concrete verification step:
- Behavior 1, 2, 6: Manual — open an AI response with a URL list (similar to the APP-1915 screenshot), right-click a URL, confirm the full AI block menu is shown with "Copy URL" inserted immediately after "Copy output as Markdown" and before any "Copy command" / "Copy git branch". Click it and confirm the clipboard contains the URL verbatim.
- Behavior 3, 4: Manual on a `local_fs` build — right-click a file-path link in an AI response, confirm "Copy path" is in the same position and copies the absolute path. On a non-`local_fs` build, confirm no "Copy path" item appears and the rest of the menu is unchanged.
- Behavior 5: Manual — hover a link, right-click, confirm the link-specific item appears exactly once and that "Copy URL" and "Copy path" never appear together.
- Behavior 7: Manual — right-click in an AI response body away from any link; menu matches the pre-regression baseline with no link-specific item.
- Behavior 8: Manual — with a text selection inside an AI response, right-click and confirm the selection-oriented menu is unchanged.
- Behavior 9: Manual — right-click a URL in the terminal grid; grid link menu is unchanged.
- Behavior 10: The new conditional push is guarded by `Option::Some`, so a missing hovered link cannot panic. A small regression test in `view_test.rs` that asserts "Copy URL" is present when a hovered URL link is set — and absent otherwise — is recommended alongside the manual checks.
- Behavior 11: Implicitly covered — menu items are built from hover state at the moment the menu is opened; no mutation of an already-open menu.
Existing `view_test.rs` coverage for `RichContentBlockRightClick` must continue to pass.
## Follow-ups
- Optional: add "Open link" or "Open in editor" items for file paths in AI responses, to reach parity with the grid link menu.
- Optional: add a hovered-link path for `RichContentTextRightClick` if user feedback asks for it.
+208
View File
@@ -0,0 +1,208 @@
# Block List Markdown Table Rendering — Product Spec
Linear: none provided
Figma: House of Agents — https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7451-99490&t=NvrWl7bhDEC5kpKF-1
## Summary
Render valid GitHub Flavored Markdown tables in AI block list responses as formatted inline tables instead of raw pipe-delimited text. The rendered table should appear directly in the response flow, match the table rendering capabilities already supported in notebooks, and preserve correct text selection behavior.
## Problem
AI responses often include Markdown tables to compare options, summarize data, or present structured results. In the AI block list, those tables are currently much harder to read because the response is shown as plain text table markup rather than as a formatted table. That makes scanning difficult, weakens visual hierarchy, and creates an inconsistent experience with notebooks, where tables already support richer rendering behavior.
This is especially problematic when table cells include inline Markdown such as bold text, inline code, strikethrough, or links. When those constructs are not rendered as formatted content inside the table, the response becomes noisy and less useful.
Selection behavior is also critical. Users need to be able to select text from rendered tables naturally, including partial selections, cross-cell selections, and selections that begin before or after the table in surrounding prose.
## Goals
- Render valid GFM pipe tables inline within AI block list responses.
- Make rendered block list tables visually and behaviorally consistent with the Markdown table primitives already supported in notebooks.
- Support the same inline formatting features inside table cells that notebook tables support.
- Preserve normal text selection and copy behavior for rendered table content.
- Keep surrounding AI response content unchanged before and after the table.
- Fall back gracefully to normal text rendering when the content is not a valid table.
## Non-goals
- Adding table editing capabilities to the AI block list.
- Supporting non-GFM table syntaxes.
- Expanding notebook table feature support as part of this work.
- Introducing a block-list-specific table feature set that diverges from notebooks.
- Converting arbitrary aligned plain text into a rendered table when it is not valid Markdown table syntax.
## User Experience
### Scope
This feature applies to Markdown-formatted AI responses rendered in the AI block list.
If a response contains a valid GitHub Flavored Markdown pipe table, that table should render inline as a formatted table in the response body. Content before and after the table should continue to render as normal Markdown text in the same response.
This behavior applies equally to:
- newly streamed AI responses
- previously rendered responses reopened from history
- transcript or restored views that display the same block list content
### Table detection
A response section should render as a table only when it is a valid GFM pipe table.
A valid table must:
- include a header row
- include a separator row
- have a consistent column structure for rows that belong to the table
If content does not form a valid table, it should remain plain Markdown text. The product should prefer not rendering a table over rendering an incorrect or partially broken table.
Tables inside fenced code blocks must continue to render as code, not as tables.
### Inline rendering behavior
When a valid table is detected, it should render as a formatted table directly in the AI block list rather than as a raw text block containing `|` characters and alignment markers.
The rendered table should behave as part of the normal response flow:
- it appears between the preceding and following response content
- it uses block list styling appropriate for AI output
- it does not require opening a notebook or a separate viewer
- it does not introduce a separate interaction mode
The baseline visual target for inline AI block list tables is the House of Agents design linked above, specifically node `7451:99490`.
The table may use horizontal scrolling when necessary to remain readable within the available block width. If horizontal overflow occurs, the table should remain fully usable and readable without truncating content unpredictably.
Wide tables should have their own horizontal scrollbar so users can scroll sideways within the table itself.
Tall tables should not introduce a separate vertical scrollbar. Vertical movement through table content should happen through the normal block list scroll behavior so the table remains part of the surrounding response flow rather than becoming a nested vertically scrolling region.
### Visual design requirements
Inline AI block list tables should match the House of Agents design:
- the table renders directly in the response flow rather than inside a card-like container
- there is no rounded outer container and no filled table background
- the header row uses bold text and primary text color while body rows use secondary text color
- table cell text uses the same font family and font size as the surrounding block list response content
- rows are separated by thin horizontal dividers
- columns do not show vertical divider lines
- the table does not show a full outer border around the perimeter
- row spacing should match the designs more open presentation, approximately 12px vertical cell padding
These presentation rules do not change the underlying Markdown, copy-response behavior, or the supported inline Markdown formatting inside cells.
### Rendering parity with notebooks
Rendered block list tables should support the same table rendering primitives already supported in notebooks. The block list should not invent a reduced or alternate table dialect.
At minimum, if notebooks support these constructs within table cells, the AI block list should render them the same way:
- left, center, and right column alignment
- bold text
- italic text
- bold-italic text
- inline code
- strikethrough
- links
- escaped pipe characters rendered as literal pipes
Alignment must remain visibly correct even when a cell's content is narrower than its column. Center-aligned and right-aligned columns should render centered and right-justified within the computed column width rather than collapsing back to left alignment.
More generally, the user-visible rule is:
If a piece of Markdown table content is supported and rendered in notebooks, it should render equivalently in the AI block list unless there is a clear product reason not to. This spec assumes parity is the default.
### Links inside table cells
Links inside table cells should render as links, using the same interaction model users already get for links in other AI Markdown output.
Single-click behavior should match normal link behavior in the block list.
Text selection must still work correctly in cells that contain links:
- click-and-drag should create a text selection rather than unexpectedly activating the link
- selecting across linked and non-linked text should behave the same as selection elsewhere in the response
### Selection behavior
Rendered tables must support normal text selection. This is a core requirement, not a best-effort enhancement.
Selection should work for:
- text within a single cell
- text spanning multiple cells in a row
- text spanning multiple rows
- selections that begin before the table and continue into the table
- selections that begin in the table and continue into following prose
- selections in horizontally scrolled tables
Selection should operate on rendered text content, not on table chrome such as borders, padding, or layout spacing.
When text is copied from a rendered table selection, the copied result should reflect the selected textual content in reading order. It should not include visual-only layout artifacts.
This feature should not regress existing selection behavior elsewhere in the block list.
### Streaming behavior
Because AI responses stream into the block list over time, table rendering should behave predictably during streaming.
The intended experience is:
- once the content is sufficient to identify a valid table, the table renders as a table
- as additional table rows stream in, the rendered table extends naturally
- streaming updates should not unexpectedly clear an active text selection
- the transition from plain incoming text to rendered table should feel stable rather than visually noisy
If the response later stops matching a valid table boundary, subsequent content should render as normal response content after the table rather than corrupting the rendered table.
### Multiple tables and surrounding content
A single AI response may contain:
- multiple tables
- prose before, between, and after tables
- code blocks adjacent to tables
- headings, lists, or other Markdown outside the table
Each table should render independently in the correct place within the response. Non-table content should continue to use its existing rendering behavior.
### Invalid or malformed table content
If a candidate table is malformed, ambiguous, or incomplete, the UI should fall back to rendering the original content as ordinary Markdown text rather than attempting a degraded table rendering.
Examples of content that should not render as a formatted table include:
- lines with pipe characters but no valid separator row
- content inside fenced code blocks
- structures that look table-like but do not resolve into a consistent table section
When a valid table ends, the next non-table content should resume normal rendering immediately after it.
### Large and wide tables
Some AI responses will contain wide tables or cells with long text.
For these cases:
- the table should remain legible inside the block list
- horizontal overflow should be handled gracefully with a table-local horizontal scrollbar
- the user should still be able to select and copy text from off-screen columns by scrolling
- vertical overflow should be handled by the block list's normal scrolling rather than by a table-local vertical scrollbar
- wide content should not break surrounding layout or cause the rest of the response to render incorrectly
### Interaction model
Rendered tables in the block list are read-only presentation of AI output.
Users should not be able to directly edit the rendered table in place. Existing higher-level actions on the AI response should continue to behave as they do today unless explicitly changed by a follow-up spec.
### Copy behavior
Block-level “copy response” behavior should copy the original Markdown source for the AI response, not a rendered or reformatted table export.
This means:
- a copied response preserves the original Markdown table syntax the model returned
- rendering a table inline in the block list does not change what block-level copy response returns
- manual text selection and copy from the rendered output may still copy the selected rendered text content, but response-level copy should preserve the original Markdown source
## Success Criteria
- A valid GFM table in an AI response renders inline in the block list as a formatted table instead of raw pipe-delimited text.
- Column alignment in the block list matches the alignment expressed by the Markdown and matches notebook table behavior.
- Inline Markdown that notebooks support inside table cells also renders correctly inside block list tables.
- Links inside cells render and behave like links without breaking text selection.
- Users can select text naturally within and across rendered table cells and rows.
- Users can extend a selection across table boundaries into surrounding prose and vice versa.
- Wide tables remain usable through a table-local horizontal scrollbar without breaking layout or selection.
- Tall tables do not create a nested vertical scrolling region and instead scroll with the surrounding block list content.
- Block-level copy response preserves the original Markdown source, including original table syntax.
- Invalid table-like content falls back to normal text rendering rather than rendering an incorrect table.
- Multiple tables in one response render independently in the correct order.
- Streamed responses and restored responses render the same table content consistently.
## Validation
- Manual validation with a simple two-column GFM table in an AI response.
- Manual validation with alignment coverage: left, center, and right aligned columns in the same table.
- Manual validation with cell formatting coverage: bold, italic, bold-italic, inline code, strikethrough, links, and escaped pipes.
- Manual validation that text can be selected inside one cell, across multiple cells, across multiple rows, and across table-to-prose boundaries.
- Manual validation that click-and-drag on linked text selects text rather than unexpectedly opening the link.
- Manual validation of a wide table that requires horizontal scrolling, confirming the table shows its own horizontal scrollbar and that readability and selection still work.
- Manual validation of a tall table, confirming it does not show its own vertical scrollbar and instead scrolls with the surrounding block list.
- Manual validation of a response containing prose, a table, more prose, and a second table.
- Manual validation that a fenced code block containing table-looking Markdown still renders as code.
- Manual validation that malformed or incomplete table syntax falls back to plain Markdown text.
- Manual validation that block-level copy response returns the original Markdown source for a response containing a table.
- Regression validation that the same Markdown table content renders equivalently in notebooks and in the AI block list wherever the notebook renderer already supports that content.
+300
View File
@@ -0,0 +1,300 @@
# Block List Markdown Table Rendering — Tech Spec
Product spec: `specs/APP-3076/PRODUCT.md`
## Problem
The AI block list already detects GFM-style tables, but it degrades them into a single preformatted text blob. That loses the structured table model we already have elsewhere in the repo, prevents reuse of notebook-style inline cell formatting, and makes copy semantics awkward because the same string is currently used for rendering and clipboard export.
The implementation needs to satisfy four constraints at once:
- reuse shared Markdown table parsing rather than maintaining a second table parser
- render with a read-only UI component appropriate for the block list rather than the editor-specific notebook renderer
- preserve source Markdown for block-level copy actions
- support block-list-native interaction rules: horizontal-only local scrolling, no nested vertical scrolling, and correct text selection
## Relevant Code
- `specs/APP-3076/PRODUCT.md` — approved product behavior
- `app/Cargo.toml` — compile-time feature declarations for feature-flagged app functionality
- `app/src/lib.rs (2363-2561)` — compile-time-to-runtime `FeatureFlag` wiring for app features
- `app/src/ai/agent/util.rs:24-145``parse_markdown_into_text_and_code_sections`; current AI-output section splitter
- `app/src/ai/agent/mod.rs (1281-1300)``AIAgentTextSection`; current table payload is `content: String`
- `app/src/ai/agent/mod.rs (1547-1588)``Display for AIAgentOutputMessage`; current copy/export path prints table `content`
- `app/src/ai/blocklist/block.rs (4941-5002)` — AI block copy helpers
- `app/src/ai/blocklist/block.rs (5923-5937)``Copy`, `CopyOutput`, and related clipboard actions
- `app/src/ai/blocklist/block/view_impl/common.rs (1139-1188)``render_table_section`; current table renderer is a `Text` element inside a horizontal scroller
- `app/src/ai/blocklist/block/find.rs:70-90` — find currently searches table `content` directly
- `markdown_parser/src/markdown_parser.rs:106-189``parse_markdown` and `parse_markdown_with_gfm_tables`
- `markdown_parser/src/markdown_parser.rs (333-458)` — structured GFM table parsing (`parse_table`, separator parsing, inline cell parsing)
- `markdown_parser/src/lib.rs (338-438)``FormattedTable` and `TableAlignment`
- `editor/src/content/text.rs (61-109)``parse_table_cell_markdown_inline`
- `editor/src/content/text.rs:261-279``table_from_internal_format_with_inline_markdown`; current notebook-side table reconstruction helper
- `editor/src/render/model/mod.rs:454-476` — notebook `TableStyle`
- `editor/src/render/model/mod.rs (1140-1234)``LaidOutTable`; notebook table layout and selection model
- `editor/src/render/element/table.rs:1-220` — notebook table painting path
- `ui/src/elements/table/mod.rs (117-240)` — shared `Table`, `TableConfig`, and column sizing API
- `ui/src/elements/table/mod.rs (517-980)``Table` layout behavior, including intrinsic measurement and viewport sizing
- `ui/src/elements/table/mod.rs (1002-1478)` — selection and scroll behavior for the shared table element
- `warp_core/src/features.rs``FeatureFlag` definitions and dogfood/default channel enablement
## Current State
### AI block list path
The block list does not use the shared Markdown table model. `parse_markdown_into_text_and_code_sections` in `app/src/ai/agent/util.rs:24-145` uses a custom helper from `ai::gfm_table` to detect a table-shaped region while scanning the response line-by-line. Once it finds one, it emits `AIAgentTextSection::Table { content: String }`, where `content` is a normalized pipe-delimited string rather than a structured table object.
Rendering then happens in `render_table_section` in `app/src/ai/blocklist/block/view_impl/common.rs (1139-1188)`, which places that string into a single `Text` node wrapped in a horizontal scroller. This satisfies the current “readable monospace dump” behavior, but it does not preserve:
- structured column alignment
- notebook-style inline formatting inside cells
- a clean separation between source Markdown and rendered representation
Because `AIAgentTextSection::Table` only stores the rendered string, copy/export and find also operate on that same string.
### Notebook path
The notebook/editor stack already has a real structured table model:
- `markdown_parser` can parse GFM tables into `FormattedTable`
- `editor/src/content/text.rs` has helpers for reconstructing a `FormattedTable` from the editors internal representation while preserving inline Markdown styles
- `editor/src/render/model/mod.rs` and `editor/src/render/element/table.rs` lay out and paint tables with alignment, padding, selection mapping, and per-cell text frames
That renderer is not a good direct fit for the block list because it is tied to the editor buffer/render model, offset maps, and editor-specific interaction flow.
### Shared UI table component
The shared `ui::elements::Table` is much closer to what the block list needs: it supports arbitrary cell elements, per-column sizing, selection delegation, and independent composition with surrounding UI.
However, its current defaults do not match the product requirements for the block list:
- it assumes an internal vertical viewport and implements vertical scrolling itself
- selection over virtualized rows is intentionally incomplete when rows are off-screen
- intrinsic width measurement only looks at headers, not row content
Those defaults are reasonable for a general-purpose scrollable table, but not for a block-list table that must stay vertically integrated with the surrounding response.
## Proposed Changes
### 0. Gate the feature behind `BlocklistMarkdownTableRendering`
Add a dedicated feature flag named `BlocklistMarkdownTableRendering` and wire it through the normal Warp feature-flag plumbing:
- add `blocklist_markdown_table_rendering` to `app/Cargo.toml`
- map that Cargo feature to `FeatureFlag::BlocklistMarkdownTableRendering` in `app/src/lib.rs`
- add the new enum variant in `warp_core/src/features.rs`
- enable it by default for dogfood builds via `DOGFOOD_FLAGS`
The new structured table rendering should only activate when this flag is enabled. When disabled, AI block list responses should continue to detect tables and render them with the pre-feature monospace scrollable table block.
### 1. Replace string-backed table sections with a structured payload
Introduce a dedicated AI-output table type in `app/src/ai/agent/mod.rs`, for example:
```rust
pub struct AgentOutputTable {
pub markdown_source: String,
pub table: FormattedTable,
}
```
Then change `AIAgentTextSection::Table` from:
```rust
Table { content: String }
```
to:
```rust
Table { table: AgentOutputTable }
```
This gives the block list two representations of the same table:
- `markdown_source` for response-level copy/export
- `FormattedTable` for rendering and selection-aware display
This is the key ownership boundary for the feature. We should not try to derive clipboard Markdown back from the rendered UI.
### 2. Reuse `markdown_parser` for table parsing
Stop using the custom `ai::gfm_table::maybe_parse_gfm_table` path as the source of truth for parsed table structure.
Instead, add a small shared helper in `markdown_parser` that parses a contiguous GFM table block into `FormattedTable`. The block list section splitter in `app/src/ai/agent/util.rs` should continue to own boundary detection between:
- plain text
- fenced code blocks with metadata
- tables
but once it has collected a candidate table block, it should hand the raw Markdown to `markdown_parser`, not re-parse inline cell content itself.
Concretely:
- keep the existing line-oriented section splitter in `app/src/ai/agent/util.rs` so code-block metadata parsing remains unchanged
- replace the current custom table-formatting helper with a new shared parser entry point from `markdown_parser`
- store the exact raw table Markdown in `markdown_source`, preserving spacing and source syntax for copy/export
This reuses the repos actual GFM table parsing logic, including:
- alignment parsing
- inline Markdown in cells
- links, inline code, bold, italic, and strikethrough handling
### 3. Preserve source Markdown in copy/export flows
Update the copy/export paths that currently rely on `AIAgentOutputMessage` display formatting to use the tables `markdown_source` rather than a rendered or normalized text serialization.
The affected flows are the existing AI block and conversation export paths in:
- `app/src/ai/agent/mod.rs (1547-1588)`
- `app/src/ai/agent/conversation.rs:1082`
- `app/src/ai/blocklist/block.rs (4941-5002, 5923-5937)`
The rule is:
- block-level copy actions use `markdown_source`
- rendered-text selection continues to come from the UI layer
This keeps block-level copy behavior aligned with the product spec without complicating the table renderer.
### 4. Add a block-list table renderer built on the shared UI `Table`
Add a new renderer for AI-output Markdown tables in the block list, either as a helper in `app/src/ai/blocklist/block/view_impl/common.rs` or as a dedicated view/component in the same module tree.
The renderer should:
- take `&AgentOutputTable`
- build a `ui::elements::Table`
- create one header element per `FormattedTable::headers` entry
- create one row per `FormattedTable::rows` entry
- wrap the table in the existing horizontal `NewScrollable` / `ClippedScrollStateHandle` composition used for block-list tables today
Each cell should be rendered with `FormattedTextElement`, using a one-line `FormattedText` built from the cells `FormattedTextInline` fragments. This preserves the same inline Markdown primitives already supported by notebooks without embedding the editor renderer.
This renderer should intentionally stay read-only. No editor state, offset map, or notebook-specific block model should be introduced into the block list.
### 5. Extend the shared UI `Table` for block-list usage
The shared UI table needs one opt-in mode for this feature.
Add an explicit vertical sizing mode to `ui/src/elements/table/mod.rs` rather than another boolean toggle. The shared `TableConfig` should expose an enum that distinguishes the default viewported behavior from a full-content mode, e.g. `TableVerticalSizing::Viewported` vs `TableVerticalSizing::ExpandToContent`.
In `ExpandToContent` mode:
- the table expands to its full content height
- table-local vertical scrolling is disabled
- the parent scroll container owns vertical scrolling
This keeps the API clear about the underlying layout model rather than asking callers to infer semantics from a boolean.
- existing behavior remains the default `Viewported` mode
- the block list opts into `ExpandToContent`
- layout measures all rows, not just visible rows
- the element reports full content height
- `ScrollableElement` does not capture vertical wheel scrolling for the table
- the table no longer behaves like its own vertical viewport
This change is necessary for two reasons:
1. it enforces the product rule that tall tables scroll with the block list, not inside a nested scroller
2. it removes the current virtualization-related selection limitation for off-screen rows
### 6. Make intrinsic widths account for row content in block-list mode
If we render block-list tables with the current `ui::elements::Table` intrinsic sizing behavior, only header content contributes to intrinsic widths. That is likely to produce visibly different results from notebook tables when body cells are wider than their headers.
To keep the block-list result visually close to notebook tables, add an opt-in width measurement path for the shared `Table` so intrinsic column widths can include body cells when desired.
This should be scoped narrowly:
- preserve current default behavior for existing `Table` users
- enable body-cell-aware intrinsic sizing only for block-list Markdown tables in `TableVerticalSizing::ExpandToContent` mode
Because block-list tables will already be in `ExpandToContent` mode, we can avoid a separate measurement-only render pass: render each row once in the full-content layout path, measure unconstrained intrinsic widths from those already-instantiated body cells, then lay those same row elements out with the final computed column widths. This removes the extra `render_fn` pass for intrinsic body-width measurement while keeping the change scoped to the block-list path.
### 7. Separate find/search text from source Markdown
After the table payload becomes structured, the block list should not use `markdown_source` for find matching. That would make the find surface operate on Markdown syntax instead of rendered text.
Add a helper on `AgentOutputTable` that flattens the parsed table into plain find/selection text in row-major order, using tab-separated cells and newline-separated rows. Then update `app/src/ai/blocklist/block/find.rs:70-90` to search that derived plain text instead of the raw Markdown source.
This keeps find behavior aligned with the rendered content while leaving clipboard export source-accurate.
## End-to-End Flow
1. The AI response streams in as Markdown text.
2. `parse_markdown_into_text_and_code_sections` in `app/src/ai/agent/util.rs` continues scanning line-by-line.
3. When it encounters a candidate table region, it collects the raw Markdown block and hands it to a shared `markdown_parser` helper.
4. The parser returns a `FormattedTable`.
5. The block list stores that as `AIAgentTextSection::Table { table: AgentOutputTable { markdown_source, table } }`.
6. The block renderer sees the table section and builds a read-only WarpUI `Table`.
7. The WarpUI table renders inline cell formatting via `FormattedTextElement`.
8. The block list wraps the table in a horizontal scroller only.
9. Vertical scrolling stays with the surrounding block list.
10. Block-level copy actions export `markdown_source`; selection copy comes from the rendered table elements.
## Implementation Plan
### Phase 1: Data model and parsing
- Add `BlocklistMarkdownTableRendering` feature-flag plumbing and gate the block-list table behavior behind it
- Add `AgentOutputTable` and update `AIAgentTextSection`
- Add a shared GFM-table parsing helper in `markdown_parser`
- Update `app/src/ai/agent/util.rs` to emit structured table sections with preserved `markdown_source`
- Remove or stop using the custom `ai::gfm_table` helper
### Phase 2: Copy/find behavior
- Update AI output formatting and block/conversation copy flows to use `markdown_source`
- Add a plain-text flattening helper for find
- Update `app/src/ai/blocklist/block/find.rs` to search rendered table text rather than raw Markdown
### Phase 3: UI table support
- Replace the expand-to-content boolean with an explicit `TableVerticalSizing` enum on `TableConfig`
- Extend `ui::elements::Table` with an `ExpandToContent` mode that disables local vertical scrolling
- Extend intrinsic measurement so body cells can participate when requested, using the single full-content layout pass in `ExpandToContent` mode
- Keep existing behavior as the default for current users of the component
### Phase 4: Block-list rendering
- Replace the current monospace `render_table_section` with a structured renderer built on WarpUI `Table`
- Reuse current horizontal scroll handle plumbing
- Match notebook table styling as closely as practical via block-list table theme helpers
## Risks and Mitigations
### Risk: nested vertical scrolling or incomplete selection
Using the shared `Table` without modification would keep the current vertical viewport and virtualization behavior, which conflicts with the product spec.
Mitigation:
- add an explicit `TableVerticalSizing::ExpandToContent` mode for block-list tables
- disable table-local vertical scrolling in that mode
### Risk: copy/export regressions
Today the table sections rendered string is also what gets copied. Moving to structured tables could accidentally change clipboard output.
Mitigation:
- make `markdown_source` a first-class field on the table payload
- route copy/export through that field explicitly
- add unit coverage for block-level and conversation-level copy
### Risk: visual mismatch with notebook tables
If block-list tables use header-only intrinsic sizing or different theme tokens, they may look noticeably different from notebook tables.
Mitigation:
- add body-cell-aware intrinsic sizing for block-list mode
- define a small style translation helper that mirrors notebook table border, padding, alternating-row, and header treatments as closely as practical
### Risk: performance on very large tables
Expanding to full height and measuring all rows is more expensive than a virtualized viewport, even after removing the extra intrinsic-width render pass.
Mitigation:
- accept the tradeoff for the first version because AI-response tables are typically modest in size
- keep the expand-to-content mode opt-in and local to this feature
- revisit with profiling only if large-table responses become a real issue
## Testing and Validation
### Parser and data-model tests
- Add `markdown_parser` tests for the new shared table-block parser:
- simple tables
- alignment parsing
- inline formatting in cells
- links
- strikethrough
- escaped pipes
- invalid/non-table input
- Add `app/src/ai/agent/util_tests.rs` coverage that verifies:
- table sections preserve exact `markdown_source`
- code blocks that contain table-looking text are not parsed as tables
- prose before/after a table still produces the correct section ordering
### Copy and find tests
- Add tests covering `AIAgentOutputMessage` / exchange formatting to verify block-level copy uses original Markdown table syntax
- Add block-list find tests to verify searches match rendered cell text rather than Markdown syntax
### UI table tests
- Add WarpUI table tests for the new expand-to-content mode:
- no local vertical scroll behavior
- full content height is returned
- selection spans all rows because no rows are virtualized away
- Add table sizing tests covering body-cell-aware intrinsic measurement
### Block-list rendering validation
- Manual validation that wide tables get a local horizontal scrollbar
- Manual validation that tall tables scroll with the block list and do not show a nested vertical scrollbar
- Manual validation that selection works within cells, across cells, across rows, and across prose/table boundaries
- Manual validation that Markdown links in cells remain clickable while click-drag still selects text
- Manual validation that rendered output visually matches notebook tables closely for alignment, padding, borders, and row treatment
## Follow-ups
- Generalize the block-list table renderer into a reusable read-only Markdown table view if other surfaces need it
- Consider adding autodetected file-path/URL highlighting inside table cells if we decide block-list tables should match plain-text-section link detection behavior as well
- If future AI outputs include very large tables, revisit whether the shared `Table` should support a hybrid mode that preserves block-list vertical scrolling while still reducing layout cost
+38
View File
@@ -0,0 +1,38 @@
# Mermaid diagram rendering in notebooks
Mermaid diagrams should be automatically recognized and rendered when they appear in GitHub Flavored Markdown documents within notebooks.
## Raw and rendered views
In raw view, Mermaid code blocks should remain unaltered and visible exactly as authored in the notebook markdown.
In rendered view, those same Mermaid blocks should appear as rendered images rather than raw source text.
## Rendering lifecycle
Rendering a Mermaid diagram may take time, so the UI should show a loading placeholder while the image is being generated.
Diagram generation must not block the UI. Rendering work should happen asynchronously, likely on a background thread.
## Clipboard and selection behavior
Selection across rendered Mermaid diagrams should preserve the authored markdown text when copied.
When rich-text/HTML clipboard output is available, Mermaid selections may also include HTML that represents the rendered diagram for paste targets that understand HTML.
This iteration does not place diagram image bytes on the clipboard, and direct image-only copy affordances for rendered Mermaid diagrams are out of scope.
## Scrolling and layout behavior
When the outer notebook scrolls, the Mermaid image should scroll naturally with the notebook content.
In rendered view, Mermaid diagrams should behave like responsive block content rather than like fixed-size thumbnails.
By default, a rendered Mermaid diagram should match the sizing behavior users expect from other markdown renderers: it should render at its natural width when that width fits comfortably within the notebook, and scale down to fit the available notebook content width when the diagram would otherwise overflow.
We are explicitly not stretching smaller diagrams to fill the full available notebook width by default.
The rendered height should be derived from the diagram's aspect ratio at the chosen width. We should not impose a small fixed default height that causes the diagram to be scaled down inside a larger box.
The rendered diagram should remain fully visible within its block without cropping or letterboxing. The notebook layout must reserve the rendered image height so the diagram never overlaps content below it. If scaling the diagram down to the notebook width still makes it tall, the notebook should simply scroll normally.
This default behavior should optimize for readability of diagram text and labels in the notebook reading experience.
For very large or dense diagrams, future iterations may add dedicated zoom or expand affordances, but the baseline sizing behavior should still be natural-width-or-fit-width with flexible height.
## Theming
We don't need to make mermaid diagram themes match the terminal theme to start but may want this in the future.
## Export
When exporting markdown we should export the raw markdown that was used to generate the diagram.
+22
View File
@@ -0,0 +1,22 @@
# Problem
Add Mermaid support to notebook markdown in Warp by recognizing Mermaid fenced blocks, rendering them to SVG asynchronously via the Rust Mermaid renderer, displaying the result in notebook rendering, and gating the behavior behind Warps feature-flag conventions.
## Current state
* Notebook bodies already flow through the shared markdown/buffer pipeline: notebook views call `NotebooksEditorModel::reset_with_markdown` / `update_to_new_markdown`, which delegate to the shared rich-text editor reset/delta path and `Buffer::from_markdown` (`app/src/notebooks/editor/model.rs (236-255)`, `editor/src/model.rs (902-966)`, `editor/src/content/buffer.rs (759-840)`).
* The markdown parser only special-cases embedded objects and table blocks; other fenced blocks remain `FormattedTextLine::CodeBlock` with the original info string preserved (`markdown_parser/src/markdown_parser.rs (39-43)`, `markdown_parser/src/markdown_parser.rs (145-160)`). Code blocks are then normalized into `CodeBlockType` in the editor layer (`editor/src/content/text.rs (534-666)`).
* Shared editor rendering already has reusable async image infrastructure: assets can be `Async` or `Raw`, and SVG bytes are parsed/rendered natively by the image cache (`ui/src/assets/asset_cache.rs (66-84)`, `ui/src/assets/asset_cache.rs (284-352)`, `ui/src/image_cache.rs (215-460)`).
* Plain image blocks are not text-editable in the rich-text model/hit-testing path, so Mermaid should not be persisted as a normal markdown image if we want notebook markdown to keep fenced Mermaid source and round-trip cleanly (`editor/src/content/core.rs (741-940)`, `editor/src/render/model/location.rs (101-252)`).
* Warp feature flags follow the existing `FeatureFlag` + Cargo feature + app registration pattern; `MarkdownTables` is the closest notebook-markdown precedent (`warp_core/src/features.rs (426-532)`, `warp_core/src/features.rs (757-917)`, `app/Cargo.toml (640-776)`, `app/src/lib.rs (2382-2581)`).
* The Mermaid renderer already exists as a standalone pure-Rust repository with a single `render_mermaid_to_svg` API plus theme support. That repository owns its nested `dagre_rust` path dependency, so Warp should consume the renderer as an external Cargo dependency rather than copying either crate into this repo (`https://github.com/warpdotdev/mermaid-to-svg`, `mermaid-to-svg/src/lib.rs (1-159)`, `mermaid-to-svg/src/theme.rs (1-35)`, `mermaid-to-svg/Cargo.toml:1`).
## Proposed changes
* Depend on the standalone `mermaid_to_svg` repository from Cargo.toml using a pinned git revision, and treat that external repo as the single source of truth for both `mermaid_to_svg` and its nested `dagre_rust` fork. Do not copy either crate into the Warp workspace. This keeps Warp reproducible while avoiding duplicated code and letting renderer fixes land upstream first (`Cargo.toml (1-200)`, `app/Cargo.toml (26-225)`, `https://github.com/warpdotdev/mermaid-to-svg`).
* Add Mermaid recognition at the shared markdown/code-block classification layer so notebook code can identify Mermaid fences without scattering raw string checks. The parser already preserves the info string, so this should be a targeted extension around fenced-block classification rather than a full parser rewrite (`markdown_parser/src/markdown_parser.rs (145-160)`, `editor/src/content/text.rs (534-666)`).
* Keep notebook markdown storage/export unchanged and add Mermaid rendering as a notebook render path, not a markdown-to-image rewrite. The render path should derive an async SVG asset from Mermaid source and reuse the existing asset/image cache so diagram generation happens off the UI thread. For this iteration we intentionally hard-code Mermaid light theme output rather than threading terminal/theme-aware variants through asset invalidation.
* Scope the rendering hook to notebook editors by extending notebook render state/configuration, so other markdown consumers are unaffected until explicitly opted in. Theme-aware Mermaid invalidation is deferred for now; notebook appearance changes should continue to trigger normal rich-text relayout, but the Mermaid asset key remains light-theme-specific in this implementation. The editor layout pipeline should branch Mermaid code blocks into a dedicated layout task when diagram rendering is enabled, rather than carrying Mermaid-only state on generic text layout tasks (`editor/src/render/model/mod.rs (231-320)`, `editor/src/render/model/mod.rs (1998-2197)`, `app/src/notebooks/editor/view.rs (1261-1270)`).
* Implement a Mermaid-specific notebook block rendering path that preserves code-block source/offsets while showing the rendered diagram in notebook UI. This can build on the existing notebook code-block model pattern rather than reusing the non-editable plain image block directly (`app/src/notebooks/editor/model.rs (1462-1760)`, `app/src/notebooks/editor/notebook_command.rs (1-260)`, `editor/src/render/element/runnable_command.rs (1-110)`).
* Add a new feature flag following Warp conventions: Cargo feature, `FeatureFlag` enum entry, app registration, rollout list decision, and notebook-side `is_enabled()` guards. The gate should cover both Mermaid parsing/classification and notebook rendering so the feature can be fully disabled. If the flag is off, we should just render the raw Mermaid diagram text; if it's on, render the diagram in the notebook view.
* Keep clipboard support scoped to normal copy behavior for selected Mermaid blocks. In this implementation, copy preserves plain text and may append HTML for Mermaid rendering, but does not place image bytes on the clipboard. Dedicated image-byte clipboard support or a “Copy image” affordance is deferred to a follow-up if we decide the extra cross-platform and async complexity is worthwhile.
* Add focused tests for Mermaid block recognition, notebook markdown round-tripping, feature-flag gating, and async SVG rendering/cache behavior. The most relevant existing suites are `markdown_parser/src/markdown_parser_test.rs`, `editor/src/content/markdown_tests.rs`, and `app/src/notebooks/editor/model_tests.rs`.
## Parallelization
* After the render shape is agreed, Cargo integration of the external `mermaid_to_svg` dependency and feature-flag plumbing can proceed in parallel with notebook render-path work.
* The markdown/code-block classification change should land before final notebook wiring if it introduces new normalized Mermaid handling; otherwise the notebook render work can temporarily key off the preserved fenced language string and converge afterward.
* Validation should happen after both workstreams merge: parser/editor tests, notebook editor tests, and a full build check in this repo.
+92
View File
@@ -0,0 +1,92 @@
# Finalize TOML Schema for Tab Configs
Linear: [APP-3575](https://linear.app/warpdotdev/issue/APP-3575/finalize-toml-schema-for-tab-configs)
## Summary
Replace the recursive `[layout]` / `[[layout.panes]]` TOML nesting in tab configs with a flat `[[panes]]` array where nodes reference children by string ID. This makes deeply nested split layouts readable and hand-editable, and ships a bundled Oz skill so users can generate tab configs from natural language.
## Problem
The existing tab config layout format uses recursive TOML tables (`[[layout.panes]]`, `[[layout.panes.panes]]`, etc.). At depth 3+ this becomes unreadable and error-prone for hand-editing. Users who want a 2x2 grid or deeper nesting have to mentally track nested array-of-tables syntax, which is a significant barrier to adoption.
Additionally, there is no guided way for users to create tab configs — they must hand-author TOML from scratch.
## Goals
- A flat, ID-referenced pane tree format that supports arbitrarily deep nesting while remaining readable.
- A bundled Oz skill (`tab-configs`) that generates valid tab config TOML from natural language descriptions.
- A default template that is ready to use out of the box (single terminal pane, uncommitted, with `commands = []` pre-populated).
- Tab color support (`color` field) matching launch config parity.
## Non-goals
- Supporting non-terminal pane types beyond agent and cloud (notebook, code, settings, etc.).
- Multi-tab or multi-window configs (tab configs define a single tab by design).
- Proportional/flex sizing of children within a split.
- Converting existing launch configs to the new tab config format.
## Figma
Figma: none provided. This feature has no new UI — it changes the file format and adds an Oz skill.
## User experience
### Creating a new tab config
1. User clicks `+` → "Create new tab config..." in the tab bar menu.
2. Warp writes the default template to `~/.warp/tab_configs/my_tab_config.toml` and opens it in the user's configured editor.
3. The template contains an active single-pane config with `commands = []`, and commented-out examples for two-pane split, 2x2 grid, and parameterized configs.
4. The template header mentions the Oz skill: "Ask Oz to generate a tab config for you!"
### Using the Oz skill
- User invokes `/skills → tab-configs` or asks Oz naturally (e.g. "create me a 2x2 tab config with one pane running my dev server").
- Oz generates a valid `.toml` file and writes it to `~/.warp/tab_configs/`.
- The file immediately appears in the `+` menu (the filesystem watcher picks it up).
### Opening a tab config
- User clicks `+` → "New Tab: <config name>".
- If the config has `[params]`, a modal appears for the user to fill in values.
- Warp opens a new tab with the specified pane layout, running any configured commands.
- If the config has a `color` field, the tab gets that color.
### Format behavior
- Pane layout is defined with a flat `[[panes]]` array.
- The first `[[panes]]` entry is the root of the tree.
- Split nodes have `split` (horizontal/vertical) and `children` (ordered array of child IDs).
- Leaf nodes have `id`, required `type` (`"terminal"`, `"agent"`, or `"cloud"`), optional `cwd`, optional `commands`, and optional `is_focused`.
- `terminal` opens a standard shell session. `agent` opens a terminal in Agent Mode. `cloud` opens a cloud mode (ambient agent) pane with no local shell.
- `cwd` and `commands` apply to `terminal` and `agent` types; they are ignored for `cloud`.
- `worktree_name_autogenerated` (optional, bool, default false): when `true`, Warp auto-generates the worktree branch name instead of prompting the user. The app detects worktree configs by scanning `commands` for `git worktree`.
- All children within a split are equally sized.
- If no pane has `is_focused = true`, the first leaf pane gets focus automatically.
### Error handling
- If the flat pane tree has validation errors (missing child references, duplicate IDs, fewer than 2 children in a split), the config falls back to a single empty terminal pane and logs a warning.
- Invalid TOML or missing `name` field causes the file to be skipped with a warning logged.
## Success criteria
1. A tab config using the flat `[[panes]]` format with a horizontal 2-pane split opens correctly: two side-by-side terminal panes, each with the specified `cwd` and `commands`.
2. A tab config using the flat format with a 2x2 grid (horizontal split → two vertical splits → four terminals) opens correctly with four equal-sized panes.
3. A tab config with `color = "blue"` opens with a blue tab.
4. A tab config with `is_focused = true` on a specific pane gives that pane initial focus.
5. A tab config with no `is_focused` gives focus to the first (leftmost/topmost) leaf pane.
6. A tab config with `[params]` shows the param-fill modal before opening.
7. A tab config with a `[[panes]]` format that has an invalid child reference logs a warning and opens a single empty terminal pane.
8. The `tab-configs` bundled skill appears in `/skills` in Warp.
9. The default template, when uncommitted and left as-is, parses as a valid single-pane tab config named "My Tab Config".
## Validation
- Unit tests for parsing and rendering the flat format (single pane, split, 2x2, focus handling, error cases).
- Manual verification: create a 2x2 tab config TOML, open it from the `+` menu, confirm four panes appear with correct `cwd` and `commands`.
- Manual verification: invoke the `tab-configs` skill and confirm it generates a valid TOML file that opens correctly.
## Open questions
(None outstanding.)
+118
View File
@@ -0,0 +1,118 @@
# Finalize TOML Schema for Tab Configs — Tech Spec
Linear: [APP-3575](https://linear.app/warpdotdev/issue/APP-3575/finalize-toml-schema-for-tab-configs)
## Problem
The recursive `[layout]` / `[[layout.panes]]` TOML format becomes unreadable at depth 3+. We need to replace it with a flat `[[panes]]` array where nodes reference children by string ID, add tab color support, and ship a bundled Oz skill for generating configs.
## Relevant code
- `app/src/tab_configs/tab_config.rs``TabConfig`, `TabConfigPaneNode`, `render_tab_config`, `resolve_pane_tree`
- `app/src/tab_configs/tab_config_tests.rs` — existing tests for parsing and rendering
- `app/src/tab_configs/mod.rs` — public exports
- `app/src/workspace/view.rs:4702-4780``open_tab_config_with_params`, `open_tab_config`, `create_and_open_new_tab_config`
- `app/src/launch_configs/launch_config.rs``PaneTemplateType`, `SplitDirection`, `CommandTemplate` (the output types tab configs produce)
- `app/src/pane_group/mod.rs:1181-1333``pane_tree_from_template` (consumes `PaneTemplateType` to create panes)
- `app/src/user_config/native.rs:228-232``load_tab_configs`
- `app/src/user_config/util.rs:167-180``parse_tab_config_dir_entry` (TOML parsing)
- `app/resources/tab_configs/new_tab_config_template.toml` — default template
- `resources/bundled/skills/tab-configs/SKILL.md` — bundled Oz skill
- `app/src/ai/skills/skill_manager.rs` — bundled skill loading from `resources/bundled/skills/`
## Current state
Tab configs are TOML files in `~/.warp/tab_configs/`. They previously used a recursive `TabConfigLayout` struct with nested `panes: Vec<TabConfigLayout>` for child splits, which became impractical at depth 3+.
There is no `color` field on `TabConfig` — launch configs support color via `TabTemplate.color` but tab configs don't.
There is no bundled skill for generating tab configs.
## Proposed changes
### 1. New struct: `TabConfigPaneNode`
A flat node in the `[[panes]]` array. Distinguished as split vs leaf by the presence of `split` + `children`. No `type` field — all leaves are terminal panes (a `type` discriminator will be added when non-terminal pane types are supported).
Fields: `id`, `pane_type` (serde-renamed to `type`), `split`, `children`, `is_focused`, `cwd`, `commands`, `worktree_name_autogenerated`.
`worktree_name_autogenerated` (bool, default false): when `true`, the param-fill UI auto-generates the worktree branch name instead of showing a free-text input. The app infers "is this a worktree config?" by scanning `commands` for `git worktree` — no separate `worktree` flag is needed.
### 1b. New enum: `TabConfigPaneType`
Terminal / Agent / Cloud — maps to `PaneMode` at render time.
### 1c. New enum: `PaneMode` (in `launch_config.rs`)
Terminal (default) / Agent / Cloud — added as a field on `PaneTemplateType::PaneTemplate` with `#[serde(default)]` so launch config deserialization is unaffected.
### 2. Updated `TabConfig`
Add two fields:
- `color: Option<AnsiColorIdentifier>` — tab color, applied after tab creation (matching the launch config pattern in `open_launch_config_window`).
- `panes: Vec<TabConfigPaneNode>` — flat pane list. The first entry is the root of the pane tree.
Remove the old `layout: TabConfigLayout` field and the `TabConfigLayout` struct entirely. The legacy `[layout]` format is no longer supported.
### 3. `resolve_pane_tree` function
Converts the flat `panes` list into a `PaneTemplateType` tree:
1. Index all nodes by ID. Reject duplicate IDs.
2. Root = first entry.
3. Recursively resolve: split nodes look up children by ID; leaf nodes produce `PaneTemplate { cwd, commands, is_focused }`.
4. Focus handling: if any pane has explicit `is_focused = true`, use it. Otherwise auto-focus the first leaf (matching legacy `render_layout` behavior).
5. On error (missing refs, <2 children), return `Err` and the caller falls back to a single empty terminal.
### 4. Updated `render_tab_config`
Always calls `resolve_pane_tree`. On error (empty panes, missing refs, missing `type` on a leaf, etc.), falls back to a single empty terminal pane with a logged warning. Leaf resolution maps `TabConfigPaneType` to `PaneMode` and sets it on the produced `PaneTemplate`.
### 5. Updated `open_tab_config_with_params` in workspace view
After `add_tab_with_pane_layout`, apply `tab_config.color` to the new tab's `selected_color` — the same pattern launch configs use at `workspace/view.rs:2386`.
### 6. Updated template
`app/resources/tab_configs/new_tab_config_template.toml` uses the new flat format. Active (uncommented) content is a single-pane terminal with `commands = []`. Commented examples show two-pane split, 2x2 grid, and parameterized configs.
### 7. Bundled Oz skill
`resources/bundled/skills/tab-configs/SKILL.md` contains the full schema reference, examples, validation rules, and common natural-language-to-layout mappings. The build script (`script/prepare_bundled_resources`) copies it into the app bundle automatically.
## End-to-end flow
1. User creates/edits a `.toml` file in `~/.warp/tab_configs/`.
2. Filesystem watcher in `WarpConfig` detects the change and calls `load_tab_configs`.
3. `parse_tab_config_dir_entry` calls `toml::from_str::<TabConfig>()`. The `panes` field deserializes from `[[panes]]` entries.
4. User selects the config from the `+` menu → `open_tab_config` is called.
5. If params exist, the param-fill modal opens. Otherwise `open_tab_config_with_params` is called directly.
6. `render_tab_config` builds param contexts, calls `resolve_pane_tree`, returns `(Option<String>, PaneTemplateType)`.
7. Workspace calls `add_tab_with_pane_layout(PanesLayout::Template(pane_template), ...)`.
8. `PaneGroup::pane_tree_from_template` recursively creates panes from the `PaneTemplateType` tree. For `PaneMode::Agent`, it creates a terminal session then enters agent mode. For `PaneMode::Cloud`, it creates an ambient agent pane via `create_ambient_agent_terminal`.
9. If `tab_config.color` is set, the tab's `selected_color` is updated.
## Risks and mitigations
- **Flat pane list validation**: Invalid configs (missing refs, cycles) are caught at render time with descriptive error messages logged and a fallback to a single terminal pane. The user's tab config file is never modified.
- **Skill generating invalid TOML**: The skill embeds validation rules and examples. The schema is simple enough (terminal-only, no type field) that generation errors are unlikely.
## Testing and validation
- Unit tests in `tab_config_tests.rs`:
- Parse single flat pane.
- Parse and render flat two-pane split.
- Parse and render flat 2x2 grid.
- Explicit `is_focused` honored.
- Auto-focus first leaf when no explicit focus.
- Invalid flat pane tree (missing child ref) returns error.
- Duplicate IDs rejected.
- Tab color deserialized correctly.
- Manual: build and run Warp, create tab configs, verify pane layouts open correctly.
- Manual: invoke the `tab-configs` skill, confirm it generates a working config.
## Follow-ups
- Add non-terminal pane types (notebook, code, settings, etc.) with a `type` field discriminator.
- Consider cycle detection in validation (currently not implemented — the recursive resolver will stack overflow on cycles, but configs are small enough that this is not a practical concern).
- Consider orphan pane warnings (panes not referenced by any `children` and not the root).
- Investigate whether the Oz skill should auto-open the created config in the user's editor.
+90
View File
@@ -0,0 +1,90 @@
# APP-3578: Vertical Tabs — New Session Dropdown Menu
## Summary
Redesign the vertical tabs "new tab" button and its dropdown menu to match the Figma mock. The horizontal tab bar menu is unchanged.
## Problem
The vertical tabs panel currently uses the same new-session dropdown as the horizontal tab bar, including separators, item ordering, and a split `[+][v]` button. The Figma design calls for a simpler, flatter menu specific to vertical tabs.
## Goals
- Replace the split `[+][v]` button with a single `+` button that opens the dropdown on click.
- Show a vertical-tabs-specific menu with no separators between groups, a different item ordering, icons per item, and a "New worktree" footer item replacing "Create new tab config...".
- Keep the horizontal tab bar menu unchanged.
## Non-goals
- Search bar in the dropdown (follow-up).
- Pixel-perfect match of the Figma menu item padding/spacing (the `Menu` component has hardcoded constants that can't be overridden per-instance).
- Changing the "New worktree" action (it uses the same behavior as "Create new tab config...").
## Figma
- Dropdown menu: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7157-41707&m=dev
- Plus button: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7150-39859&m=dev
## User Experience
### Plus button
- Single `+` icon button in the vertical tabs control bar, to the right of the search input.
- Clicking the button opens the new-session dropdown menu anchored below the button.
- Tooltip on hover: "New Tab" with keybinding sublabel.
### Dropdown menu items (in order)
1. "Default" — creates a new tab (same as current `AddTab` / welcome tab behavior). Shows ⌘T keybinding. Icon: `LayoutAlt01`.
2. "Agent" — creates an agent tab (behind AI feature flag). Icon: `LayoutAlt01`.
3. "Terminal" — creates a terminal tab. Icon: `LayoutAlt01`.
4. "Cloud agent tab" — creates a cloud agent tab (behind `AgentView` + `CloudMode` flags). Icon: `LayoutAlt01`.
5. Available shells (behind `ShellSelector` flag) — one item per shell, no separator above. Icon: `LayoutAlt01`.
6. Launch configs — rendered as "Launch {name}" items. Icon: `LayoutAlt01`.
7. Tab configs — one item per loaded tab config, no separator above. Icon: `LayoutAlt01` for non-worktree configs, `Dataflow02` for worktree configs.
8. Separator line.
9. "New worktree" — opens the tab config template file (same as current "Create new tab config..."). Icon: `Plus`.
### Icons
- All non-worktree items use the `LayoutAlt01` icon (sidebar-left layout shape).
- Worktree tab configs use the `Dataflow02` icon (merging-branches shape from Figma `dataflow-02`). A tab config is detected as a worktree if any pane has `worktree_name_autogenerated = true` or commands containing `"git worktree"`.
- "New worktree" uses the `Plus` icon.
### Items removed from the vertical tabs menu
- "Restore Closed Tab" (will exist elsewhere).
- "Learn about Launch Configs..." link.
- All `MenuItem::Separator` dividers between groups (only one separator remains, before "New worktree").
### Items unchanged
- Launch configs still appear (rendered as "Launch {name}" items between shells and tab configs).
## Edge Cases
1. **No AI enabled**: Agent and Cloud agent tab items are hidden (same gating as today).
2. **No tab configs**: Only the built-in items + shells + "New worktree" appear.
3. **Menu lifecycle**: Same as existing — closes on item selection, click outside, or Escape.
4. **Menu width**: Set to 268px for the vertical tabs dropdown (matching the Figma `OptionMenuItem` width). Resets to default 186px for horizontal tabs.
## Success Criteria
- Vertical tabs panel shows a single `+` button (no chevron).
- Clicking `+` opens a flat dropdown anchored below the button with items in the Figma order.
- No separator lines between item groups; one separator before "New worktree".
- Each item has an icon (`LayoutAlt01`, `Dataflow02`, or `Plus`).
- "New worktree" item opens the tab config template file.
- Horizontal tab bar menu is completely unchanged.
## Validation
- Visual inspection of vertical tabs dropdown matches Figma mock.
- Click each menu item and confirm the correct action fires.
- Confirm horizontal tab bar dropdown is unaffected.
- Test with `TabConfigs` and `ShellSelector` feature flags on and off.
## Known Limitations
The `Menu` component has hardcoded layout constants that cannot be overridden per-instance:
- Item padding: 5px vertical / 14px horizontal (Figma specifies 8px / 16px).
- No gap between items (Figma uses 2px).
- Corner radius: 5px (Figma uses 4px).
- Menu body padding: 9px top/bottom (Figma has none).
Matching these exactly requires either adding per-instance padding/radius overrides to `Menu`, or building a separate component.
+69
View File
@@ -0,0 +1,69 @@
# APP-3578: Tech Spec — Vertical Tabs Dropdown Menu
## Problem
The vertical tabs panel reuses the horizontal tab bar's new-session menu. The Figma mock requires a distinct menu with different items, ordering, icons, and a single `+` trigger button.
## Relevant Code
- `app/src/workspace/view/vertical_tabs.rs``render_new_tab_button` (single `+` button, formerly `render_new_tab_split_button`)
- `app/src/workspace/view/vertical_tabs.rs``VerticalTabsPanelState` (mouse states), `VERTICAL_TABS_ADD_TAB_POSITION_ID` (menu anchor)
- `app/src/workspace/view.rs``new_session_menu_items()` (horizontal tab bar item builder)
- `app/src/workspace/view.rs``vertical_tabs_new_session_menu_items()` (vertical tabs item builder)
- `app/src/workspace/view.rs``toggle_new_session_dropdown_menu(position, is_vertical_tabs, ctx)` (opens the menu)
- `app/src/workspace/action.rs``WorkspaceAction::ToggleNewSessionMenu { position, is_vertical_tabs }`
## Changes Made
### 1. `is_vertical_tabs` field on `ToggleNewSessionMenu`
`WorkspaceAction::ToggleNewSessionMenu` now carries `is_vertical_tabs: bool`. Horizontal tab bar dispatch sites pass `false`; the vertical tabs `+` button passes `true`.
### 2. `vertical_tabs_new_session_menu_items()` method
New method on `Workspace` in `view.rs`. Builds items in Figma order with icons:
1. "Default" — `AddTab`, `LayoutAlt01` icon, ⌘T keybinding
2. "Agent" — `AddAgentTab`, `LayoutAlt01` icon (if AI enabled)
3. "Terminal" — `AddTerminalTab`, `LayoutAlt01` icon
4. "Cloud agent tab" — `AddAmbientAgentTab`, `LayoutAlt01` icon (if flags enabled)
5. Shells — `AddTabWithShell`, `LayoutAlt01` icon (if `ShellSelector` enabled)
6. Launch configs — `LayoutAlt01` icon, skip "Learn about Launch Configs..."
7. Tab configs — `LayoutAlt01` for non-worktree, `Dataflow02` for worktree configs
8. `MenuItem::Separator`
9. "New worktree" — `CreateNewTabConfig`, `Plus` icon
Worktree detection: a tab config is a worktree if any pane has `worktree_name_autogenerated` or commands containing `"git worktree"`.
### 3. `toggle_new_session_dropdown_menu` selects item builder and width
Accepts `is_vertical_tabs: bool`. When true: calls `vertical_tabs_new_session_menu_items()` and sets menu width to 268px. When false: calls `new_session_menu_items()` and resets width to `MENU_DEFAULT_WIDTH` (186px).
### 4. Single `+` button replaces split button
`render_new_tab_split_button` replaced by `render_new_tab_button` — a single `+` icon button. On click dispatches `ToggleNewSessionMenu { position, is_vertical_tabs: true }`. Removed `new_tab_menu_state` from `VerticalTabsPanelState`.
### 5. Menu positioning
For vertical tabs, the dropdown is anchored to the `+` button via `offset_from_save_position_element(VERTICAL_TABS_ADD_TAB_POSITION_ID, ...)` with `BottomLeft``TopLeft` anchoring (4px gap). For horizontal tabs, the existing `offset_from_parent` positioning is unchanged.
### 6. New SVG assets and Icon variants
Added `layout-alt-01.svg`, `layout-alt-02.svg`, `layout-alt-03.svg`, `layout-alt-04.svg`, `layout-bottom.svg` to `app/assets/bundled/svg/`. Added corresponding `LayoutAlt01` through `LayoutBottom` variants to `warp_core::ui::Icon`. Currently only `LayoutAlt01` is used; the others are available for future icon differentiation.
## End-to-End Flow
1. User clicks `+` in vertical tabs panel.
2. `ToggleNewSessionMenu { position, is_vertical_tabs: true }` dispatched.
3. Handler calls `toggle_new_session_dropdown_menu(position, true, ctx)`.
4. Menu width set to 268px, items built via `vertical_tabs_new_session_menu_items()`.
5. Menu rendered as overlay anchored below the `+` button via `VERTICAL_TABS_ADD_TAB_POSITION_ID`.
6. User selects an item → action dispatched (`AddTab`, `AddAgentTab`, `SelectTabConfig`, etc.).
## Files Changed
- `app/src/workspace/action.rs` — added `is_vertical_tabs` field
- `app/src/workspace/view.rs` — new `vertical_tabs_new_session_menu_items()`, updated `toggle_new_session_dropdown_menu`, updated menu positioning in `render()`, updated action handler
- `app/src/workspace/view/vertical_tabs.rs` — replaced split button with single `+` button, simplified state, made position ID `pub(super)`
- `warp_core/src/ui/icons.rs` — added `LayoutAlt01`, `LayoutAlt02`, `LayoutAlt03`, `LayoutAlt04`, `LayoutBottom` variants
- `app/assets/bundled/svg/` — added 5 new layout SVG icons
- `specs/APP-3578/` — PRODUCT.md and TECH.md
+117
View File
@@ -0,0 +1,117 @@
# Product Spec: Plugin Installation Fallback Modal
## Problem
When the Warp notification plugin can't be auto-installed (SSH session, or a previous install attempt failed), the user currently has no way to learn how to install it manually. We need a modal that shows step-by-step manual installation instructions.
## Current Behavior
- A green "Install Warp plugin" chip appears in the CLI agent footer when the plugin isn't installed (`agent_input_footer/mod.rs:611-633`)
- Clicking it runs auto-install via `claude plugin` CLI commands (`plugin_manager/claude.rs:29-37`)
- On failure: an error toast appears with a link to logs
- On SSH: the chip visibility has a bug (see below)
## Chip Visibility Fix (Remote Sessions)
`should_show_install_plugin_button` hides the chip when `manager.is_installed()` returns true. But `is_installed()` reads the **local** filesystem (`~/.claude/plugins/installed_plugins.json`), not the remote machine's. In any remote session (warpified SSH, legacy SSH, Docker via SSH) where Claude Code runs on the remote, this check is wrong:
- Plugin installed locally but not on remote → chip hidden, user stuck with no instructions
Fix: the `CLIAgentSession` tracks an `is_remote` flag (set at session creation from `active_session_is_local()`). When `is_remote` is true, skip the `is_installed()` check and rely solely on whether the session has an active listener. If no listener → show the chip.
## Two Chip Modes
The chip has two modes depending on context:
### Mode 1: Auto-Install (current behavior)
**When:** local session, no prior install failure for this session.
- Chip label: "Install Warp plugin"
- Chip tooltip: "Install the Warp plugin to enable rich agent notifications within Warp"
- On click: runs auto-install (existing `handle_install_plugin` flow)
- On success: chip disappears (listener registers)
- On failure: transitions to Mode 2 for the rest of the session
### Mode 2: Manual Instructions Modal
**When:** SSH session, OR auto-install previously failed in this session.
- Chip label: "Plugin install instructions"
- Chip tooltip: "View instructions to install the Warp plugin"
- Chip icon: `Icon::Info` (instead of `Icon::Download`)
- On click: opens a modal with manual installation steps
## Modal Design
### Layout
Custom modal view following the `CodexModal` pattern (centered overlay, semi-transparent backdrop, Escape to close, click-outside to dismiss via `Dismiss` element).
- Title from `PluginInstallInstructions.title` (e.g. "Install Warp Plugin for Claude Code")
- Subtitle from `PluginInstallInstructions.subtitle`
- Numbered steps, each with:
- A short description of what the step does
- A monospace code block rendered via `render_code_block_plain` with a copy button
- Close button (X) in the top-right corner
- Copying a command shows a "Copied to clipboard" ephemeral toast
### Content for Claude Code
These are in-session slash commands (the user is already running Claude Code).
Step 1: "Add the Warp plugin marketplace repository"
```
/plugins marketplace add warpdotdev/claude-code-warp
```
Step 2: "Install the Warp plugin"
```
/plugins install warp@claude-code-warp
```
Step 3: "Reload plugins to activate"
```
/reload-plugins
```
Subtitle: "Ensure that jq is installed on your machine. Then, run these commands inside your Claude Code session."
Auto-install success toast: "Warp plugin installed. Please run /reload-plugins to activate."
### Extensibility
Each agent provides its own modal view. Common rendering helpers (backdrop, title bar, step layout, code blocks) are shared. Adding a new agent's modal means:
- Implementing a new view that uses the shared helpers
- Returning the appropriate view from a factory function keyed on `CLIAgent`
## State Tracking
### `plugin_install_failed` on `AgentInputFooter`
A per-session boolean (scoped to the `AgentInputFooter` instance) that tracks whether auto-install has failed. Set to `true` in the `handle_install_plugin` error callback. This determines whether the chip is in Mode 1 or Mode 2 for local sessions.
Reset to `false` if the plugin activates (listener connects — which already hides the chip entirely).
### No persistence across sessions
Failure state is not persisted. A new terminal session starts fresh in Mode 1 (auto-install).
## Behavior Summary
- Plugin active (listener present) → chip hidden
- Local, plugin installed on disk → chip hidden
- Local, plugin not installed, no prior failure → chip shown, Mode 1 (auto-install)
- Local, plugin not installed, prior failure → chip shown, Mode 2 (modal)
- Remote (any SSH), no listener → chip shown, Mode 2 (modal)
- Remote (any SSH), listener present → chip hidden
- Agent has no plugin support → chip hidden
- Install in progress → chip hidden
## Edge Cases
- **User installs plugin manually mid-session (without using the chip):** The listener will connect on next `SessionStart` event, chip disappears automatically.
- **User clicks chip in Mode 2 then installs manually:** Modal stays open until dismissed. Chip disappears on next render once listener is present.
- **Multiple terminal tabs with same agent:** Each tab has its own `AgentInputFooter` with independent failure tracking. This is correct — one tab's failure shouldn't affect another.
- **Warpified SSH (tmux wrapper):** Even though the local filesystem is accessible via tmux, the agent runs on the remote machine. The `is_remote` flag is set for all SSH sessions (warpified or legacy), so Mode 2 applies to all remote sessions.
+117
View File
@@ -0,0 +1,117 @@
# Tech Spec: Plugin Installation Fallback Modal
See `specs/APP-3619/PRODUCT.md` for the full product spec.
## 1. Install Instructions Data Model
`plugin_manager/mod.rs`
Add `PluginInstallStep` and `PluginInstallInstructions` structs. Replace the existing `post_install_hint()` trait method with `install_instructions() -> &'static PluginInstallInstructions` that returns all modal content for this agent.
```rust
pub(crate) struct PluginInstallStep {
pub description: &'static str,
pub command: &'static str,
}
pub(crate) struct PluginInstallInstructions {
pub title: &'static str,
pub subtitle: &'static str,
pub steps: &'static [PluginInstallStep],
pub success_toast: &'static str,
}
```
Each implementation uses `LazyLock` so the data is built once. Claude Code's steps are the in-session slash commands (`/plugins marketplace add ...`, `/plugins install ...`, `/reload-plugins`) since the user is already in a running session.
The existing `post_install_hint` is removed. The auto-install success toast reads from `install_instructions().success_toast`.
## 2. Modal View
New file: `workspace/view/plugin_install_modal.rs`, following the `CodexModal` pattern (standalone view, centered overlay, backdrop, Escape to close).
### View struct
```rust
pub struct PluginInstallModal {
agent: Option<CLIAgent>,
close_button_mouse_state: MouseStateHandle,
step_code_handles: Vec<CodeSnippetButtonHandles>,
}
```
`agent` is `Option` so the view can be constructed once in workspace init and updated via `set_agent()` before each open. All `MouseStateHandle`s are stored in the struct (not created inline during render). Copying a command shows a "Copied to clipboard" ephemeral toast via `ToastStack`. The modal uses `Dismiss` to close on outside click.
### Code block rendering
Reuse `render_code_block_plain` (`ai/blocklist/code_block.rs:117`) with `CodeBlockOptions` — the same component already used by `cloud_setup_guide_view.rs:393` for its step-by-step CLI guide. Each step gets `on_copy` + a `CodeSnippetButtonHandles`.
### Per-agent extensibility
`render()` dispatches to per-agent render functions that compose shared helpers (modal shell, numbered step layout, code blocks). Adding a new agent means adding a render function + `PluginInstallInstructions` implementation.
### Actions & Events
```rust
enum PluginInstallModalAction { Close, CopyCommand(usize) }
enum PluginInstallModalEvent { Close }
```
## 3. Workspace Wiring
Follows the standard modal pattern (like `CodexModal` at `workspace/view.rs:817,1573,12878,18110`):
- `is_plugin_install_modal_open` on `WorkspaceState` (add to `is_any_non_palette_modal_open` and `close_all_modals` in `workspace/util.rs`)
- `plugin_install_modal: ViewHandle<PluginInstallModal>` on `Workspace`
- `open_plugin_install_modal(agent)` sets the agent, flips the bool, focuses the modal
- Rendered conditionally in the workspace render method's modal stack
## 4. Event Plumbing: Footer → Workspace
New `ShowPluginInstallModal(CLIAgent)` variant propagated through the standard event chain:
`AgentInputFooterEvent` (`mod.rs:1325`) → `Input::Event` (`input.rs:1039`) → `TerminalView::Event``pane_group::Event` → Workspace handler
Follows the existing `OpenAutoReloadModal` pattern (`view.rs:18411`, `workspace/view.rs:10182`).
## 5. Remote Session Detection
`CLIAgentSession` has an `is_remote: bool` field set at session creation from `TerminalView::active_session_is_local()`. This uses `SessionType::WarpifiedRemote` and `IsLegacySSHSession` — the same logic as the SSH host chip (`context_chips/builtins.rs:76`).
This avoids relying on `terminal_model.is_ssh_block()` (which only tracks the pre-warpification login phase) or `is_warpified_ssh()` (which misses legacy SSH). The `is_remote` flag is threaded through `set_session` and `register_listener` at all call sites in `terminal/view.rs`.
## 6. Two-Mode Chip
`agent_input_footer/mod.rs` — the install chip has two modes based on whether auto-install is viable.
### Mode selection
`should_use_manual_install_mode(&self, app)` returns `true` when:
- `plugin_install_failed` is set (auto-install already failed this session), OR
- `session.is_remote` is true (checked via `CLIAgentSessionsModel`)
### Remote visibility fix
`should_show_install_plugin_button` checks `session.is_remote` — when true, skips the local `is_installed()` filesystem check and relies solely on listener presence.
### Two buttons
`ActionButton` doesn't support changing label/icon after construction, so we create two `ActionButton` views and conditionally render the right one:
- **Auto-install** (existing `install_plugin_button`): "Install Warp plugin" / `Icon::Download` → triggers `handle_install_plugin`
- **Manual** (new `plugin_instructions_button`): "Plugin install instructions" / `Icon::Info` → emits `ShowPluginInstallModal`
In `render_cli_mode_footer`, branch on `should_use_manual_install_mode()` to pick which button to render.
On install failure (`handle_install_plugin` error callback), set `plugin_install_failed = true` to switch to manual mode for the rest of the session.
## 7. Files Changed
- **New:** `workspace/view/plugin_install_modal.rs`
- **Modified:** `plugin_manager/mod.rs``PluginInstallStep`, `PluginInstallInstructions`, new trait method
- **Modified:** `plugin_manager/claude.rs``LazyLock` implementation
- **Modified:** `cli_agent_sessions/mod.rs``is_remote` field on `CLIAgentSession`, threaded through `register_listener`
- **Modified:** `agent_input_footer/mod.rs` — two-mode chip, failure tracking, remote detection via `session.is_remote`, new action/event variants
- **Modified:** `terminal/input.rs`, `terminal/view.rs` — event forwarding, `is_remote` computation at session creation
- **Modified:** `workspace/util.rs`, `workspace/view.rs`, `workspace/mod.rs` — modal integration
+17
View File
@@ -0,0 +1,17 @@
# Artifact Notifications — Product Spec
## Problem
When an agent creates artifacts (plans, PRs, screenshots) during a conversation, the user has no way to know this happened without navigating into the conversation. The existing notifications only fire on status changes (completed, blocked, error).
## Desired Behavior
When a conversation reaches a terminal state (success, cancelled, error) and artifacts were added during the most recent turn, the completion notification (both toast and mailbox) should include an artifact row beneath the standard notification content. The artifact row uses the same interactive chip style as the management view (plan name, branch name, PR link, screenshot count).
## Scope
- **Trigger:** The existing completion notification (`UpdatedConversationStatus` reaching a terminal state). No separate artifact notification.
- **Which artifacts:** All artifacts added since the last terminal-state notification (i.e. accumulated across all turns of the current response).
- **Interactivity:** The artifact chips are interactive (same buttons used in the management view). Clicking the notification itself navigates to the conversation.
- **Surfaces:** Both the toast popup and the notification mailbox.
- **Feature flag:** Gated behind `hoa_notifications` (the existing flag for the notification system).
+48
View File
@@ -0,0 +1,48 @@
# Artifact Row in Completion Notifications — Tech Spec
Product spec: `specs/APP-3630/PRODUCT.md`
## Current State
**Notification data:** `NotificationItem` (notifications/item.rs:62) has `title`, `message`, `category`, `agent`, `origin`, `is_read`, `created_at`, `terminal_view_id`. No artifact data.
**Notification creation:** `AgentNotificationsModel` (agent_management_model.rs) listens for `UpdatedConversationStatus` and creates notifications in `handle_history_event_for_mailbox`. It does not listen for `UpdatedConversationArtifacts`.
**Artifact event:** `UpdatedConversationArtifacts` (history_model.rs:2003) is emitted by `AIConversation::add_artifact` and `update_plan_notebook_uid`. It carries `terminal_view_id` and `conversation_id` but not the artifact itself.
**Rendering:** `render_notification_item_content` (item_rendering.rs:21) renders avatar + title + message. Takes `&NotificationItem` + `&Appearance`, no view context.
**Artifact buttons pattern:** The management view (agent_management/view.rs:1106-1113) creates `ViewHandle<ArtifactButtonsRow>`, subscribes to events, and stores the handle in `CardState`.
## Changes
### 1. Carry the artifact in `UpdatedConversationArtifacts`
Add `artifact: Artifact` field to the `UpdatedConversationArtifacts` event (history_model.rs:2003). Clone the artifact at both emit sites:
- `add_artifact` (conversation.rs:1010): clone before pushing.
- `update_plan_notebook_uid` (conversation.rs:1025): clone after mutating.
### 2. Accumulate artifacts in `AgentNotificationsModel`
Add `pending_artifacts: HashMap<AIConversationId, Vec<Artifact>>` to `AgentNotificationsModel`.
- On `UpdatedConversationArtifacts`: append the artifact to `pending_artifacts[conversation_id]`.
- On `InProgress`: do **not** clear `pending_artifacts` — artifacts accumulate across turns.
- On terminal state (Success/Cancelled/Error): drain `pending_artifacts[conversation_id]` and pass to `add_notification`.
- On conversation deletion/removal: clean up `pending_artifacts`.
- CLI agent notifications: empty vec.
### 3. Add `artifacts: Vec<Artifact>` to `NotificationItem`
Thread through `NotificationItem::new` and `add_notification`.
### 4. Store `ArtifactButtonsRow` views in toast and mailbox
Both `NotificationToastItem` and `NotificationMailboxView` store an `Option<ViewHandle<ArtifactButtonsRow>>` per notification. Create on add, subscribe to `ArtifactButtonsRowEvent`, handle actions (same as agent_management/view.rs:1116-1163).
### 5. Thread artifact view into rendering
Add `Option<&ViewHandle<ArtifactButtonsRow>>` param to `render_notification_item_content`. Render `ChildView` below message text when present. Update call sites in toast and mailbox.
## Parallelism
Step 1 must go first. Steps 2-3 and steps 4-5 can be done in parallel.
+69
View File
@@ -0,0 +1,69 @@
# APP-3632: Code Review Header UI Refactor
## Problem
The updated Figma designs for the code review panel introduce git operation buttons (commit, push, create PR) in the inner header. The existing header layout doesn't have room for these — it already contains the branch name, diff stats, discard button, add-context button, and diff mode dropdown. This PR clears space by relocating contextual info upward and consolidating actions into the overflow menu.
## Feature Flag
All UI changes are gated behind `FeatureFlag::GitOperationsInCodeReview`. The flag is **not** in `DOGFOOD_FLAGS` — it is off everywhere by default. Both the inner header (`code_review_header.rs`) and the right panel header (`right_panel.rs`) maintain legacy render paths that match master when the flag is off.
To test locally: `cargo run --features git_operations_in_code_review`
## Overview of Changes
When the flag is **on**, this PR restructures the code review header into two layers:
1. **Right panel header** (top-level): shows repo context — repo path, branch name, and diff stats
2. **Inner code review header**: simplified to just the diff mode selector, file nav button, and an overflow menu
Actions that were previously standalone buttons (discard all, add diff set as context) are consolidated into the overflow menu. The file navigation toggle button is now an `ActionButton` with `PaneHeaderTheme` that appears in both wide and compact layouts.
When the flag is **off**, both headers render identically to master.
## File-by-File Changes
### `app/src/workspace/view/right_panel.rs`
**Purpose**: Redesign the panel header to show contextual git info (flag-gated).
- Call site dispatches to `render_header` (flag on) or `render_header_legacy` (flag off)
- **New layout** replaces the static "Code review" title with:
- **Repo path** — tilde-shortened (e.g. `~/Repos/warp-internal:`), rendered in semibold sub-text color
- **Branch name** — read from `DiffStateModel` via `get_diff_state_model()`
- **Diff stats** — read from `CodeReviewView::loaded_diff_stats()`
- Uses shared `CONTENT_LEFT_MARGIN` / `CONTENT_RIGHT_MARGIN` constants so the header aligns with the content area below
- **Legacy layout** preserves the "Code review" title with `PANE_HEADER_HEIGHT` and `HEADER_EDGE_PADDING`
### `app/src/code_review/code_review_header.rs`
**Purpose**: Simplify the inner header to only layout concerns, with legacy fallback.
- Master's code (`render`, `render_wide_layout`, `render_compact_layout`, and all helpers) is **untouched** — zero deletions from master's version aside from adding `FilterableDropdown` to imports.
- **New path (`render_new`)**: added at the bottom of the file. Renders diff mode dropdown (left) + git operations button + file nav button + overflow menu (right). Compact layout is a single row.
- `render_header` in `CodeReviewView` checks the flag and calls `render_new` or `render`
### `app/src/code_review/code_review_view.rs`
**Purpose**: Restyle the dropdown, relocate buttons, consolidate menu items.
- **File navigation button**: `ViewHandle<ActionButton>` with `PaneHeaderTheme`, created once in `new()`. Passed to the header via `CodeReviewHeaderFields.file_nav_button` so both wide and compact layouts can render it. Tooltip updates dynamically when sidebar state changes.
- **Diff mode dropdown**: flag-gated styling in `new()`. Flag on: `ButtonVariant::Text` with semibold larger font. Flag off: master's bordered outline/accent style.
- **`header_menu_items()`**: dispatches to `header_menu_items_new` (flag on) or `header_menu_items_legacy` (flag off, matches master). New version adds "Discard all" and `AISettings` check.
- **`loaded_diff_stats()`**: new public accessor for the right panel header
- **Shared margin constants**: `CONTENT_LEFT_MARGIN` (16px) and `CONTENT_RIGHT_MARGIN` (4px) exported as `pub(crate)`
- **`render_header`**: takes `state` and `app` params, dispatches to `render_new` or `render` based on flag
- **`render_file_navigation_button`**: public function retained from master (used by legacy right panel header)
### `app/src/pane_group/working_directories.rs`
**Purpose**: Expose diff state for the panel header to read.
- Adds `get_diff_state_model(&self, repo_path: &Path) -> Option<ModelHandle<DiffStateModel>>`
### `app/src/lib.rs`
- Wired `git_operations_in_code_review` cargo feature to `FeatureFlag::GitOperationsInCodeReview` runtime flag
### `crates/warp_features/src/lib.rs`
- `GitOperationsInCodeReview` removed from `DOGFOOD_FLAGS` (flag is off everywhere by default)
## Design Decisions
- **Dual render paths behind feature flag**: the header restructuring is a prerequisite for git operation buttons (added in child branches). Since this PR may merge before the rest of the stack, both old and new rendering coexist. The legacy code is clearly marked and will be deleted when the flag is promoted.
- **File nav button as `ActionButton`**: uses `PaneHeaderTheme` to match the three-dots and maximize buttons. Created as a `ViewHandle` in `new()` (not inline during render) so it can appear in both wide and compact layouts via `ChildView`.
- **Branch name reads from `DiffStateModel` directly** rather than being passed through `CodeReviewView`, because the panel header renders independently of whether diffs have loaded.
- **Diff stats still read from `CodeReviewView::loaded_diff_stats()`** because they depend on the loaded diff state, which only `CodeReviewView` owns.
- **Overflow menu is always rendered** (no longer gated on `FileAndDiffSetComments`). Individual items are independently gated, so the menu gracefully degrades to empty when all flags are off.
+58
View File
@@ -0,0 +1,58 @@
# CLI Agent Rich Input: @ Context Product Spec
## Summary
Add support for `@` context (files, folders, and code symbols) in the CLI agent rich input composer — the input that appears when composing a prompt to send to a running CLI agent (Claude Code, Codex, Gemini CLI, etc.). Selected items are inserted as repo-relative paths.
## Problem
When users compose prompts for CLI agents through Warp's rich input (Ctrl-G or the Compose button), they cannot reference files or code symbols. Users must manually type file paths without any discovery or autocomplete. The normal Warp agent input supports `@` context, but the CLI agent input does not.
The core constraint is that CLI agent input writes plain text to a PTY — so `@` context must resolve to a plain text path that the CLI agent can interpret.
## Goals
- Let users attach file, folder, and code symbol paths via `@` in the CLI agent rich input.
- Insert repo-relative paths as plain text.
- Hide Warp-specific `@` context categories that CLI agents cannot interpret.
## Non-goals
- Supporting `@` context types other than files/folders and code symbols (e.g., Blocks, Workflows, Diff Sets, Notebooks). These are Warp-specific concepts that CLI agents cannot interpret.
## Figma
https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7001-18001&p=f&m=dev
## User Experience
### Trigger
User types `@` anywhere in the CLI agent rich input. This works regardless of position in the buffer — including when the input starts with a mode-switch prefix like `!` (bash mode in Claude Code) or `&` (background mode). The `@` trigger is not restricted to AI-mode input.
### Limitation
`@` context is only available in local sessions. It is not supported in SSH/remote contexts because file search and code symbol indexing rely on local filesystem access.
### Menu
The AI context menu opens showing the available categories: files/folders and code symbols. These are the only categories relevant for CLI agents — all Warp-specific categories (Blocks, Workflows, Notebooks, Diff Sets, Conversations, etc.) are hidden.
### Search
The user can type after `@` to filter file results, reusing the existing file search logic. For example, typing `@foo` filters to files matching "foo" (e.g., `footer.rs`, `foo_bar.py`). This is the same search behavior already implemented for the file data source in the AI context menu.
### Selection
When the user selects a file or folder, the repo-relative path is inserted into the input buffer as plain text followed by a trailing space (e.g., `src/foo.rs `). The trailing space lets the user continue typing immediately. CLI agents typically reference files relative to the project root, so repo-relative paths are concise and consistent with how these tools handle file references. If the file is outside the project tree, fall back to an absolute path. No special chip or token rendering is needed.
When the user is outside a git repository, the menu shows files from the current working directory (matching existing Warp behavior). Paths are relative to pwd in this case. This is the existing `CurrentFolderFiles` vs `RepoFiles` distinction in the AI context menu.
### Submission
The inserted file path is part of the plain text written to the PTY. No special processing is needed at submit time.
### Edge cases
- Paths with spaces should be inserted as-is (the CLI agent will interpret them in the context of the surrounding prompt text, not as shell arguments).
- If no files are found (e.g. empty directory), the menu should show its standard empty state.
## Success Criteria
- Users can type `@` in the CLI agent rich input, see file/folder and code symbol results, select one, and have the repo-relative path inserted as text.
- Warp-specific `@` context categories do not appear in the menu.
- This feature is only available in local sessions, not SSH/remote.
## Validation
- Open a CLI agent rich input while Claude Code is running. Type `@`, verify files/folders and code symbols categories appear (no Warp-specific categories), select a file, verify the repo-relative path is inserted.
- Verify `@foo` filters files by name.
- Verify `@` works after `!` prefix in the buffer.
- Verify that Warp-specific context categories (Blocks, Workflows, etc.) do not appear.
- Verify that `@` context is not available in SSH/remote sessions.
+64
View File
@@ -0,0 +1,64 @@
# CLI Agent Rich Input: @ Context Technical Spec
## Summary
This spec covers implementing `@` context (files, folders, code symbols) in the CLI agent rich input composer. The approach reuses the existing AI context menu with mode-based category filtering, restricting it to files/folders and code symbols.
## Relevant Code
- `app/src/search/ai_context_menu/view.rs``AIContextMenu`, `get_categories_for_mode()`, `refresh_categories_state()`
- `app/src/terminal/input.rs``Input` view; handles `EditorEvent::AcceptAIContextMenuItem`, `InsertFilePath` action
- `app/src/terminal/cli_agent_sessions/mod.rs``CLIAgentSessionsModel`, `CLIAgentInputState`
- `app/src/search/ai_context_menu/files/data_source.rs``file_data_source_for_current_repo()`, `file_data_source_for_pwd()`
- `app/src/search/files/model.rs``FileSearchModel` (relies on `ActiveSession::path_if_local()` — local only)
## Current State
The AI context menu (`AIContextMenu`) already supports mode-based category filtering via `get_categories_for_mode()`. It takes flags like `is_ai_or_autodetect_mode`, `is_shared_session_viewer`, and `is_in_ambient_agent` to determine which categories to show. When only one category is available, `refresh_categories_state()` automatically skips the category picker and jumps to the search results view.
The file data sources (`file_data_source_for_current_repo`, `file_data_source_for_pwd`) use `ActiveSession::path_if_local()` and local `FileSearchModel`/`RepositoryMetadataModel` — these only work for local sessions, not SSH/remote.
The `InsertFilePath` handler in `EditorEvent::AcceptAIContextMenuItem` already handles file path insertion via `replace_at_symbol_with_text()`. In AI mode it inserts repo-relative paths; in terminal mode it computes the shorter of relative-to-cwd or absolute.
The `@` trigger in the editor fires when the user types `@` and the input is in AI mode. The CLI agent rich input calls `set_input_mode_agent`, so the `@` trigger should already work.
## Proposed Changes
### 1. Filter AI context menu categories for CLI agent input
**What changes**: `get_categories_for_mode()` gains an additional `is_cli_agent_input: bool` parameter. When true, it uses a **positive allowlist** to determine which categories to show: `RepoFiles`, `CurrentFolderFiles`, and `Code`. All other categories are excluded by default. This is safer than a blocklist because new categories added to the enum in the future won't accidentally leak into the CLI agent menu.
**Source of truth**: Rather than storing a duplicated flag on `AIContextMenuState`, callers should read `CLIAgentSessionsModel::is_input_open(terminal_view_id)` and pass the result into `get_categories_for_mode()`. The `AIContextMenu` needs to subscribe to `CLIAgentSessionsModel` events and call `refresh_categories_state()` when the input session changes — same pattern used elsewhere (e.g., `UseAgentToolbar` subscribes to `CLIAgentSessionsModel` and re-renders on change). This avoids stale duplicated state.
### 2. Path insertion: use repo-relative paths
**Insertion**: The `InsertFilePath` action in `EditorEvent::AcceptAIContextMenuItem` already handles file path insertion via `replace_at_symbol_with_text()`. In AI mode, this appends a trailing space after the inserted path (`format!("{text} ")`). For CLI agent input, use the same AI-mode behavior (repo-relative paths with trailing space). The trailing space is desirable — it lets the user continue typing the prompt naturally after the path.
### 3. Ensure @ trigger works in CLI agent mode
**Trigger gating**: The `@` trigger must work anywhere in the buffer, including after mode-switch prefixes like `!`. The CLI agent input already calls `set_input_mode_agent`, so the existing `@` detection logic should fire. Verify this works — if the `@` detection is gated differently, add an explicit check for `CLIAgentSessionsModel::is_input_open()`.
## End-to-End Flow
1. User opens CLI agent rich input (Ctrl-G or Compose button).
2. User types `@` anywhere in the buffer.
3. Editor detects `@` and opens the AI context menu.
4. `AIContextMenu` reads `CLIAgentSessionsModel::is_input_open()` = true, returns only file/folder and code symbols categories.
5. User types to filter, selects a file.
6. `InsertFilePath` action fires → `replace_at_symbol_with_text()` inserts the repo-relative path.
7. User presses Enter → buffer text (including the file path) is written to the PTY.
## Risks and Mitigations
### @ trigger not firing in CLI agent mode
The editor's `@` detection may be gated on AI mode. The CLI agent input does call `set_input_mode_agent`, so this should work, but needs verification. If gated differently, add an explicit check for CLI agent input being open.
### Mode-switch prefix interaction with @
When input starts with `!` (bash mode), the `@` trigger must still work at positions after the prefix. The existing `@` detection is position-based (tracks the byte offset of the `@` character) and should work regardless of preceding content. No special handling needed.
## Testing and Validation
- Verify `@` opens the context menu with files/folders and code symbols (no Warp-specific categories) in CLI agent rich input.
- Verify `@foo` filters files by name.
- Verify selecting a file inserts the repo-relative path as plain text.
- Verify `@` works after `!` prefix in the buffer.
- Verify all inserted content submits correctly to the PTY.
- Verify no regressions in normal Warp agent input (`@` context still works as before).
## Follow-ups
- Support `@` context in SSH/remote sessions (requires remote file discovery).
+69
View File
@@ -0,0 +1,69 @@
# CLI Agent Rich Input: /skills Product Spec
## Summary
Add support for `/skills` in the CLI agent rich input composer — the input that appears when composing a prompt to send to a running CLI agent (Claude Code, Codex, Gemini CLI, etc.). Only natively supported skills are shown, and the selected skill name is passed through to the CLI agent via PTY write.
## Problem
When users compose prompts for CLI agents through Warp's rich input (Ctrl-G or the Compose button), they cannot browse or invoke skills. The normal Warp agent input supports skills, but the CLI agent input does not. Users must manually type `/skill-name` without any discovery or autocomplete.
The core constraint is that CLI agent input writes plain text to a PTY — so skill invocation must resolve to a plain text `/skill-name args` string that the CLI agent interprets natively.
## Goals
- Let users browse and select skills via `/` in the CLI agent rich input.
- Only show skills that the active CLI agent can natively interpret (passthrough).
- Hide Warp-specific slash commands that don't apply to CLI agents.
## Non-goals
- Surfacing non-natively-supported skills (e.g., bundled Warp skills like `oz-platform`). Only skills the active CLI agent can interpret should appear.
- Implementing client-side argument parsing for skills. The CLI agent handles argument parsing natively.
- Showing native CLI agent slash commands (e.g., Claude Code's `/compact`, `/model`) in the menu. This is a follow-up (see APP-3641).
## Figma
https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7001-18001&p=f&m=dev
## User Experience
### Trigger
User types `/` at the start of the input in the CLI agent rich input, same trigger as in normal Warp input.
### Limitation
`/skills` is only available in local sessions. It is not supported in SSH/remote contexts because skill discovery relies on local filesystem access.
### Menu
The slash command / skill selector menu opens. Static slash commands that are Warp-agent-specific (e.g., `/agent`, `/new`, `/conversations`, `/cloud-agent`) are hidden when the CLI agent rich input is active. The `/skills` command itself remains available so users can browse skills. Individual skills also appear as direct items in the menu.
### Behavior on selection
Only natively supported skills are shown in the menu. When a skill is selected, `/{skill-name} ` is inserted into the buffer. The user can type arguments after it. On submit, the full text (e.g., `/my-skill arg1 arg2`) is written to the PTY — the CLI agent handles argument parsing natively.
### Natively supported skills
Some CLI agents support a skills/agents folder convention. The known mapping is:
- **Codex**: supports `.agents/`, `.claude/`, `.codex/` folders.
- **OpenCode**: supports `.opencode/`, `.agents/`, `.claude/` folders.
- **Claude Code**: supports `.claude/` folder.
- **Gemini CLI**: supports `.agents/`, `.gemini/` folders.
- **Amp**: supports `.agents/` folder.
- **Copilot**: supports `.agents/`, `.copilot/` folders.
- **Droid**: supports `.factory/`, `.agents/` folders.
A skill is shown in the CLI agent input menu if:
1. The active CLI agent is one that supports skills folders, AND
2. The skill's provider (determined from its filesystem path) matches one of the agent's supported providers.
Skills that don't match the active CLI agent's supported providers are hidden from the menu entirely. Non-natively supported skills (including bundled Warp skills like `oz-platform`) are not shown — supporting them is a potential follow-up.
## Success Criteria
- Users can type `/` in the CLI agent rich input, see natively supported skills, select one, and have `/{skill-name} ` inserted for passthrough to the CLI agent.
- Warp-specific slash commands do not appear in the CLI agent menu (except `/skills`).
- Non-native skills are hidden from the menu.
- This feature is only available in local sessions, not SSH/remote.
## Validation
- Open a CLI agent rich input while Codex is running. Type `/`, select a skill that exists in `.agents/`, verify `/{skill-name} ` is inserted. Type arguments and submit. Verify the full text is written to the PTY.
- Open a CLI agent rich input while Claude Code is running. Type `/`, verify only skills with `.claude/` provider appear.
- Verify that `/skills` command works and opens the skill browser.
- Verify that `/agent`, `/new`, etc. do not appear in the `/` menu.
- Verify that `/skills` is not available in SSH/remote sessions.
## Open Questions
- Should we surface native CLI agent slash commands (e.g., Claude Code's `/compact`, `/model`) in the menu alongside skills? This would require keeping command lists in sync with each CLI agent. See APP-3641. **Answer: not for now!**
+84
View File
@@ -0,0 +1,84 @@
# CLI Agent Rich Input: /skills Technical Spec
## Summary
This spec covers implementing `/skills` support in the CLI agent rich input composer. The approach filters the existing slash command menu and skill data sources to show only natively supported skills, and passes through the selected skill name to the CLI agent via PTY write.
## Relevant Code
- `app/src/terminal/input/slash_commands/data_source/mod.rs``SlashCommandDataSource`, `recompute_active_commands()`
- `app/src/terminal/input/slash_commands/mod.rs``handle_slash_commands_menu_event()`, skill selection handling
- `app/src/terminal/input/slash_commands/view.rs``InlineSlashCommandView`, mixer with 3 data sources
- `app/src/terminal/input/skills/view.rs``InlineSkillSelectorView`
- `app/src/terminal/input/skills/data_source.rs``SkillSelectorDataSource`
- `app/src/terminal/cli_agent_sessions/mod.rs``CLIAgentSessionsModel`, `CLIAgentInputState`
- `app/src/ai/skills/skill_manager.rs``SkillManager`, `skill_by_reference()`
- `ai/src/skills/skill_provider.rs``SkillProvider`, `SKILL_PROVIDER_DEFINITIONS`, provider-to-folder mapping
- `app/src/terminal/cli_agent.rs``CLIAgent` enum
## Current State
The CLI agent rich input (opened via Ctrl-G or the Compose button) is a plain text editor that writes its buffer to the PTY on submit. It reuses the same `Input` view and editor as the normal Warp input, but in a constrained mode. On enter, `input_enter()` detects `CLIAgentSessionsModel::is_input_open()` and emits `Event::SubmitCLIAgentInput` with the raw buffer text, which is written to the PTY.
The slash commands menu and skill selector already exist and work in the normal Warp input. The slash menu (`InlineSlashCommandView`) uses a `SearchMixer` with three data sources:
1. `SlashCommandDataSource` (sync) — static commands like `/agent`, `/new`, `/skills`. Stored in `active_commands_by_id`.
2. `saved_prompts_data_source` (async) — saved prompts from Warp Drive.
3. `ZeroStateDataSource` (sync) — zero-state items combining commands and skills.
Individual skills appear as `AcceptSlashCommandOrSavedPrompt::Skill` items, produced by the data source querying `SkillManager`. The `/skills` command opens a dedicated `InlineSkillSelectorView`.
Currently, all static commands and all skills are shown regardless of whether CLI agent input is active. Warp-specific commands like `/agent` and `/new` don't make sense for CLI agents, and non-native skills can't be interpreted by the CLI agent.
## Proposed Changes
### 1. Filter static slash commands for CLI agent input
**Approach**: Inside `recompute_active_commands()`, read `CLIAgentSessionsModel::is_input_open(terminal_view_id)` directly. When true, filter `active_commands_by_id` to only keep allowlisted commands — specifically `/skills` (so users can browse skills). All other static commands (`/agent`, `/new`, `/conversations`, `/cloud-agent`, etc.) are removed.
No stored boolean is needed. `CLIAgentSessionsModel` is the single source of truth, avoiding stale state.
**Plumbing**: `SlashCommandDataSource` already subscribes to `CLISubagentController` events and calls `recompute_active_commands()`. Add a subscription to `CLIAgentSessionsModel` for `InputSessionChanged` events to trigger `recompute_active_commands()` when the input session opens or closes.
### 2. Filter skills to native-only
**What changes**: Only natively supported skills are shown in the CLI agent input menu. When selected, `/{skill-name} ` is inserted into the buffer. The CLI agent handles argument parsing natively — no client-side parsing needed.
**Filtering skills**: Use the existing `SkillProvider` and `CLIAgent` types to build a mapping of which CLI agents support which skill providers:
```
CLIAgent::Claude → [SkillProvider::Claude]
CLIAgent::Codex → [SkillProvider::Agents, SkillProvider::Claude, SkillProvider::Codex]
CLIAgent::OpenCode → [SkillProvider::OpenCode, SkillProvider::Agents, SkillProvider::Claude]
CLIAgent::Gemini → [SkillProvider::Agents, SkillProvider::Gemini]
CLIAgent::Amp → [SkillProvider::Agents]
CLIAgent::Copilot → [SkillProvider::Agents, SkillProvider::Copilot]
CLIAgent::Droid → [SkillProvider::Droid, SkillProvider::Agents]
CLIAgent::Unknown → [] (no skills shown)
```
The `SkillSelectorDataSource` and `SlashCommandDataSource` (which also surfaces skills) need to filter results based on the active CLI agent's supported providers. When `CLIAgentSessionsModel::is_input_open()` is true, look up the active agent, get its supported providers, and filter out skills whose `ParsedSkill::provider` is not in the list. Non-native skills (including bundled Warp skills) are hidden entirely.
**Selection behavior**: No branching needed — all skills in the menu are natively supported, so the existing behavior of inserting `/{skill-name} ` works as-is.
## End-to-End Flow
1. User types `/` in CLI agent rich input.
2. Slash commands menu opens, showing only `/skills` command and natively supported skills (static commands filtered out).
3. User selects a skill whose provider matches the active CLI agent.
4. `/{skill-name} ` is inserted into the buffer.
5. User types arguments and presses Enter → full text written to PTY.
## Risks and Mitigations
### Skills not appearing for a CLI agent
If the `CLIAgent → SkillProvider` mapping is wrong or incomplete, users won't see their skills. Mitigation: the mapping is derived from existing `SkillProvider` and `SKILL_PROVIDER_DEFINITIONS` which are already used for skill discovery. Keep the mapping in sync with `skill_provider.rs`.
### `/skills` command filtered out
The `/skills` command must be allowlisted when filtering static commands. If accidentally removed, users lose the skill browsing entry point. Mitigation: explicit allowlist check in `recompute_active_commands()`.
## Testing and Validation
- Verify `/` opens the menu with only `/skills` and natively supported skills (no `/agent`, `/new`, etc.).
- Verify only natively supported skills appear (e.g., `.claude/` skills for Claude Code, `.agents/` skills for Codex).
- Verify non-native skills (including bundled Warp skills) are hidden from the CLI agent input menu.
- Verify selecting a skill inserts `/{skill-name} ` for passthrough.
- Verify all inserted content submits correctly to the PTY.
- Verify no regressions in normal Warp agent input (slash menu and skills still work as before).
## Follow-ups
- Surface native CLI agent slash commands (e.g., Claude Code's `/compact`, `/model`) in the menu (APP-3641).
+33
View File
@@ -0,0 +1,33 @@
# CLI Agent Rich Input: /prompts Product Spec
## Summary
Add support for `/prompts` (saved prompts) in the CLI agent rich input composer — the input that appears when composing a prompt to send to a running CLI agent (Claude Code, Codex, Gemini CLI, etc.). When a saved prompt is selected, its content is inserted into the input and submitted as plain text to the PTY.
## Problem
When users compose prompts for CLI agents through Warp's rich input (Ctrl-G or the Compose button), they cannot browse or insert saved prompts. The normal Warp agent input supports prompts, but the CLI agent input does not. Users must manually type or paste prompt content.
## Goals
- Let users browse and select saved prompts in the CLI agent rich input.
- Insert the prompt content into the editor, reusing the existing workflow info box flow (with argument highlighting and shift-tab editing).
## Non-goals
- Changing how prompts work in the normal Warp agent input.
## Figma
https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7001-18001&p=f&m=dev
## User Experience
### Trigger
User selects a prompt from the prompts menu, same as in normal Warp input.
### Behavior
When a saved prompt is selected, the existing workflow info box flow handles insertion — the prompt template is inserted into the editor with argument highlighting and the shift-tab UX for editing parameters. This works as-is because the CLI agent rich input reuses the same `Input` view and editor. On submit, the buffer text (with filled-in arguments) is written to the PTY as plain text.
## Success Criteria
- Users can select a saved prompt in the CLI agent rich input and have its content inserted with the standard workflow argument editing UX.
- The prompt content submits correctly as plain text to the PTY.
## Validation
- Open a CLI agent rich input. Open the prompts menu, select a prompt, verify the prompt content is inserted with argument highlighting.
- Edit a prompt argument using shift-tab, submit, verify the full text is written to the PTY.
+38
View File
@@ -0,0 +1,38 @@
# CLI Agent Rich Input: /prompts Technical Spec
## Summary
This spec covers enabling `/prompts` (saved prompts) in the CLI agent rich input composer. The existing prompt insertion flow works as-is — no code changes are needed beyond ensuring the prompts menu is accessible when CLI agent input is active.
## Relevant Code
- `app/src/terminal/input/prompts/view.rs``InlinePromptsMenuView`
- `app/src/terminal/input.rs``handle_inline_prompts_menu_event()`, `show_workflows_info_box_on_workflow_selection()`
## Current State
The prompts menu (`InlinePromptsMenuView`) and the workflow info box insertion flow already exist in the normal Warp input. When a prompt is selected, `handle_inline_prompts_menu_event()` calls `show_workflows_info_box_on_workflow_selection()`, which inserts the workflow template into the editor with argument highlighting and the shift-tab UX.
The CLI agent rich input reuses the same `Input` view and editor. On submit, `input_enter()` detects `CLIAgentSessionsModel::is_input_open()` and emits `Event::SubmitCLIAgentInput` with the raw buffer text, which is written to the PTY.
## Proposed Changes
### 1. Allowlist the `/prompts` static command in CLI agent input
The `/prompts` static command is currently filtered out when CLI agent rich input is active (along with all other static slash commands). It needs to be **allowlisted** in `SlashCommandDataSource::recompute_active_commands()` so users can open the prompts browser from the slash menu. This is the same allowlisting mechanism used for `/skills` in the skills spec.
### 2. Prompt insertion: no changes needed
The existing `handle_inline_prompts_menu_event()` flow works as-is in CLI agent input mode — the editor is the same, and on submit the buffer text (with filled-in arguments) goes through `SubmitCLIAgentInput` → PTY write as plain text. The shift-tab argument editing is useful for CLI agent prompts too.
The prompts menu is already rendered through the `suggestions_mode_model` system, which works in CLI agent mode.
## End-to-End Flow
1. User opens the prompts menu in CLI agent rich input (via `/prompts` or the slash menu).
2. User selects a saved prompt via click or Enter. This is a **menu selection**`input_enter()` checks `is_prompts_menu()` first and routes to `accept_selected_item()`, not the PTY submission path. The prompt template is inserted into the editor buffer with argument highlighting.
3. The prompts menu closes. The user is now in the normal editor with the prompt content in the buffer.
4. User edits arguments via shift-tab if needed.
5. User presses Enter again → this time no menu is open, so `input_enter()` takes the CLI agent submission path and writes the buffer text to the PTY.
## Testing and Validation
- Verify selecting a saved prompt inserts the prompt text with argument highlighting.
- Verify shift-tab argument editing works in CLI agent rich input.
- Verify the prompt content submits correctly to the PTY.
- Verify no regressions in normal Warp agent input (prompts still work as before).
+81
View File
@@ -0,0 +1,81 @@
# CLI Agent Composer Auto-Show & Auto-Dismiss Settings
## Summary
Add three new user-facing settings that control the automatic visibility of the CLI agent rich input composer. The first setting auto-hides the composer whenever a CLI agent is blocked (requiring direct keyboard interaction) and auto-shows it when the agent resumes work, gated on having the Warp plugin installed for rich status information. The second setting auto-opens the composer when a CLI agent session starts or a plugin listener is registered. The third setting controls whether the composer auto-dismisses after the user submits a prompt, applying whenever Setting 1 is not actively managing composer visibility (either because it is disabled or because there is no plugin listener).
A per-session `should_auto_toggle_input` flag tracks whether auto-toggle is active for a given session. Opening the composer (manually or automatically) opts the session in; manually dismissing (Escape, Ctrl-G toggle, footer button) opts it out. Auto-close on Blocked preserves the flag so auto-open can fire when the agent resumes.
## Problem
Today, users interacting with CLI agents (Claude, Codex, Gemini, etc.) must manually open the rich input composer via Ctrl-G or the footer button every time they want to send a message. There is no way to have the composer appear automatically when the agent is waiting for input. Similarly, after submitting a prompt, the composer remains open, which may not be desired for users who prefer a minimal terminal view when the agent is actively working.
## Goals
- Let users opt into auto-hiding the composer whenever a CLI agent enters a "blocked" state (requiring direct keyboard interaction), and auto-showing it when the agent resumes work, so the interaction feels seamless.
- Let users opt into auto-dismissing the composer after sending a prompt, reducing visual clutter when the agent is working.
- Gate the auto-show behavior on having rich conversation status (i.e., the Warp plugin listener is active), since without it we cannot reliably detect when the agent is blocked.
- Gate the auto-dismiss (post-submission) behavior on Setting 1 not actively managing visibility — when the plugin is present and Setting 1 is enabled, auto-show/hide handles visibility; otherwise the user can choose to have the composer close after submission.
## Non-goals
- Changing the existing manual Ctrl-G / footer button flows.
- Auto-installing the plugin or prompting for installation from these settings.
- Changing behavior in the regular agent conversation view (these settings apply only to CLI agent sessions).
## Figma / Design References
Figma: none provided
## User Experience
### Setting 1: "Auto show/hide composer based on agent status" (`auto_toggle_composer`)
- **Location**: Settings > AI > Coding Agents section, below existing "Show coding agent toolbar" toggle.
- **Label**: `Auto show/hide composer based on agent status`
- **Info tooltip** (ⓘ icon next to label): "Requires the Warp plugin for your coding agent"
- **Default**: `true` (on)
- **Behavior when enabled**:
- When a CLI agent session has a plugin listener (`session.listener.is_some()`), the session's `should_auto_toggle_input` flag is true, and the session status transitions to `Blocked` (permission request, idle prompt), the composer automatically closes (the agent requires direct keyboard interaction in the terminal).
- When the session status transitions away from `Blocked` (to `InProgress` or `Success`), the composer automatically opens.
- If the user manually dismisses the composer (Escape, Ctrl-G toggle, footer button), `should_auto_toggle_input` is set to `false` for that session, disabling auto-toggle until the composer is opened again.
- If there is no plugin listener on the session, this setting has no effect.
- **Behavior when disabled**: No automatic composer visibility changes based on status.
### Setting 2: "Auto open composer when a CLI agent session starts" (`auto_open_composer_on_cli_agent_start`)
- **Location**: Settings > AI > Coding Agents section, below Setting 1.
- **Label**: `Auto open composer when a CLI agent session starts`
- **Default**: `false` (off)
- **Behavior when enabled**:
- When a CLI agent session is created (command detection) or a plugin listener is registered, the composer automatically opens.
- Also sets the session's initial `should_auto_toggle_input` flag to `true`, enabling auto-toggle from Setting 1 immediately.
- **Behavior when disabled**: The composer does not auto-open on session start. The session's `should_auto_toggle_input` starts as `false`, so auto-toggle from Setting 1 remains dormant until the user manually opens the composer.
### Setting 3: "Auto dismiss composer after prompt submission" (`auto_dismiss_composer_after_submit`)
- **Location**: Settings > AI > Coding Agents section, directly below Setting 2.
- **Label**: `Auto dismiss composer after prompt submission`
- **Default**: `false` (off)
- **Behavior when enabled**:
- After the user submits a prompt through the CLI agent composer, the composer automatically closes.
- This setting is a no-op only when the plugin IS present, Setting 1 is enabled, AND `should_auto_toggle_input` is true (because Setting 1's status-driven logic manages visibility in that case). In all other scenarios (no plugin, or plugin present but Setting 1 disabled), this setting controls post-submission behavior.
- **Behavior when disabled**: The composer remains open after submission.
### Edge Cases
- **All settings enabled, plugin present**: Setting 1 governs visibility (auto-hide on blocked, auto-show on resume). Setting 2 auto-opens the composer on session start. Setting 3 is effectively a no-op because the plugin provides rich status.
- **All settings enabled, no plugin**: Setting 2 has no effect (requires plugin for reliable status). Setting 3 closes the composer after submission. Setting 1 has no effect (no rich status to react to).
- **Settings 1 on, Setting 2 off, plugin present**: Auto-toggle is enabled but dormant until the user manually opens the composer (which sets `should_auto_toggle_input = true`). After that, auto-hide on blocked and auto-open on resume are active.
- **Session ends while composer is open**: Existing behavior already handles this (composer closes when session is removed).
- **User manually dismisses composer**: `should_auto_toggle_input` is set to `false`, disabling auto-toggle for that session. The user must re-open the composer to re-enable it.
- **Multiple terminals with different CLI agents**: Settings are global; auto-show/hide applies per-terminal based on each terminal's session state and its own `should_auto_toggle_input` flag.
## Success Criteria
1. A new "Auto show/hide composer based on agent status" toggle appears in Settings > AI > Coding Agents with an (ⓘ) tooltip reading "Requires the Warp plugin for your coding agent". Defaults to on.
2. When enabled and the plugin is present, the composer closes automatically when the CLI agent enters a blocked state and opens when it resumes (once `should_auto_toggle_input` is true for the session).
3. A new "Auto open composer when a CLI agent session starts" toggle appears below the first setting. Defaults to off.
4. A new "Auto dismiss composer after prompt submission" toggle appears below the second setting. Defaults to off.
5. When enabled and no plugin is present, the auto-dismiss setting closes the composer after the user submits a prompt.
6. When the plugin IS present and auto-toggle is active, the auto-dismiss setting has no observable effect (auto-show/hide from setting 1 takes precedence).
7. All three settings persist via the standard settings infrastructure (cloud-synced).
8. All three settings are only effective when AI is enabled and the coding agent toolbar is enabled.
## Validation
- Manual testing: Enable each setting independently and in combination, with and without the Warp plugin, to verify correct auto-show/hide behavior.
- Unit tests: Verify that `CLIAgentSessionsModel` status transitions trigger the correct open/close calls when settings are enabled.
- Settings persistence: Verify settings survive app restart and cloud sync.
## Open Questions
- Should there be a brief delay before auto-showing the composer to avoid flicker for very brief blocked states? (Recommend: no delay initially, iterate if needed.)
+112
View File
@@ -0,0 +1,112 @@
# CLI Agent Composer Auto-Show & Auto-Dismiss — Tech Spec
## Problem
The PRODUCT.md spec requires three new settings that control the visibility lifecycle of the CLI agent rich input composer. The implementation spans settings definitions, the settings UI, the session model, and the terminal view's subscription to CLI agent session status changes.
## Relevant Code
- `app/src/settings/ai.rs (4921144)``AISettings` group where new settings will be added, near the existing `should_render_cli_agent_footer` setting.
- `app/src/settings_view/ai_page.rs (49495067)``CLIAgentWidget` that renders the "Coding Agents" section in Settings > AI.
- `app/src/terminal/cli_agent_sessions/mod.rs``CLIAgentSessionsModel` singleton, `CLIAgentSession`, `CLIAgentSessionStatus`, `CLIAgentInputState`.
- `app/src/terminal/view.rs:10802``handle_cli_agent_sessions_event()` which reacts to `CLIAgentSessionsModelEvent::StatusChanged`.
- `app/src/terminal/view/use_agent_footer/mod.rs:486524``submit_cli_agent_rich_input()` which currently always closes the composer after submission.
- `app/src/terminal/view/use_agent_footer/mod.rs:527574``open_cli_agent_rich_input()` which opens the composer.
## Current State
- The composer is opened manually via Ctrl-G or the footer button (`open_cli_agent_rich_input`).
- After the user submits a prompt, `submit_cli_agent_rich_input` always calls `close_cli_agent_rich_input`.
- `handle_cli_agent_sessions_event` only handles `StatusChanged` for desktop notifications when the user is navigated away — it does not drive any composer visibility logic.
- The `CLIAgentSession` struct has a `listener: Option<ModelHandle<CLIAgentSessionListener>>` field that indicates whether the plugin is connected.
## Proposed Changes
### 1. New settings in `AISettings` (`settings/ai.rs`)
Add three new boolean settings inside the `define_settings_group!(AISettings, ...)` block, placed after `should_render_cli_agent_footer`:
- `auto_toggle_composer` (`AutoToggleComposer`): default `true`. Auto-hides the composer on `Blocked` and auto-shows on `InProgress`/`Success`, gated on plugin presence and the per-session `should_auto_toggle_input` flag.
- `auto_open_composer_on_cli_agent_start` (`AutoOpenComposerOnCLIAgentStart`): default `false`. Auto-opens the composer when a session is created or a plugin listener is registered. Also sets the session's initial `should_auto_toggle_input` flag.
- `auto_dismiss_composer_after_submit` (`AutoDismissComposerAfterSubmit`): default `false`. Auto-closes the composer after prompt submission, only when Setting 1 is not actively managing visibility.
### 2. Settings UI (`settings_view/ai_page.rs`)
Extend `CLIAgentWidget` to include two new `SwitchStateHandle` fields and render two new toggles inside the "Coding Agents" section, gated on `is_footer_enabled`:
- **Setting 1 toggle**: Label "Auto show/hide composer based on agent status" with an `AdditionalInfo` info tooltip saying "Requires the Warp plugin for your coding agent".
- **Setting 2 toggle**: Label "Auto dismiss composer after prompt submission" with a description explaining the behavior.
Add corresponding `AISettingsPageAction` variants (`ToggleAutoToggleComposer`, `ToggleAutoDismissComposerAfterSubmit`) and wire them to the settings.
### 3. Per-session `should_auto_toggle_input` flag (`cli_agent_sessions/mod.rs`)
Add a `should_auto_toggle_input: bool` field to `CLIAgentSession`. This flag controls whether auto-toggle is active for a given session:
- Initialized from `*AISettings::as_ref(ctx).auto_open_composer_on_cli_agent_start` when the session is created or a listener is registered.
- Set to `true` whenever the composer is opened (via `open_input`, which always passes `true`).
- Set to `false` when the user manually dismisses the composer (`close_cli_agent_rich_input_and_disable_auto_toggle``close_input` with `false`).
- Preserved as `true` when auto-close fires on Blocked (`close_cli_agent_rich_input``close_input` with `true`).
Threaded through `register_listener`, `open_input`, and `close_input` as a parameter.
### 4. Auto-show/hide on status changes (`terminal/view.rs`)
Extend `handle_cli_agent_sessions_event` to react to `StatusChanged` for the current terminal view (not just notifications). When all conditions are met:
- `auto_toggle_composer` is enabled
- The session has a plugin listener and `should_auto_toggle_input` is `true`
- AI is enabled and the CLI agent toolbar is enabled
Then:
- On transition to `Blocked`: call `close_cli_agent_rich_input` (preserves `should_auto_toggle_input = true`).
- On transition to `InProgress` or `Success`: call `open_cli_agent_rich_input(AutoShow)` if the composer isn't already open.
Additionally, `maybe_auto_open_cli_agent_composer` is called after session creation and listener registration to handle the auto-open-on-start setting.
### 5. Conditional close after submission (`terminal/view/use_agent_footer/mod.rs`)
A shared `maybe_close_composer_after_submit` method encapsulates the conditional close logic, called from both the synchronous path and the `DelayedEnter` timer callback in `write_cli_agent_text_then_submit`. It checks `has_plugin` (plugin present AND `should_auto_toggle_input`) and `auto_toggle_composer` to decide whether status events manage visibility or `auto_dismiss_composer_after_submit` should close the composer.
### 6. Close variants (`terminal/view/use_agent_footer/mod.rs`)
- `close_cli_agent_rich_input`: delegates to `close_cli_agent_rich_input_impl(true)` — preserves `should_auto_toggle_input` for auto-close on Blocked.
- `close_cli_agent_rich_input_and_disable_auto_toggle`: delegates to `close_cli_agent_rich_input_impl(false)` — disables auto-toggle when the user manually dismisses.
All manual close call sites (Escape, Ctrl-G toggle, footer button toggle, footer hide, block completion) use the `_and_disable_auto_toggle` variant.
### 7. New `CLIAgentInputEntrypoint::AutoShow` variant
Added to `cli_agent_sessions/mod.rs` to distinguish auto-opens from manual opens in telemetry.
## End-to-End Flow
### Auto-open on session start (Setting 2 enabled):
1. CLI agent command detected → session created with `should_auto_toggle_input = true`.
2. `maybe_auto_open_cli_agent_composer` fires → composer opens via `AutoShow` entrypoint.
### Auto-hide on blocked, auto-show on resume (Setting 1 enabled, plugin present):
1. CLI agent runs and enters `PermissionRequest``CLIAgentSession::apply_event` sets status to `Blocked`.
2. `CLIAgentSessionsModel` emits `StatusChanged { status: Blocked }`.
3. `TerminalView::handle_cli_agent_sessions_event` checks: setting on, plugin present, `should_auto_toggle_input` true → calls `close_cli_agent_rich_input` (preserves flag).
4. User interacts directly with the terminal (e.g., approves permission).
5. Agent resumes → status changes to `InProgress` → handler calls `open_cli_agent_rich_input(AutoShow)`.
### Manual dismiss breaks auto-toggle cycle:
1. During auto-toggle, user presses Escape → `close_cli_agent_rich_input_and_disable_auto_toggle` sets `should_auto_toggle_input = false`.
2. Subsequent status changes no longer trigger auto-open/close for this session.
3. User manually re-opens with Ctrl-G → `open_input` sets `should_auto_toggle_input = true` → auto-toggle resumes.
### Auto-dismiss on submit (Setting 3 enabled, no plugin):
1. User manually opens composer with Ctrl-G.
2. User submits text → `maybe_close_composer_after_submit` checks: no plugin (or `should_auto_toggle_input` false), setting 3 is on → closes the composer.
## Risks and Mitigations
- **Flicker from rapid status transitions**: If a CLI agent rapidly transitions Blocked→InProgress→Blocked, the composer could flicker open/close. Mitigation: unlikely in practice since permission requests have user-gated responses. Can add a debounce later if needed.
- **Race with manual open**: If the user manually opens the composer just before auto-close fires, it could feel jarring. Mitigation: the auto-close only fires on status transitions, not on a timer, so it maps to genuine agent state.
## Testing and Validation
- Add unit tests in `cli_agent_sessions/mod_tests.rs` verifying that `StatusChanged` events propagate correctly.
- Add integration test scenarios exercising auto-show on blocked and auto-dismiss on submit.
- Manual testing with and without the plugin to verify both settings behave correctly.
## Follow-ups
- Add telemetry for auto-show/auto-dismiss to track adoption.
- Consider debounce/delay on auto-show if rapid transitions prove to be an issue.
+41
View File
@@ -0,0 +1,41 @@
# APP-3648: Vertical Tabs Panel — Search + Control Bar
## Overview
Add a fixed control bar at the top of the vertical tabs panel containing a search input and a "new tab" button. This bar sits above the scrollable tab groups and is always visible when the panel is open.
## Milestone 1 Scope
- **Search input**: Renders as a full-width text field with placeholder text (e.g. "Search tabs...") and a magnifying glass icon. **Non-functional** — the input does not accept text and is not focusable. Hovering anywhere on the search input shows a tooltip: **"Not yet implemented"**.
- **Plus button**: A single icon button (`+`) to the right of the search input. Left-click creates a new tab (equivalent to `WorkspaceAction::AddTab`). Right-click opens the new session dropdown menu (equivalent to `WorkspaceAction::ToggleNewSessionMenu`).
- **Configs button**: Out of scope for this milestone.
## Behavior
### Search Input
- Visually resembles a standard search field: magnifying glass icon on the left, placeholder text, themed background/border consistent with the panel.
- The field is **inert** — clicking it does nothing (no focus, no cursor, no text entry).
- On hover, a tooltip appears with the text **"Not yet implemented"**.
- The search input stretches to fill all available horizontal space to the left of the plus button.
### Plus Button
- Renders as a small icon button with a `+` icon.
- **Left-click**: Creates a new tab (same as the existing `AddTab` action — opens a welcome tab or terminal tab depending on feature flags).
- **Right-click**: Opens the new session dropdown menu, anchored below the plus button at the bottom edge of the control bar.
- On hover, shows a tooltip with label **"New Tab"** and the keybinding for the new tab action (Cmd+Shift+T / Ctrl+Shift+T).
- The dropdown menu contains the same items as the existing new session menu (shell options, launch configs, tab configs).
### Layout
- The control bar is a horizontal row at the top of the vertical tabs panel, above the scrollable tab group list.
- It is **not scrollable** — it stays fixed at the top as tab groups scroll.
- It has horizontal padding consistent with the rest of the panel (12px).
- Vertical padding provides comfortable spacing from the panel's top edge and the first tab group below.
## Edge Cases
1. **Narrow panel widths**: At the minimum panel width (200px), the search input text truncates but the plus button remains fully visible and usable. The plus button has a fixed size; the search input absorbs all remaining width.
2. **Tooltip clipping**: The "Not yet implemented" tooltip and the new session dropdown menu must not clip outside the window bounds. Use window-aware positioning.
3. **Focus**: Since the search input is inert, clicking it must **not** steal focus from the active terminal or editor. The control bar itself does not participate in focus management.
4. **Panel toggle**: The control bar is only visible when the vertical tabs panel is open. No special behavior on open/close.
5. **Dropdown menu lifecycle**: The new session dropdown menu follows the same open/close lifecycle as the existing top bar menu — it closes on item selection, clicking outside, or pressing Escape.
6. **Multiple windows**: Each window's vertical tabs panel has its own independent control bar instance. Dropdown menus are scoped to their window.
+84
View File
@@ -0,0 +1,84 @@
# APP-3648: Tech Spec — Vertical Tabs Control Bar
## Current State
The vertical tabs panel is rendered in `app/src/workspace/view/vertical_tabs.rs` via the free function `render_vertical_tabs_panel`. The panel layout is:
```
Resizable (drag-right edge)
└─ Container (background + right border)
└─ Flex::column
└─ Shrinkable(ClippedScrollable::vertical(tab groups))
```
There is currently no control bar — tab groups fill the entire panel. The panel is resizable (min 200px, max 50% window width) with state managed in `VerticalTabsPanelState`.
The "new tab" split button in the top bar (`render_new_session_button` in `view.rs`) dispatches `WorkspaceAction::AddTab` on left-click and `WorkspaceAction::ToggleNewSessionMenu { position }` on right-click/chevron-click. The dropdown menu is rendered as an overlay in the workspace's main `Stack`, positioned at the stored `show_new_session_dropdown_menu` coordinates.
## Proposed Changes
### 1. Add state handles to `VerticalTabsPanelState`
`vertical_tabs.rs (struct VerticalTabsPanelState)`:
- `search_mouse_state: MouseStateHandle` — drives the tooltip on the search input
- `add_tab_mouse_state: MouseStateHandle` — drives the plus button hover/click states
Initialize both with `Default::default()` in the existing `Default` impl.
### 2. Add `render_control_bar` function
New free function in `vertical_tabs.rs`:
```
render_control_bar(state, app) -> Box<dyn Element>
```
Layout: `Flex::row` with `CrossAxisAlignment::Center`, padded with horizontal `GROUP_HORIZONTAL_PADDING` (12px) and vertical padding ~8px.
**Search input** (left, fills remaining space via `Shrinkable`):
- `Hoverable` wrapping a `Container` styled as an inert search field:
- Left: `WarpIcon::Search` magnifying glass icon (12px, sub-text color)
- Right: `Text::new_inline("Search tabs...", ...)` placeholder in sub-text color, clipped
- Background: `fg_overlay_1` or similar subtle fill; rounded corners
- Height: ~24px to match icon button sizing
- On hover: show tooltip "Not yet implemented" via `ui_builder.tool_tip_on_element(...)` (overlay variant, so it's not clipped by the panel's `Clipped` wrapper)
- No click handler, no cursor change — remains inert
**Plus button** (right, fixed width):
- Use `icon_button(appearance, Icon::Plus, false, state.add_tab_mouse_state.clone())`
- Wrap in a `Hoverable` to add a tooltip with "New Tab" + keybinding sublabel
- `.on_click(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::AddTab))`
- `.on_right_click(|ctx, _, position| ctx.dispatch_typed_action(WorkspaceAction::ToggleNewSessionMenu { position }))`
- Wrap in `SavePosition` with a dedicated position ID (e.g. `"vertical_tabs_add_tab_button"`) so the dropdown menu can anchor to it
### 3. Integrate into `render_vertical_tabs_panel`
Change the `Flex::column` to include the control bar as the first child, outside the scrollable:
```
Flex::column()
.with_main_axis_size(MainAxisSize::Max)
.with_child(render_control_bar(state, app)) // NEW — fixed at top
.with_child(Shrinkable::new(1., scrollable_groups)) // existing — scrolls
.finish()
```
The control bar stays fixed because it's a sibling of the `Shrinkable`-wrapped scrollable, not inside it.
### 4. Dropdown menu positioning
No changes needed to the workspace-level menu rendering. The existing `ToggleNewSessionMenu { position }` flow stores the click position and renders the menu in the workspace `Stack` overlay. The right-click on the plus button passes its screen position directly, so the menu will appear below the button naturally. If the menu clips at the panel edge, `ParentOffsetBounds::WindowByPosition` (already used for the shell-selector variant) will reposition it within the window.
## Edge Cases Handled
- **Narrow panel (200px min)**: Search input uses `Shrinkable` so it compresses; plus button has fixed width and remains usable.
- **Focus**: No focus handlers on the search input; clicking it does nothing.
- **Tooltip clipping**: Use the overlay tooltip variant (`overlay_tool_tip_on_element`) so the tooltip renders above the `Clipped` scrollable.
## Parallelism
This is a single-file change (`vertical_tabs.rs`) with a minor dependency on imports from `buttons.rs` and `icons.rs`. No sub-agents needed — the work is sequential and localized.
## Files Changed
- `app/src/workspace/view/vertical_tabs.rs` — all substantive changes (state, rendering, control bar)
+165
View File
@@ -0,0 +1,165 @@
# APP-3651: Vertical Tabs Panel — Pane Row Layout Iteration
## Summary
Iterate on the vertical tabs panel pane row rendering to improve information density, relevance, and interaction. The changes restructure how terminal and non-terminal pane rows display their content, introduce start-clipping for path text, replace the group collapse chevron with a close button, and deduplicate redundant terminal titles.
## Problem
The current vertical tabs layout has several information hierarchy issues:
- Terminal panes show the working directory as the primary line, but users more often care about *what they're doing* (the terminal title, conversation status, or last command) than *where they are*.
- Non-terminal panes waste a full row on a "kind badge" (e.g. "Code", "Notebook") that duplicates information already conveyed by the icon.
- Path text clips from the end, hiding the most distinguishing part (the filename) when paths are long.
- The expand/collapse chevron on tab group headers is no longer needed and occupies space that would be better used for a close button.
- When a shell's terminal title is just the working directory (the default for most shells), both the primary and secondary lines show the same information.
## Goals
- Make the most task-relevant information (terminal title, conversation status, or last command) the primary line for terminal panes.
- Reduce visual noise for non-terminal panes by inlining the kind icon next to the title and removing the standalone badge row.
- Show the most distinguishing portion of paths by clipping from the start.
- Provide a direct way to close a tab from the vertical tabs panel.
- Eliminate redundant information when the terminal title matches the working directory.
## Non-goals
- Implementing a "compact" single-row rendering mode (shown in the Figma mock but out of scope for this iteration).
- Adding new pane types or changing how pane titles/subtitles are set in `PaneConfiguration`.
- Changing the tab group header title, count label, or drag behavior.
- Search functionality in the control bar (already non-functional/placeholder).
## Figma / design references
Figma: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7079-22324&m=dev
Note: The Figma mock shows a compact single-row rendering that is not in scope. The close button (X) design on the tab group header and the inline kind icons next to titles are relevant references.
## User experience
### Terminal pane rows
Currently the terminal row layout is:
1. **Primary line** (main text color): working directory • git branch
2. **Secondary line** (sub text color): conversation title/status, or terminal title if it differs from working directory
3. **Tertiary line**: kind badge ("Terminal" or "Oz") + right-side badges (diff stats, PR)
The new layout reverses lines 1 and 2:
1. **Primary line** (main text color): terminal title or conversation status (see rules below)
2. **Secondary line** (sub text color): working directory • git branch
3. **Tertiary line**: unchanged (kind badge + right-side badges)
#### Primary line rules for terminal panes
The primary line content is determined by, in order of precedence:
1. If the pane has an active agent conversation with a display title: show the conversation status indicator followed by the conversation display title.
2. If the terminal title differs from the displayed working directory: show the terminal title.
3. If the terminal title matches the displayed working directory exactly (trimmed comparison) and there is a last completed user command: show the last completed user command string.
4. If the terminal title matches the displayed working directory and there is no completed user command (brand new session): show "New session" in the UI font.
The primary line always uses main text color.
#### Secondary line rules for terminal panes
The secondary line shows the working directory and git branch (same content as the current primary line) in sub text color. This line is always shown when a working directory is available.
#### Terminal title / working directory deduplication detail
"Matches exactly" means a case-sensitive, trimmed string comparison between `terminal_title_from_shell()` and `display_working_directory()`. The deduplication only applies when there is no active agent conversation display title.
When deduplication triggers and we show the last completed command:
- The command string should be rendered in the monospace font family currently used for terminal titles.
- If the last completed command is empty or unavailable (brand new session with no commands run), show the text "New session" in the UI font (not monospace).
### Non-terminal pane rows
Currently non-terminal panes render:
1. **Title line**: title text (main text color)
2. **Subtitle line** (if non-empty): subtitle text (sub text color)
3. **Meta row**: kind badge (icon + label like "Code", "Notebook") on the left, optional badge on the right
The new layout removes the standalone meta row and inlines the kind icon:
1. **Primary line**: kind icon (12px, sub text color) followed by title text (main text color). For code panes, the icon is the programming language icon for the active file (falling back to the generic `Code2` icon if no language icon exists).
2. **Subtitle line** (if non-empty): subtitle text (sub text color)
No separate badge row is rendered. The kind badge row is removed entirely.
#### Code panes with multiple tabs
For code panes with more than one open tab:
- **Primary line**: kind icon + first file's path (the active tab's path from `PaneConfiguration.title()`).
- **Secondary line**: "and X more" where X is `tab_count - 1`, rendered in sub text color. This replaces the current `(+N)` secondary title set by `CodeView::set_title`.
For code panes with a single tab, render normally using the title from `PaneConfiguration`.
#### Kind icon for code panes
Use `crate::code::icon_from_file_path` to attempt to get a language-specific icon (Rust, TypeScript, Python, etc.) from the active file's path. If `icon_from_file_path` returns `None`, fall back to the generic `WarpIcon::Code2` icon rendered as a `to_warpui_icon` in sub text color.
For non-code, non-terminal panes (Notebook, Settings, Workflow, etc.), use the existing `TypedPane::icon()` value.
### Path clipping
All path text in pane rows should clip from the start instead of the end. This applies to:
- Working directory text in terminal pane secondary lines
- File path text in code pane primary lines
- Any other path-like text rendered in pane rows
Use `ClipConfig::start()` instead of `ClipConfig::end()`. The `ClipConfig::start()` variant already exists in the codebase and fades text at the leading edge.
Git branch text should continue to clip from the end (branch names are most distinguishing at the start).
### Close button on tab group headers
Replace the expand/collapse chevron button in the tab group header with a close button (X icon):
- The close button uses an X icon instead of `ChevronDown`/`ChevronRight`.
- Clicking the close button dispatches `WorkspaceAction::CloseTab(tab_index)` to close the entire tab.
- The close button has the same hover styling as the current collapse button (background highlight on hover, pointing hand cursor).
- The close button is always visible in the header (not only on hover).
Remove all expand/collapse state and behavior:
- Remove the `collapsed_tab_groups` set from `VerticalTabsPanelState`.
- Remove the `toggle_group_collapsed`, `toggle_all_groups_collapsed`, and `is_group_collapsed` methods.
- Remove the `ToggleVerticalTabsGroupCollapsed` action handling.
- Tab groups are always expanded (pane rows are always visible).
### No behavioral changes
- Clicking a pane row still focuses that pane (dispatches `WorkspaceAction::FocusPane`).
- Clicking the tab group header title still activates the tab.
- Double-clicking the header still triggers rename.
- Right-clicking still opens the tab context menu.
- Drag-and-drop tab reordering is unchanged.
- The pane count label next to the close button is unchanged.
## Success criteria
1. **Terminal primary line**: For a terminal pane with no agent conversation and a non-default terminal title (e.g. `vim`, `htop`, a custom title), the primary line shows that terminal title in main text color.
2. **Terminal primary line (agent)**: For a terminal pane with an active agent conversation, the primary line shows the conversation status indicator + conversation display title.
3. **Terminal secondary line**: The working directory and git branch are always shown on the second line in sub text color, with the same layout as the current primary line.
4. **Terminal title dedup**: For a terminal pane where `terminal_title_from_shell()` trims to the same string as `display_working_directory()`, the primary line shows the last completed user command (if available) instead of the terminal title.
5. **New session fallback**: For a brand new terminal session with no completed commands and a default terminal title matching the working directory, the primary line shows "New session" in the UI font.
6. **Non-terminal kind icon inline**: For a Notebook pane, the primary line shows the Notebook icon followed by the title — no separate "Notebook" badge row.
7. **Code pane language icon**: For a code pane with a `.rs` file open, the primary line shows the Rust language icon (not the generic Code2 icon).
8. **Code pane language fallback**: For a code pane with a `.txt` file (no language icon), the primary line shows the generic Code2 icon.
9. **Code pane multi-tab**: For a code pane with 3 tabs open, the primary line shows the active file path and the secondary line shows "and 2 more".
10. **No badge row**: Non-terminal pane rows have no standalone kind badge or badge row beneath the title/subtitle.
11. **Path start-clipping**: A long working directory path like `~/very/long/path/to/my-project` clips from the left (showing `…my-project`) rather than from the right.
12. **Git branch end-clipping**: A long git branch name clips from the right (showing `feature/my-long-br…`).
13. **Close button**: Clicking the X button on a tab group header closes the tab (equivalent to `CloseTab`). The close button shows a hover highlight.
14. **No collapse**: There is no expand/collapse chevron. All pane rows in a tab group are always visible.
15. **Pane row click**: Clicking a pane row within a group still focuses that pane.
## Validation
- **Manual testing**: Open Warp with vertical tabs enabled. Create terminal tabs with various states (default shell, running `vim`, agent conversations, multiple directories). Verify primary/secondary line content matches the rules above.
- **New session**: Open a brand new terminal tab. Before running any commands, verify the primary line says "New session" and the secondary line shows the working directory.
- **Code pane testing**: Open code panes with single and multiple files of various languages. Verify language icons appear for supported extensions and fall back to Code2 for unsupported ones. Verify multi-tab "and X more" rendering.
- **Path clipping**: Resize the vertical tabs panel to a narrow width and verify that long paths clip from the start (filename visible) and git branches clip from the end.
- **Close button**: Click the X on a tab group header and verify the tab closes. Verify no collapse/expand behavior remains.
- **Terminal dedup**: In a shell where the terminal title defaults to the working directory, run a command and verify the primary line shows the last command rather than the (redundant) working directory.
- **Regression**: Verify tab group header click (activate tab), double-click (rename), right-click (context menu), and drag reorder still work. Verify pane row click still focuses the pane.
## Open questions
1. **Last completed command source**: The terminal model tracks block metadata, but the exact API to retrieve "the last completed user command string" from `TerminalView` needs to be identified or added. The current codebase doesn't expose a simple `last_completed_command_text()` accessor — this will need to be addressed in the tech spec.
2. **"and X more" click behavior**: Should clicking the "and X more" secondary line text do anything special (e.g. cycle to the next tab in the code pane), or should it behave the same as clicking anywhere else on the pane row (focus the code pane)?
+199
View File
@@ -0,0 +1,199 @@
# 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.rs``terminal_title_from_shell()`, `display_working_directory()`, `current_git_branch()`
- `app/src/terminal/view/pane_impl.rs:962-973``selected_conversation_status()`, `selected_conversation_display_title()`, `is_ambient_agent_session()`
- `app/src/terminal/model/blocks.rs:1708``BlockList::blocks()` returns `&Vec<Block>`
- `app/src/terminal/model/block.rs:2143``Block::command_to_string()`, `Block::finished()`, `BlockState`
- `app/src/code/view.rs:222-231``CodeView::tab_group` (private), `CodeView::set_title` (sets PaneConfiguration title/secondary)
- `app/src/code/icon.rs:11``icon_from_file_path(path, appearance) -> Option<Box<dyn Element>>`
- `app/src/pane_group/pane/code_pane.rs:53``CodePane::file_view()` returns `ViewHandle<CodeView>`
- `app/src/workspace/action.rs:99,227-230``WorkspaceAction::CloseTab`, `ToggleVerticalTabsGroupCollapsed`, `ToggleAllVerticalTabsGroupsCollapsed`
- `ui/src/text_layout.rs:455-460``ClipConfig::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
`TerminalModel``BlockList::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:
```rust
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`:
```rust
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:
```rust
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.
+109
View File
@@ -0,0 +1,109 @@
# APP-3655: Vertical Tabs — Search Functionality + Control Bar UI Polish
## 1. Summary
Implement search/filter functionality for the vertical tabs panel search bar, and update the search bar's visual appearance to match the design mocks. The search bar will filter the visible pane items and tab groups in real time as the user types.
## 2. Problem
The search bar in the vertical tabs panel was implemented as a non-functional placeholder (APP-3648). Users with many tabs and tab groups have no quick way to navigate to a specific pane. The control bar's current visual treatment (background, border, padding) also diverges from the design mocks.
## 3. Goals
- Make the search bar fully functional: it accepts text input and filters the list of pane items and tab groups in real time.
- Match the design mocks for the control bar: correct horizontal padding and a visually "uncontainerized" search input (no background, no border).
- Preserve tab/group ordering in filtered results.
- Omit tab groups that have no matching panes.
## 4. Non-goals
- Fuzzy matching or ranked/sorted results — simple substring matching is sufficient.
- Searching across window sessions or workspaces beyond the current panel.
- Keyboard-navigable filtered results (arrow-key selection of filtered items) — out of scope for this iteration.
- Persisting or restoring the search query across panel closes or app restarts.
- Highlighting matched text within pane item rows.
## 5. Figma / Design References
Figma: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7080-26025&m=dev
## 6. User Experience
### Search bar activation
- The search input is now fully focusable and accepts keyboard input.
- Clicking anywhere on the search bar focuses it and shows a cursor.
- The "Not yet implemented" hover tooltip is removed.
### Filtering behavior
- As the user types into the search bar, the pane list is filtered in real time — no submit/enter required.
- Filtering is **case-insensitive** substring matching.
- **Search domain**: all visible text within a pane item row is considered, including:
- Primary title (e.g. working directory or pane title)
- Secondary title / subtitle (e.g. git branch, terminal title)
- Pane kind label (e.g. "Terminal", "Code", "Settings")
- Any badge text visible in the row (e.g. "Unsaved", PR name, diff stats)
- **Ordering**: panes that pass the filter are shown in their original display order — no reranking.
- **Tab groups**: a tab group header is shown only if at least one of its panes matches the query. If no panes in a group match, the group header is omitted entirely.
- **Empty state**: if the query matches no panes across all groups, the list area shows an empty state message, e.g. "No tabs match your search."
### Clearing the search
- Pressing **Escape** while the search bar is focused clears the query and returns focus to the previously active pane (existing behavior for focus return is preserved).
- Deleting all characters from the input restores the full, unfiltered list.
- Clicking outside the search bar (e.g. clicking a pane item) does **not** clear the query — the filter remains active.
### Control bar visual changes
- **Horizontal padding**: The control bar's left and right padding matches the content area of the panel (12px on each side, consistent with `GROUP_HORIZONTAL_PADDING`).
- **Search input container**: The background fill and border of the search input container are removed. The search input appears as a plain, unstyled text field with the magnifying glass icon — visually integrated into the control bar rather than enclosed in a box.
- All other control bar elements (split new-tab button, icon sizes, vertical padding) are unchanged.
### Tab switching while filtered
- When a search query is active, the "next tab" and "previous tab" actions (keyboard shortcuts) cycle through only the **filtered** pane list — not the full natural order. The user steps through exactly the panes that are currently visible.
- Once the query is cleared, next/previous tab navigation resumes using the full natural order.
### Interaction with non-search actions
- Creating a new tab (plus button) while a search query is active does not clear the search.
- Clicking a pane item to navigate to it does not clear the search query.
- The filtered view reflects the current query at all times while the query is non-empty.
## 7. Success Criteria
1. Typing into the search bar visibly filters the pane list to only panes whose row text contains the query (case-insensitive) without pressing Enter.
2. Tab groups with no matching panes are hidden entirely, including their group header row.
3. Tab groups with at least one matching pane are shown, and only their matching panes appear beneath them.
4. Original display ordering of both groups and panes within groups is preserved in filtered results.
5. Clearing the query (by deleting all text) restores the full unfiltered list identically to how it appeared before searching.
6. Pressing Escape while focused on the search bar clears the query and returns focus to the active pane.
7. While a search query is active, next/previous tab keyboard shortcuts cycle through only the filtered (visible) panes, not the full natural order.
8. An empty state is displayed when the query matches no panes.
9. The control bar has 12px horizontal padding on each side, matching the panel content area.
10. The search input has no visible background or border — it is visually "uncontainerized."
11. Clicking on the search bar focuses it (cursor visible, text input accepted) with no tooltip shown.
12. The search domain includes all visible text in the pane row: primary title, secondary title, pane kind label, and badge text.
13. Collapsing and re-opening the panel preserves the active search query and filtered view.
14. Clicking a pane item while a query is active does not clear the query.
## 8. Validation
- **Manual**: Type partial strings that match: only the primary title, only a subtitle, only a kind label (e.g. "code"), only badge text. Confirm only matching panes appear.
- **Manual**: Confirm tab groups with no matches are completely hidden.
- **Manual**: Confirm ordering of surviving results matches the original order.
- **Manual**: Confirm empty state message appears when no panes match.
- **Manual**: Press Escape — query clears, full list restores, focus returns to active pane.
- **Visual**: Screenshot the control bar with the search input focused and unfocused; confirm no background or border on the search input, and 12px horizontal padding aligning with pane item rows.
- **Regression**: Confirm the plus/new-tab button still creates tabs and opens the dropdown while a query is active.
- **Manual**: With a filtered list active, use next/previous tab shortcuts and confirm navigation skips non-matching panes.
- **Manual**: Collapse and re-open the panel; confirm the query and filtered view are preserved.
- **Manual**: Click a pane item in the filtered list; confirm the query is not cleared.
## 9. Open Questions
None — all open questions resolved:
- Search query persists across panel collapse/re-open.
- Clicking a pane item to navigate does not clear the query.
- All visible text in the pane row is searchable; no fields are excluded.
+208
View File
@@ -0,0 +1,208 @@
# APP-3655: Vertical Tabs — Search Functionality + Control Bar UI Polish (Tech Spec)
## 1. Problem
The vertical tabs panel has a search input that is fully inert — it cannot be focused or typed into. This spec covers:
1. Making the search input functional: read query text, filter the rendered pane list and tab group headers in real time.
2. Updating tab cycling (`activate_next_tab` / `activate_prev_tab`) to skip tab groups that have no matching panes when a filter is active.
3. Visual polish of the control bar: remove the search input container background/border, and align the control bar's horizontal padding with the rest of the panel content.
## 2. Relevant Code
- `app/src/workspace/view/vertical_tabs.rs` — all vertical tabs rendering; the files that change most
- `render_control_bar` (line 185) — search bar UI
- `render_groups` (line 380) — tab group iteration, filter logic goes here
- `render_tab_group` (line 408) — renders one group header + its pane rows
- `PaneProps::new` (line ~882) — assembles the searchable text fields per pane
- `TypedPane::kind_label`, `TypedPane::badge` (lines ~820860)
- `app/src/workspace/view.rs`
- `Workspace` struct (line ~760) — add `vertical_tabs_search_query: String`
- `vertical_tabs_search_input` constructor (line 890) — add `Edited` subscription
- `activate_next_tab` / `activate_prev_tab` (lines 74817497) — filter-aware cycling
- `app/src/editor/view/mod.rs`
- `EditorView::buffer_text(&self, ctx: &AppContext) -> String` (line 3675) — the public API to read query text from an `EditorView`
## 3. Current State
**Search input**: The `EditorView` for the search bar is created and rendered but is fully passive. No subscription listens to text changes, and no query string is read or used anywhere.
**Control bar layout** (`render_control_bar`, line 232242):
- The outer `Container` uses `Padding::uniform(CONTROL_BAR_VERTICAL_PADDING)` (4px all sides) — no left/right padding to match the content area.
- The search bar `Container` has `with_background(internal_colors::fg_overlay_1(theme))` and `with_corner_radius(...)`, giving it a visible box appearance.
**Tab cycling** (`activate_next_tab` / `activate_prev_tab`, lines 74817497):
- Cycles through `self.tabs` (all tab groups) unconditionally by index with wrap-around.
## 4. Proposed Changes
### 4.1 Store the search query on `VerticalTabsPanelState`
Add a `String` field to `VerticalTabsPanelState` (vertical_tabs.rs):
```rust
pub(super) struct VerticalTabsPanelState {
// ... existing fields ...
search_query: String,
}
```
Initialize it as `String::new()`. Keeping the query here (rather than on `Workspace`) co-locates it with all other panel-specific state, and `render_groups` already receives `state: &VerticalTabsPanelState` directly — no additional threading needed to read it during render. `activate_next_tab`/`activate_prev_tab` access it via `self.vertical_tabs_panel.search_query` (the field on `Workspace` is `vertical_tabs_panel: VerticalTabsPanelState`, view.rs line 862).
### 4.2 Subscribe to `EditorEvent::Edited` in `vertical_tabs_search_input`
In `Workspace::vertical_tabs_search_input` (view.rs line 890), add a second subscription alongside the existing `Escape` handler:
```rust
ctx.subscribe_to_view(&editor, |me, editor_view, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
me.vertical_tabs_panel.search_query = editor_view.as_ref(ctx).buffer_text(ctx);
ctx.notify();
}
});
```
When `Escape` clears the editor, also clear the query. Merge this into the existing `Escape` handler rather than adding a separate subscription:
```rust
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
if matches!(event, EditorEvent::Escape) {
me.vertical_tabs_panel.search_query.clear();
me.focus_active_tab(ctx);
}
});
```
### 4.3 Apply the filter in `render_groups`
`render_groups` (line 380) already receives `state: &VerticalTabsPanelState`. Read the query from `state.search_query` directly — no signature change needed.
If the query is non-empty:
1. For each `tab` in `workspace.tabs`, compute the list of `PaneId`s from `pane_group.visible_pane_ids()` that satisfy `pane_matches_query`.
2. If no panes match → skip the entire tab group (no call to `render_tab_group`).
3. If ≥1 panes match → call `render_tab_group`, passing the filtered `Vec<PaneId>` so only matching rows are rendered.
4. If no groups survive and the query is non-empty → render the empty-state message: `"No tabs match your search."` styled the same as the existing `"No tabs open"` message.
If the query is empty → preserve the existing behavior (pass all pane IDs, no change).
**Add a helper function:**
```rust
fn pane_matches_query(props: &PaneProps<'_>, query_lower: &str) -> bool {
props.title.to_lowercase().contains(query_lower)
|| props.subtitle.to_lowercase().contains(query_lower)
|| props.kind_label.to_lowercase().contains(query_lower)
|| props.typed.badge().map_or(false, |b| b.to_lowercase().contains(query_lower))
}
```
The caller lowercases the query once before the loop: `let query_lower = state.search_query.to_lowercase();`. This avoids re-allocating the lowercased query string for every pane check. Each pane field is still lowercased per check; that is fine given that pane titles are short strings and tab counts are in the tens to low hundreds. No caching is needed at this scale.
`PaneProps` already aggregates `title`, `subtitle`, `kind_label`, and `badge` — no need to reach into lower-level types.
### 4.4 Update `render_tab_group` to accept a filtered pane list
Change the signature to accept an optional filtered pane ID list:
```rust
fn render_tab_group(
state: &VerticalTabsPanelState,
workspace: &Workspace,
tab_index: usize,
tab: &TabData,
filtered_pane_ids: Option<&[PaneId]>, // None = render all
app: &AppContext,
) -> Box<dyn Element>
```
Inside the function, where `visible_pane_ids` is currently read from `pane_group.visible_pane_ids()`, replace with the filtered list when `filtered_pane_ids` is `Some`. The group header always renders when this function is called (skipping was already handled in `render_groups`).
### 4.5 Filter-aware tab cycling
In `activate_next_tab` and `activate_prev_tab` (view.rs lines 74817497), when `self.vertical_tabs_search_query` is non-empty, compute the set of tab indices that have at least one matching pane, then find the next/previous in that set (with wrap-around) relative to `self.active_tab_index`.
```rust
pub fn activate_next_tab(&mut self, ctx: &mut ViewContext<Self>) {
if self.vertical_tabs_panel.search_query.is_empty() {
// existing logic
let index = if self.active_tab_index + 1 < self.tabs.len() {
self.active_tab_index + 1
} else {
0
};
self.activate_tab(index, ctx);
} else {
let matching: Vec<usize> = self.matching_tab_indices(ctx);
if let Some(next) = next_in_cycle(&matching, self.active_tab_index) {
self.activate_tab(next, ctx);
}
}
}
```
Add a private helper:
```rust
fn matching_tab_indices(&self, ctx: &AppContext) -> Vec<usize> {
// returns tab indices (in original order) where ≥1 pane matches the query
}
```
The `next_in_cycle` / `prev_in_cycle` helpers find the next/previous element in a sorted index list relative to a current value, wrapping around.
### 4.6 Control bar visual changes
In `render_control_bar` (line 209227), change the `search_bar` container:
- Remove `.with_background(internal_colors::fg_overlay_1(theme))`.
- Remove `.with_corner_radius(...)`.
- Remove (or simplify) the fixed `with_padding(Padding::uniform(4.).with_left(8.).with_right(8.))` inner padding — replace with minimal padding that aligns the icon visually without a box.
In the outer `Container` (line 232242), change its padding to add 12px left and right:
```rust
.with_padding(
Padding::uniform(CONTROL_BAR_VERTICAL_PADDING)
.with_left(GROUP_HORIZONTAL_PADDING)
.with_right(GROUP_HORIZONTAL_PADDING),
)
```
Remove the `SEARCH_BAR_HEIGHT` constraint on the search bar's `ConstrainedBox` if it conflicts with the new unboxed layout, or keep it for consistent height — check against the Figma mock.
## 5. End-to-End Flow
1. User types "rust" into the search bar.
2. `EditorView` emits `EditorEvent::Edited(_)`.
3. The subscription in `vertical_tabs_search_input` fires: `workspace.vertical_tabs_search_query = "rust"`, then `ctx.notify()`.
4. The workspace re-renders. `render_groups` reads `"rust"` from `workspace.vertical_tabs_search_query`.
5. For each tab group, `visible_pane_ids()` is retrieved, each pane is tested via `pane_matches_query`, and the filtered ID list is passed to `render_tab_group`. Groups with no matches are skipped.
6. User presses next-tab shortcut. `activate_next_tab` reads the non-empty query, computes `matching_tab_indices`, and steps to the next matching tab index, skipping groups with no matches.
7. User presses Escape. The `Escape` subscription clears `vertical_tabs_search_query` and calls `focus_active_tab`. The workspace re-renders with the full unfiltered list.
## 6. Risks and Mitigations
- **Performance**: `pane_matches_query` runs on every render for each visible pane. For realistic Warp usage (tens to low hundreds of tabs), this is O(n) with cheap string operations and is not a concern. The query is lowercased once before the loop; pane fields are lowercased per check on short strings. No caching is needed unless profiling shows otherwise.
- **`PaneProps::new` returns `Option`**: The filter must handle the `None` case (pane no longer exists) gracefully — consistent with how the existing render loop handles it via `continue`.
- **Collapse state**: Collapsed tab groups still participate in filtering. A group whose header is collapsed but whose panes match should be shown with its header visible (collapsed state preserved). The group header is always rendered when `render_tab_group` is called; only the pane rows are hidden when collapsed. This is unchanged behavior.
- **Query not cleared on new tab**: Creating a new tab appends to `self.tabs`; the query remains. The new tab group will appear only if one of its panes matches the query (it likely won't until it has content), which is acceptable. No special handling needed.
- **Escape event double-clear**: Subscribing to `Escape` in two places (old handler + new query-clear) needs to be merged into one subscription to avoid double-firing. Merge both effects into the single `Escape` handler.
## 7. Testing and Validation
- **Manual**: Type a partial query that matches only a subset of panes — confirm only matching rows and their group headers are visible.
- **Manual**: Type a query that matches no panes — confirm the empty state message appears.
- **Manual**: Clear the query — confirm the full list is restored exactly.
- **Manual**: Press Escape — confirm the query clears, the full list restores, and focus returns to the active pane.
- **Manual**: With a filter active, use next/prev tab shortcuts — confirm navigation lands only on tab groups with matching panes.
- **Manual**: Collapse a tab group that has matching panes, then search — confirm the group header is visible and collapsed (not omitted).
- **Visual**: Screenshot the control bar; confirm no background or border on the search input, and left/right padding aligns with pane row text.
- **Regression**: Confirm next/prev tab cycling without a query is unchanged (all tabs cycle in order).
## 8. Follow-ups
- Highlighted matched text within pane rows (out of scope for this iteration).
- Keyboard navigation through filtered results via arrow keys (out of scope).
- Fuzzy or ranked matching if substring search proves insufficient.
- Persisting the query across app restarts (currently out of scope per PRODUCT.md).
+165
View File
@@ -0,0 +1,165 @@
# APP-3656: Vertical Tabs Panel — Compact Mode + View Toggle
## Summary
Add a compact display mode for the vertical tabs panel and a settings icon button that opens a popup to switch between compact and expanded views. In compact mode, each pane is rendered as a single-line row (icon + title) instead of the current multi-line card layout.
## Problem
The current expanded pane rows show rich detail (working directory, branch, agent status, diff stats, PR badge) and occupy significant vertical space. When a user has many tabs and panes, the panel requires heavy scrolling. Users need a denser view that lets them quickly scan and switch between panes without scrolling past multiple lines of metadata per item.
## Goals
- Let users switch the vertical tabs panel between a dense compact view and the current detailed expanded view.
- Persist the user's preference across sessions.
- Add a control-bar icon button that opens a settings popup for toggling the view mode.
## Non-goals
- **Group-by**: The settings popup in the Figma mocks also contains "Group panes by" options (Tab, Directory/Environment, Branch, Status). These are out of scope for this ticket.
- **Compact group headers**: Group headers remain unchanged in both modes. Iterating on header layout is a separate concern.
- **Search functionality**: The search input in the control bar remains inert (per APP-3648).
## Figma / design references
- Compact view: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7079-25549&m=dev
- Compact view (settings button active): https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7079-24535&m=dev
- Expanded view + settings popup: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7103-34398&m=dev
**Intentional deviation from mocks**: In compact mode, non-agent terminal panes display the **terminal title** (e.g. process name, user-set title) instead of the working directory shown in the mock.
## User experience
### View modes
The vertical tabs panel supports two view modes:
- **Expanded** (default, current behavior): Multi-line pane rows with full metadata (working directory, git branch, conversation status, agent badge, diff stats, PR badge). This is the existing layout, unchanged.
- **Compact**: Single-line pane rows with just an icon and a title string. Described in detail below.
### Setting
A new user setting (`VerticalTabsViewMode`) controls which mode is active. It is a synced setting (cloud-persisted) with two variants: `Compact` and `Expanded`. Default is `Expanded`.
### Settings icon button
A new icon button is added to the existing control bar in the vertical tabs panel header, to the left of the existing Configs button and Plus button.
- **Icon**: `settings-04` (a sliders/filter icon, per the Figma mock).
- **Size**: 16×16 icon in a 20×20 hit target with 2px padding, matching other icon buttons in the control bar.
- **Behavior**: Clicking the button toggles the settings popup open/closed.
- **Active state**: When the popup is open, the button has a highlighted background (`fg_overlay_3`), matching the active state shown in mock 2.
- **Tooltip**: On hover (when popup is closed), show "View options".
### Settings popup
The popup appears anchored below the settings icon button.
- **Contents**: A segmented control with exactly two segments:
- **Left segment**: Hamburger/list icon (`menu-01`), representing compact mode.
- **Right segment**: Grid icon (`grid-01`), representing expanded mode.
- The segment corresponding to the current view mode is visually selected (highlighted background).
- Clicking a segment immediately switches the view mode setting and the panel re-renders.
- **Dismiss**: The popup closes when the user clicks outside it, presses Escape, or clicks the settings icon button again.
- **Positioning**: Anchored to the bottom edge of the settings button, left-aligned with it. If the popup would clip outside the window, reposition within window bounds.
- **Style**: Rounded container with border (`neutral_4`), subtle background, and drop shadow, matching the existing option menu pattern.
### Compact pane rows
In compact mode, each pane row within a tab group is a single-line element:
```
[icon 16×16] [4px gap] [title text, 12px, single line, ellipsis truncation]
```
With horizontal padding (12px left and right) and vertical padding (8px top and bottom) per row. Rows have 4px corner radius, consistent with the expanded layout.
#### Per-pane-type content
**Terminal pane (non-agent)**:
- Icon: Terminal icon (same as expanded tertiary line).
- Title: The terminal title from the shell (e.g. the running process name or user-set title). This intentionally differs from the mock, which shows the working directory.
**Terminal pane (agent session)**:
- Icon: Conversation status icon (the colored status badge — running, stopped, completed, etc.) at 16×16.
- Title: The conversation display title (e.g. "Refactor the button component to use..."). Truncated with ellipsis if it overflows.
**Terminal pane (ambient agent)**:
- Icon: `OzCloud` icon.
- Title: The conversation display title if available, otherwise the terminal title.
**Code pane**:
- Icon: Code file icon (language-specific if available, falling back to generic code icon).
- Title: The file name/path as shown in the pane title.
**Other panes** (Notebook, Workflow, Settings, Rules, Plan, MCP Server, etc.):
- Icon: The type-specific icon (same icon used in the expanded view's kind badge).
- Title: The pane configuration title.
#### Split panes (multiple visible panes in one tab group)
Each visible pane in a tab group gets its own single-line compact row, same as in expanded mode. There is no combined-row or "(+N)" treatment — the group collapse/expand chevron and the pane count label in the group header already communicate how many panes exist.
#### Row interactions
All interactions are identical to the expanded view:
- **Click**: Focus the pane (`WorkspaceAction::FocusPane`).
- **Right-click**: Open the tab right-click context menu.
- **Hover**: Highlight with `fg_overlay_1` background (or `fg_overlay_2` if selected). Cursor changes to pointing hand.
- **Selected state**: The focused pane in the active tab has a `fg_overlay_2` background and a 1px `fg_overlay_2` border.
- **Drag**: Pane rows are not individually draggable (only entire tab groups are draggable, unchanged).
#### Row indicators
- **Unsaved changes indicator**: For code panes with unsaved changes, show a small filled circle icon (`circle-filled`, 16×16) to the right of the title text, same as the expanded view's badge. This is right-aligned in the row.
#### Tab color support
Per-pane and per-group tab colors work the same as in expanded mode. In compact mode, the color applies as a background tint on the pane row (at `TAB_COLOR_OPACITY` / `TAB_COLOR_HOVER_OPACITY`).
### Group headers
Group headers are unchanged between compact and expanded modes. They continue to show:
- Left: Group title (uppercase, 10px, sub-text color)
- Right: Pane count label + collapse/expand chevron
The collapse/expand behavior for groups works the same in both modes. Collapsing a group hides all its pane rows (whether compact or expanded).
### Transitions
Switching between compact and expanded mode re-renders all pane rows immediately. No animation is required. The scroll position should be preserved as closely as possible (the scroll state handle is shared).
## Success criteria
1. A `VerticalTabsViewMode` setting with `Compact` and `Expanded` variants is persisted as a synced cloud setting.
2. The settings icon button appears in the control bar between the search input and the Configs button.
3. Clicking the settings icon button opens a popup with a two-segment control (compact/expanded). Clicking a segment switches the mode immediately.
4. The popup closes on outside click, Escape, or re-clicking the settings button.
5. In compact mode, every pane type renders as a single-line row with the correct icon and title per the rules above.
6. Non-agent terminal panes in compact mode show the terminal title (not the working directory).
7. Agent terminal panes in compact mode show the conversation status icon and conversation title.
8. Code panes show the unsaved-changes circle indicator when applicable.
9. Tab-color tinting works correctly on compact rows.
10. Group headers, collapse/expand, drag-to-reorder tabs, and right-click context menus all work unchanged in compact mode.
11. Switching modes preserves the scroll position and does not reset collapsed/expanded group states.
12. The setting defaults to `Expanded`, matching current behavior — no user-visible change until the user explicitly switches.
## Validation
- **Visual inspection**: Toggle between compact and expanded modes. Verify that compact rows are single-line, icons are correct per pane type, and text truncates with ellipsis.
- **Terminal title vs pwd**: Open a non-agent terminal, set a custom title or run a process, switch to compact mode, and verify the terminal title (not the pwd) is shown.
- **Agent panes**: Start an agent conversation, switch to compact mode, verify the status icon and conversation title are shown.
- **Unsaved code indicator**: Open a code file, make an unsaved edit, switch to compact mode, and verify the filled circle indicator appears.
- **Tab colors**: Assign a tab color, switch to compact mode, verify the color tint is visible on the compact row.
- **Settings popup**: Click the settings icon, verify the popup appears anchored below it with the correct segment selected. Click the other segment, verify the mode switches. Click outside, verify the popup closes.
- **Persistence**: Switch to compact mode, quit and relaunch, verify the panel opens in compact mode.
- **Narrow panel**: Resize the panel to minimum width (200px) in compact mode. Verify rows truncate gracefully and the control bar remains usable.
- **Group collapse**: Collapse a group in compact mode, switch to expanded, verify it remains collapsed.
## Open questions
None — all resolved:
1. ~~Should the compact view hide the group pane count label?~~ No. Keep it unchanged.
2. ~~Keyboard shortcut to toggle compact/expanded?~~ Out of scope for now; to be added later.
3. ~~Where does the compact/expanded toggle live when group-by is added?~~ It stays inside the same popup, per the Figma mock.
+293
View File
@@ -0,0 +1,293 @@
# APP-3656: Tech Spec — Compact Mode + View Toggle
## Problem
The vertical tabs panel currently renders every pane as a multi-line card (24 lines each). With many tabs/panes the panel requires heavy scrolling. The product spec (APP-3656 PRODUCT.md) defines a compact single-line rendering mode and a control-bar settings popup to toggle between compact and expanded views. This spec translates that behavior into concrete implementation changes.
## Relevant code
- `app/src/workspace/view/vertical_tabs.rs` — all rendering for the vertical tabs panel; `VerticalTabsPanelState`, `render_control_bar`, `render_pane_row`, `render_terminal_row_content`, `TypedPane`, `PaneProps`
- `app/src/workspace/tab_settings.rs``TabSettings` group and `define_settings_group!` / `implement_setting_for_enum!` macros for persisted settings
- `app/src/workspace/action.rs``WorkspaceAction` enum for dispatching UI events
- `app/src/workspace/view.rs``Workspace` struct fields, `show_new_session_dropdown_menu` pattern for popup overlays
- `ui/src/ui_components/segmented_control.rs``SegmentedControl<T>` view, `RenderableOptionConfig`, `SegmentedControlEvent`
- `warp_core/src/ui/icons.rs``Icon` enum and SVG path mappings
- `app/src/ai/conversation_status_ui.rs``render_status_element` for agent status badges
- `app/src/terminal/view/tab_metadata.rs``terminal_title_from_shell()`, `display_working_directory()`, `selected_conversation_display_title()`
- `app/src/terminal/view/pane_impl.rs (926-973)``is_ambient_agent_session()`, `selected_conversation_status()`, `selected_conversation_display_title()`
## Current state
The panel layout is:
```
Resizable (drag-right edge)
└─ Container (background + right border)
└─ Flex::column
├─ render_control_bar (search input + new-tab split button)
└─ Shrinkable(ClippedScrollable::vertical(tab groups))
```
`render_pane_row` dispatches to either `render_terminal_row_content` (for terminal panes, producing 3 lines: primary dir+branch, secondary agent/title, tertiary kind+badges) or an inline multi-line layout for non-terminal panes (title, subtitle, kind badge row). There is no concept of a view mode — every row is always expanded.
The `SegmentedControl<T>` view is fully reusable. It takes a vec of options, a render config callback, and emits `SegmentedControlEvent::OptionSelected(T)` on segment click. Icon-only segments are supported via `RenderableOptionConfig` with `label: None` and a populated `icon_path`.
## Proposed changes
### 1. Add `VerticalTabsViewMode` setting
In `tab_settings.rs`, add an enum and wire it into `TabSettings`:
```rust
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsViewMode {
Compact,
#[default]
Expanded,
}
```
Register with `implement_setting_for_enum!` using `SyncToCloud::Globally(RespectUserSyncSetting::Yes)` and hierarchy `"appearance.tabs"`. Add `vertical_tabs_view_mode: VerticalTabsViewMode` to the `define_settings_group!` block.
### 2. Add icon variants
The segmented control needs two icons not currently in the `Icon` enum:
- `Menu` already exists (`layout-left.svg`) but maps to the sidebar icon, not a hamburger/list icon. Verify `bundled/svg/` for a `menu-01.svg` or similar. If absent, add `ListMenu``bundled/svg/list-menu.svg` (or the closest available SVG). The Figma mock's `menu-01` icon is a standard 3-line hamburger.
- `Grid` already exists → `bundled/svg/grid.svg`. Should work for the expanded segment.
- For the settings button, `Settings``bundled/svg/settings.svg` already exists. Check whether the Figma `settings-04` sliders icon matches visually; if not, add a `Settings04` variant mapping to a new SVG.
If new SVGs are needed, add them to `resources/bundled/svg/` and extend the `Icon` enum + `From<Icon> for &'static str` match.
### 3. Add state to `VerticalTabsPanelState`
```rust
pub(super) struct VerticalTabsPanelState {
// ... existing fields ...
settings_button_mouse_state: MouseStateHandle,
settings_popup_mouse_state: MouseStateHandle,
show_settings_popup: bool,
}
```
Initialize all with `Default::default()` / `false`.
### 4. Add `WorkspaceAction` variants
```rust
pub enum WorkspaceAction {
// ... existing ...
ToggleVerticalTabsSettingsPopup,
SetVerticalTabsViewMode(VerticalTabsViewMode),
}
```
`ToggleVerticalTabsSettingsPopup` toggles `show_settings_popup` on the panel state and calls `ctx.notify()`.
`SetVerticalTabsViewMode` writes the new value through `TabSettings` (same as other setting mutations). Both actions should be listed in the `should_save_app_state_on_action` match as `false` (no workspace state save needed).
### 5. Update `render_control_bar`
Insert the settings button between the search bar and the new-tab button. Layout becomes:
```
Flex::row [search_bar (Shrinkable)] [settings_button] [new_tab_button]
```
The settings button:
- Uses `Hoverable` wrapping an icon button with `WarpIcon::Settings` (or `Settings04`) at 16×16 in a 20×20 container with 2px padding.
- Background is `fg_overlay_3` when `state.show_settings_popup` is true, `fg_overlay_2` on hover, transparent otherwise.
- `on_click` dispatches `WorkspaceAction::ToggleVerticalTabsSettingsPopup`.
- Wrap in a `Stack` to position a tooltip ("View options") as an overlay when hovered and popup is closed.
- Wrap the whole button in a `SavePosition` with ID `"vertical_tabs_settings_button"` for popup anchoring.
### 6. Render the settings popup
Inside `render_vertical_tabs_panel`, after building the panel content `Flex::column`, wrap the result in a `Stack`. When `state.show_settings_popup` is true, add a positioned overlay child:
```rust
if state.show_settings_popup {
let popup = render_settings_popup(state, app);
stack.add_positioned_overlay_child(
popup,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
```
**Alternative (simpler)**: Rather than making the popup a positioned overlay on the Stack, render it inline as an absolutely-positioned element anchored to the settings button's `SavePosition`. Both approaches work; use whichever is more consistent with existing popup patterns in the file.
`render_settings_popup` builds a `Container` styled as a popover (border `neutral_4`, background with subtle overlay, corner radius 6px, drop shadow). Contents:
```
Container (popup styling)
└─ Padding(16px horizontal, 8px vertical)
└─ SegmentedControl (rendered inline via ChildView)
```
The `SegmentedControl<VerticalTabsViewMode>` is **not** stored as a `ViewHandle` — it is too lightweight for that. Instead, build the segmented control UI manually using the same pattern: two `Hoverable` icon buttons inside a rounded container, with the active segment highlighted. This avoids needing a persistent `ViewHandle` on `VerticalTabsPanelState` and keeps the popup self-contained.
Concretely, `render_settings_popup` returns:
```rust
fn render_settings_popup(state: &VerticalTabsPanelState, app: &AppContext) -> Box<dyn Element> {
let current_mode = *TabSettings::as_ref(app).vertical_tabs_view_mode;
// Build two icon buttons (compact / expanded), highlight the active one
// on_click dispatches WorkspaceAction::SetVerticalTabsViewMode(...)
}
```
**Dismiss handling**: The popup should close on:
- Outside click: Wrap the popup's overlay in a dismiss layer (same pattern as existing menus — e.g., a transparent full-window Hoverable behind the popup that dispatches close on click).
- Escape: Add a keybinding handler or check focus loss.
- Re-click on settings button: Already handled by `ToggleVerticalTabsSettingsPopup`.
### 7. Add `render_compact_pane_row`
New function in `vertical_tabs.rs`:
```rust
fn render_compact_pane_row(props: PaneProps<'_>, app: &AppContext) -> Box<dyn Element>
```
This function shares the same `PaneProps`, `Hoverable` wrapper, click handler, background/border logic, and cursor as `render_pane_row`. The difference is only the content layout.
**Approach**: Extract the shared interaction wrapper into a helper, then call either compact or expanded content rendering inside it. Specifically:
```rust
fn render_pane_row_wrapper(
props: PaneProps<'_>,
is_compact: bool,
app: &AppContext,
) -> Box<dyn Element> {
// ... Hoverable + click + right-click + cursor + background logic (unchanged) ...
let content = if is_compact {
render_compact_content(&props, app)
} else {
render_expanded_content(&props, app) // current logic extracted
};
// ... container with padding, border, corner radius ...
}
```
**Compact content layout**:
```
Flex::row (CrossAxisAlignment::Center, spacing 4px)
├─ [icon 16×16] // type-dependent
├─ [title text, 12px] // Shrinkable, single line, ellipsis
└─ [indicator 16×16]? // optional, right-aligned (unsaved circle for code panes)
```
**Compact padding**: 8px vertical, 12px horizontal (vs 12px uniform in expanded). This matches the Figma mock's `py-8 px-12`.
**Per-type icon and title logic**:
For terminal panes, read from `TerminalView`:
```rust
let (icon, title) = if let Some(view_handle) = terminal_view_handle.as_ref() {
let tv: &TerminalView = view_handle.as_ref(app);
let conversation_title = tv.selected_conversation_display_title(app);
let conversation_status = tv.selected_conversation_status(app);
let is_ambient = tv.is_ambient_agent_session(app);
if let Some(conv_title) = conversation_title {
// Agent session: status icon + conversation title
let icon_element = if let Some(status) = conversation_status {
render_status_element(&status, 12., appearance)
} else if is_ambient {
WarpIcon::OzCloud icon element
} else {
WarpIcon::Oz icon element
};
(icon_element, conv_title)
} else {
// Non-agent terminal: terminal icon + terminal title (NOT pwd)
let terminal_title = tv.terminal_title_from_shell();
(WarpIcon::Terminal icon element, terminal_title)
}
} else {
// Non-terminal pane: type icon + pane title (already in props.title)
(typed.icon() icon element, props.title.clone())
};
```
For the unsaved indicator (code panes only): append a `CircleFilled` icon (16×16, sub-text color) to the right of the row when `typed.badge(app).is_some()`.
### 8. Integrate view mode into `render_pane_row` call sites
In `render_tab_group`, where `render_pane_row(pane_props, app)` is called inside the rows loop, read the current view mode and branch:
```rust
let view_mode = *TabSettings::as_ref(app).vertical_tabs_view_mode;
let row = match view_mode {
VerticalTabsViewMode::Compact => render_compact_pane_row(pane_props, app),
VerticalTabsViewMode::Expanded => render_pane_row(pane_props, app),
};
rows.add_child(row);
```
Or use the combined `render_pane_row_wrapper` approach from section 7.
### 9. Handle `WorkspaceAction` in `view.rs`
In the `WorkspaceAction` match in `handle_action`:
```rust
WorkspaceAction::ToggleVerticalTabsSettingsPopup => {
self.vertical_tabs_panel.show_settings_popup =
!self.vertical_tabs_panel.show_settings_popup;
ctx.notify();
}
WorkspaceAction::SetVerticalTabsViewMode(mode) => {
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
settings.vertical_tabs_view_mode.set_value(mode, ctx);
});
ctx.notify();
}
```
## End-to-end flow
1. User clicks the settings icon button in the vertical tabs control bar.
2. `ToggleVerticalTabsSettingsPopup` is dispatched → `show_settings_popup` flips to `true` → panel re-renders.
3. The panel's `Stack` now includes the popup overlay anchored below the button.
4. User clicks the compact segment → `SetVerticalTabsViewMode(Compact)` dispatches → `TabSettings` writes the new value → settings sync triggers → panel re-renders.
5. `render_tab_group` reads the updated `VerticalTabsViewMode` and calls `render_compact_pane_row` for each pane.
6. Each compact row renders: icon + title in a single line.
7. Popup auto-closes because the click dismisses it (or the user clicks outside / presses Escape).
8. On next launch, the setting is loaded from the synced settings store, and the panel renders in compact mode immediately.
## Risks and mitigations
**Icon availability**: The Figma mock references `settings-04`, `menu-01`, and `grid-01` icons. The existing `Icon::Menu` maps to `layout-left.svg` (sidebar icon), not a hamburger. Mitigation: audit `resources/bundled/svg/` for matching SVGs; add new variants if needed. If exact icons are unavailable, use the closest existing ones (`Settings`, `Menu`, `Grid`) and iterate visually.
**Segmented control as ViewHandle**: Creating a `ViewHandle<SegmentedControl<VerticalTabsViewMode>>` requires storing it on `VerticalTabsPanelState` and wiring up subscriptions. This adds complexity for a two-button toggle. Mitigation: build the toggle inline as two `Hoverable` icon buttons inside a styled container, dispatching `WorkspaceAction::SetVerticalTabsViewMode` directly. This avoids the `ViewHandle` lifecycle overhead.
**Popup dismiss on outside click**: The existing popup patterns in the workspace (e.g., `show_new_session_dropdown_menu`) rely on `Menu` views that handle their own focus/dismiss. Our popup is simpler (no menu items). Mitigation: render a full-window transparent `Hoverable` behind the popup that dispatches `ToggleVerticalTabsSettingsPopup` on click. This is the same "click-away backdrop" pattern used elsewhere.
**Compact row height consistency**: Different pane types produce different icon heights (status badges have padding/background, plain icons don't). Mitigation: use `ConstrainedBox` to fix all icons to 16×16 and fix the row height via `ConstrainedBox::with_height` or consistent padding.
## Testing and validation
- **Visual verification**: Build and run with `cargo run`, open several tabs with different pane types (terminal, code, agent, settings, notebook), toggle between compact and expanded. Verify single-line rendering and correct icons per pane type.
- **Setting persistence**: Switch to compact, restart (`cargo run` again), verify the panel starts in compact mode.
- **Popup behavior**: Click the settings icon, verify popup appears below it. Click outside, verify it closes. Click the icon again, verify it toggles.
- **Edge cases**:
- Empty tab group → "No tabs open" message should display in both modes.
- Collapsed groups → remain collapsed across mode switches.
- Minimum panel width (200px) → compact rows truncate gracefully.
- Tab colors → tint renders correctly on compact rows.
## Follow-ups
- **Group-by options**: The popup will eventually include "Group panes by" options (Tab, Directory/Environment, Branch, Status) above the segmented control, per the Figma mock. The popup container is designed to accommodate additional content above the toggle.
- **Keyboard shortcut**: A keybinding to toggle compact/expanded mode can be added later as a new action binding.
- **Search functionality**: The search input remains inert. When implemented, it should filter in both compact and expanded modes.
- **Icon audit**: Once the feature is visually reviewed, confirm the chosen icons match the Figma mock and swap SVGs if needed.
+76
View File
@@ -0,0 +1,76 @@
# Product Spec: Plugin Update Flow
## Problem
We're releasing a new version of the Warp notification plugin for Claude Code (v2.0.0). Existing users on v1.1.0 won't automatically receive the update because Claude Code's plugin update system is unreliable. We need Warp to detect outdated plugin versions and prompt users to update.
## Current Chip Behavior
Today, a green chip appears in the CLI agent footer when the plugin isn't installed:
- Local session → chip auto-installs on click
- SSH session or prior install failure → chip opens a modal with manual steps
- Plugin is active (connected) or installed → chip is hidden
- User dismissed the chip → chip is hidden
There is no concept of an "outdated" plugin. A user on v1.1.0 will never see a prompt to update.
## New Behavior: Update Chip
When the plugin is installed but on an old version, show an update chip:
- Label: "Update Warp plugin"
- Tooltip: "A new version of the Warp plugin is available"
- Same green styling and dismiss (X) button as the install chip
- On local sessions: clicking runs the update automatically
- On SSH or after a failed auto-update: clicking opens a modal with manual update steps
The update chip replaces the install chip in the same position — they never appear simultaneously.
## How Version Detection Works
The plugin reports its own version via a `plugin_version` field in the `SessionStart` event when it connects. Warp compares this against a minimum required version. This works identically for local and remote sessions — no filesystem check needed for update detection.
A missing `plugin_version` (from a plugin that predates version reporting) is treated as outdated.
## When Each Chip Appears
1. Plugin connected, version >= minimum → **no chip**
2. Plugin connected, version < minimum or not reported → **update chip** (new)
3. Plugin not connected, not installed locally → **install chip** (existing behavior)
4. Plugin not connected, installed locally, on-disk version outdated → **update chip** (filesystem fallback for plugins too old to send structured events)
5. Plugin not connected, installed locally, on-disk version current → **no chip** (waiting for connection)
6. Remote session, no listener → **install chip** (can't check filesystem remotely)
7. Just completed install/update → **no chip** (assume current version until next `SessionStart`)
8. Chip dismissed for this version → **no chip**
## Auto-Update (Local Sessions)
On click, Warp runs `marketplace add` (to refresh the local clone) + `plugin update` via the CLI. Same UX pattern as auto-install: persistent toast while running, success/failure toast on completion. A post-update sanity check verifies the on-disk version actually changed.
On success: "Warp plugin updated. Please run /reload-plugins to activate."
On failure: transition to manual mode (modal) for the rest of the session.
## Manual Update (SSH / Failed Auto-Update)
Opens a modal with step-by-step update instructions. Unlike the install modal (which uses in-session `/plugin` slash commands), the update modal uses CLI commands (`claude plugin ...`) because there is no working in-session slash command for updating plugins. Users are instructed to run the commands in a separate terminal, or inside Claude Code by typing `!` before each command.
## Dismiss Behavior
The install chip and update chip have **independent** dismiss state:
- Dismissing the install chip hides the install chip (existing boolean behavior, unchanged)
- Dismissing the update chip hides the update chip for the current minimum version
- If we later release a newer version (e.g., v3.0.0), the update chip reappears
This means tracking *which version* was dismissed for the update chip, not just whether it was dismissed.
## Edge Cases
- **User updates manually in Claude Code:** The listener reconnects with a new `plugin_version`, chip disappears automatically.
- **Plugin doesn't report version:** Treated as outdated — these are pre-versioning builds that definitely need an update.
- **Plugin too old to send structured events:** Falls back to on-disk version check. If the on-disk version is below minimum, the update chip appears even without a listener.
- **Just completed install/update (mid-session):** The session's `plugin_version` is set to `MINIMUM_PLUGIN_VERSION` to suppress the update chip until the user runs `/reload-plugins` and the plugin sends a real `SessionStart`.
- **Multiple tabs:** All tabs see the same session state. Update failure tracking is shared across tabs.
- **Plugin connected over SSH:** Version detection works the same way — the plugin reports its own version regardless of where it's running.
- **Old plugin over SSH (pre-structured-events):** The old public plugin (v1.1.0) doesn't send structured events, so no listener connects and we can't check the remote filesystem. The install chip shows instead of the update chip. This is functionally correct — the install instructions work to upgrade — but the label says "install" rather than "update".
+243
View File
@@ -0,0 +1,243 @@
# Tech Spec: Plugin Update Flow
See `specs/APP-3661/PRODUCT.md` for the product spec.
## 1. Trait Changes
`plugin_manager/mod.rs`
Keep `is_installed() -> bool` on the trait (filesystem check: is the plugin key present in `installed_plugins.json`?). Add `update()`, `update_instructions()`, and `needs_update()` methods:
```rust
trait CliAgentPluginManager: Send + Sync {
fn is_installed(&self) -> bool;
fn needs_update(&self) -> bool;
async fn install(&self) -> Result<(), PluginInstallError>;
async fn update(&self) -> Result<(), PluginInstallError>;
fn install_instructions(&self) -> &'static PluginInstructions;
fn update_instructions(&self) -> &'static PluginInstructions;
}
```
The **update** chip is primarily driven by `plugin_version` reported in the `SessionStart` event (see section 3b). As a fallback for plugins too old to send structured events, `needs_update()` checks the on-disk version. Also add `PluginModalKind { Install, Update }` enum for event plumbing.
## 2. Renamed Instructions Struct
`plugin_manager/mod.rs`
Rename `PluginInstallInstructions``PluginInstructions` and `PluginInstallStep``PluginInstructionStep`:
```rust
pub(crate) struct PluginInstructionStep {
pub description: &'static str,
pub command: &'static str,
}
pub(crate) struct PluginInstructions {
pub title: &'static str,
pub subtitle: &'static str,
pub steps: &'static [PluginInstructionStep],
pub success_toast: &'static str,
}
```
## 3. Claude Implementation
`plugin_manager/claude.rs`
### is_installed()
Unchanged — reads `installed_plugins.json`, returns true if the `PLUGIN_KEY` entry exists and is non-empty.
### MINIMUM_PLUGIN_VERSION
New `pub(crate)` `&str` constant (initially `"2.0.0"`). Exported so the footer can compare against it.
Must be kept in sync with the plugin version in `warpdotdev/claude-code-warp`. Add a comment on the constant pointing to the plugin repo, and a reciprocal comment in the plugin repo's README.
### compare_versions()
New `pub(crate)` helper. Compares two `X.Y.Z` version strings using simple integer comparison. Unparseable components treated as 0.
### needs_update()
Checks the on-disk version in `installed_plugins.json`. Returns true if the installed version is below `MINIMUM_PLUGIN_VERSION`, or if the entry exists but has no version field (very old plugin). Used as a fallback when no listener has connected (e.g., the plugin is too old to send structured events).
### update()
Runs `marketplace remove` + `marketplace add` (to ensure the local clone is fresh) + `plugin install` (to reinstall the plugin from the freshly added marketplace). We use `plugin install` instead of `plugin update` because `marketplace remove` unlinks the plugin, so `plugin update` would fail with `Plugin "warp" is not installed`.
As an internal sanity check, re-reads `installed_plugins.json` and checks the version. If still below minimum → returns `Err` with a message like "Plugin update did not take effect". This triggers the fallback to manual mode in the footer.
## 3b. Session Version Tracking
`cli_agent_sessions/mod.rs`
Add `plugin_version: Option<String>` to `CLIAgentSession`. Populated via two paths:
1. **`SessionStart` event path:** `register_listener()` now accepts `plugin_version` as a parameter, threaded from the `SessionStart` notification payload. This is the primary path.
2. **Mid-session install path:** `register_cli_agent_listener()` (called after install/update succeeds) sets `plugin_version` to `MINIMUM_PLUGIN_VERSION` to suppress the update chip until the user runs `/reload-plugins`.
Also set in `apply_event()` when the event type is `SessionStart` (for subsequent `SessionStart` events after the initial one).
This is the **authoritative signal** for whether the plugin is outdated. It works for both local and remote sessions because the plugin reports its own version. A `None` value means the plugin predates version reporting and is definitely outdated.
### update_instructions()
Returns a `&'static PluginInstructions` (via `LazyLock`) with:
- Title: "Update Warp Plugin for Claude Code"
- Subtitle: "Run the following commands in Claude Code by typing ! before each command, or in a separate terminal."
- Steps:
1. `claude plugin marketplace remove claude-code-warp`
2. `claude plugin marketplace add warpdotdev/claude-code-warp`
3. `claude plugin install warp@claude-code-warp`
4. Restart Claude Code to activate → `/exit`
- Success toast (auto-update): "Warp plugin updated. Please run /reload-plugins to activate." (the one-click flow registers the listener programmatically, so `/reload-plugins` suffices)
- Success toast (manual modal): tells user to restart Claude Code (manual installs require a full restart for hooks to fire)
Note: the update modal uses CLI commands (not in-session slash commands) because there is no working `/plugin update` slash command in Claude Code. The install modal continues to use slash commands since `/plugin install` works in-session.
## 4. Modal Changes
`workspace/view/plugin_install_modal.rs`
The modal becomes a generic instructions renderer. Replace `agent: Option<CLIAgent>` with `instructions: Option<&'static PluginInstructions>`. The `set_agent(agent)` method becomes `set_instructions(instructions: &'static PluginInstructions)` which stores the reference and resizes `step_code_handles` for the steps.
Copy button on each step copies the command to clipboard and shows a green success toast.
## 5. Footer Changes
`agent_input_footer/mod.rs`
### New buttons
Add two new `ActionButton` views (same `InstallPluginButtonTheme` and construction pattern as the existing install buttons):
- `update_plugin_button`: "Update Warp plugin", `Icon::Download`, dispatches `AgentInputFooterAction::UpdatePlugin`
- `update_instructions_button`: "Plugin update instructions", `Icon::Info`, dispatches `AgentInputFooterAction::ShowPluginInstructionsModal`
### Chip visibility and mode
The footer uses a `PluginChipKind` enum (`Install` / `Update`) returned by `plugin_chip_kind()`. The logic uses three layers of version detection:
**Install chip** (pre-connection, local only):
- No listener, local session, `is_installed()` returns false, install chip not dismissed → `PluginChipKind::Install`
- Same conditions as today (unchanged)
**Update chip** (post-connection, local and remote):
- Listener connected, `session.plugin_version` is `None` or < `MINIMUM_PLUGIN_VERSION``PluginChipKind::Update`
- (`None` means the plugin predates version reporting and definitely needs an update)
- Update chip dismissed for current minimum version → hidden
- Listener connected, `session.plugin_version` >= minimum → no chip
**Update chip** (pre-connection filesystem fallback, local only):
- No listener, `is_installed()` true, `needs_update()` true → `PluginChipKind::Update`
- Handles plugins too old to send structured events (no listener ever connects)
- Only works locally — for remote sessions with old plugins that don't send structured events, we fall through to the install chip since we can't check the remote filesystem
**No chip:**
- No listener, plugin installed on disk, on-disk version current → waiting for connection
- Notifications disabled
- Operation in progress
### Chip mode selection
The render method selects which button based on install-vs-update and auto-vs-manual:
- Install + auto → `install_plugin_button`
- Install + manual → `plugin_instructions_button` (install instructions modal)
- Update + auto → `update_plugin_button`
- Update + manual → `update_instructions_button` (update instructions modal)
Manual mode is triggered by: remote session, or prior auto-operation failure for this agent/host.
### Failure tracking
Reuse the existing `plugin_install_failures: HashSet<(CLIAgent, Option<String>)>` on `CLIAgentSessionsModel` — rename to `plugin_auto_failures`. This single set covers both install and update failures. This works because `PluginStatus` already determines which operation the chip shows; there's no scenario where install failures and update failures need to be distinguished (and the set resets each session anyway).
### handle_plugin_operation()
Extract a shared helper from `handle_install_plugin` that both install and update use. The shared logic: set `plugin_operation_in_progress`, show persistent toast, spawn the async operation, on success emit `PluginInstalled` event, on failure record in `plugin_auto_failures` and show error toast. Replace the two separate `plugin_install_in_progress`/`plugin_update_in_progress` bools with a single `plugin_operation_in_progress: bool` (install and update are mutually exclusive based on `PluginStatus`).
The only differences between install and update are: (a) which async fn to call (`manager.install()` vs `manager.update()`), (b) progress/success/error toast messages. All three are passed as `&str` parameters.
## 6. Settings
`settings/ai.rs`
Add a new setting for update chip dismissal:
```
plugin_chip_dismissed_for_version: PluginChipDismissedForVersion {
type: String,
default: "",
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Never,
hierarchy: "private",
}
```
When the user dismisses the update chip, store the current `MINIMUM_PLUGIN_VERSION`. In `should_show_plugin_chip`, compare the dismissed version against the current minimum — if dismissed version >= current minimum, hide; otherwise show.
## 7. Event Plumbing
Replace the existing `ShowPluginInstallModal(CLIAgent)` with a single `ShowPluginInstructionsModal(CLIAgent, PluginModalKind)` where `PluginModalKind { Install, Update }`. This avoids duplicating an event variant through every layer of the chain.
`AgentInputFooterEvent``Input::Event``TerminalView::Event``pane_group::Event` → Workspace handler
The workspace handler matches on the kind, calls the appropriate `install_instructions()` or `update_instructions()`, and passes the result to `modal.set_instructions(...)` before opening.
## 8. Testing
### Unit tests (`plugin_manager/claude_tests.rs`)
Existing `check_installed` tests remain valid (now testing `is_installed()`).
Add:
- `compare_versions` — covers equal, less-than, greater-than, different major/minor/patch, unparseable components
### Unit tests (`cli_agent_sessions/mod_tests.rs`)
Rename existing `plugin_install_failure` tests to `plugin_auto_failure` and verify the renamed set works identically. No new test logic needed — just a rename.
### Unit tests (`plugin_manager/mod_tests.rs`)
Add tests for the new trait methods:
- `claude_manager_returns_update_instructions` — verify `update_instructions()` returns non-empty steps
- `claude_manager_returns_install_instructions` — verify `install_instructions()` returns non-empty steps
### View tests (`terminal/view_test.rs`)
Using the existing `App::test` + `CLIAgentSessionsModel` pattern:
- `update_chip_shown_when_plugin_version_below_minimum` — set `session.plugin_version` to `"1.1.0"`, verify update chip shown
- `update_chip_shown_when_plugin_version_is_none` — listener connected but no `plugin_version`, verify update chip shown
- `no_chip_when_plugin_version_meets_minimum` — set `session.plugin_version` to `"2.0.0"`, verify no chip
- `update_chip_hidden_when_dismissed_for_current_version`
- `update_chip_shown_when_dismissed_for_older_version`
- `update_chip_and_install_chip_dismiss_are_independent`
### Integration tests
Add to `integration/tests/integration/ui_tests.rs`:
- `test_plugin_update_chip_appears_for_outdated_plugin` — start a Claude Code session (via OSC event injection), set up a fake `installed_plugins.json` with an old version, verify the "Update Warp plugin" chip renders in the footer
- `test_plugin_update_modal_opens` — same setup as above but in manual mode (inject a failure first), click the instructions chip, verify the modal opens with update steps
- `test_plugin_update_chip_dismiss_persists` — click dismiss on the update chip, verify it stays hidden, then bump `MINIMUM_PLUGIN_VERSION` concept (or re-render), verify it reappears for a new minimum
## 9. Files Changed
- **Modified:** `plugin_manager/mod.rs` — renamed structs, `update()` + `update_instructions()` + `needs_update()` on trait, `PluginModalKind` enum, `PluginChipKind` enum (in footer)
- **Modified:** `plugin_manager/claude.rs``update()` implementation, `MINIMUM_PLUGIN_VERSION` constant (pub(crate)), `compare_versions` (pub(crate)), update instructions `LazyLock`
- **Modified:** `cli_agent_sessions/mod.rs``plugin_version: Option<String>` on `CLIAgentSession`, populated from `SessionStart` event; rename `plugin_install_failures``plugin_auto_failures`
- **Modified:** `workspace/view/plugin_install_modal.rs` — generic instructions rendering via `set_instructions()`, copy-to-clipboard with success toast
- **Modified:** `agent_input_footer/mod.rs` — new buttons, chip visibility via `plugin_chip_kind()`, `handle_plugin_operation` helper, `plugin_operation_in_progress`
- **Modified:** `settings/ai.rs``plugin_chip_dismissed_for_version` setting
- **Modified:** `terminal/input.rs`, `terminal/view.rs`, `pane_group/mod.rs`, `pane_group/pane/terminal_pane.rs``ShowPluginInstructionsModal` event (replaces old install-only variant)
- **Modified:** `workspace/view.rs`, `workspace/mod.rs` — modal rename, instruction-kind dispatch
- **Modified:** `workspace/util.rs` — rename modal state field
+105
View File
@@ -0,0 +1,105 @@
# APP-3679: New Worktree Modal
Linear: [APP-3679](https://linear.app/warpdotdev/issue/APP-3679/modal-for-new-worktree-and-flow-for-saving-that-to-a-launch-config)
## Summary
Replace the "Create new tab config..." / "New worktree" menu items in both the horizontal and vertical tab bar menus with a "+ New Worktree" button that opens a GUI modal. The modal lets users select a repository, a base branch, and optionally auto-generate the worktree branch name. On submit, it writes a reusable worktree tab config TOML file to `~/.warp/tab_configs/` (which the filesystem watcher picks up for the menu), and immediately opens the worktree as a new tab.
## Problem
Creating a worktree tab config currently requires hand-authoring TOML. The "Create new tab config..." button opens a template file in an editor, which is a poor experience for the common worktree use case. Users need a quick, discoverable way to create reusable worktree configs from a modal — a "worktree factory."
## Goals
- Provide a modal-based flow for creating git worktrees from both the horizontal and vertical tab menus.
- Persist each created worktree config as a `.toml` file so it appears in the menu for future re-use.
- Open the resulting worktree tab immediately after creation.
- Support auto-generating unique worktree branch names when the user checks the autogenerate option.
## Non-goals
- Validating that the selected repository is a valid git repo before submission.
- Listing existing worktrees or providing worktree management beyond creation.
- Replacing the existing "Create new tab config..." flow for non-worktree configs (that still uses the TOML template).
- Running `git worktree` commands from within the modal itself (commands are written into the config and executed when the tab opens).
## Figma
- Modal: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7143-34685&m=dev
## User Experience
### Menu entry point
Both horizontal and vertical tab bar menus replace their last row item with:
- Label: "+ New Worktree"
- Icon: Plus
- Action: Opens the new worktree modal
In the horizontal tab bar, the item appears after any existing tab configs, separated by a `MenuItem::Separator`. In the vertical tab bar, the item appears in the existing "New worktree" position.
### Modal layout (matches Figma)
1. **Header**: "New worktree" with compact-pane close button (X / ESC).
2. **Body**:
- **Select repository**: Label "Select repository" + `RepoPicker` dropdown (filterable, with "+ Add new repo..." footer to open a folder picker).
- **Select branch**: Label "Select branch" + `BranchPicker` dropdown (filterable, auto-populates when a repo is selected).
- **Autogenerate checkbox**: Checkbox labeled "Autogenerate worktree branch name", checked by default.
3. **Footer**: Border-top separator, right-aligned "Cancel" (secondary) and "Open" (accent) buttons. "Open" is disabled until a repo is selected.
### Interaction flow
1. User clicks "+ New Worktree" in either tab menu.
2. Modal opens. The repo picker shows known repos from `PersistedWorkspace`.
3. User selects a repo (or adds a new one via the folder picker).
4. Branch picker populates with that repo's git branches; user optionally selects a base branch.
5. User optionally unchecks "Autogenerate worktree branch name" — if unchecked, the selected branch is used directly; if checked, a placeholder name like `worktree-1` is generated.
6. User clicks "Open":
- A worktree tab config TOML is generated and written to `~/.warp/tab_configs/worktree_{branch_name}.toml`.
- The config is immediately parsed and opened as a new tab (running `git worktree add` and `cd` commands).
- The filesystem watcher picks up the new file, so it appears in the menu for future use.
7. User can later click the saved worktree config in the menu to re-run it.
### Cancel / close behavior
- Clicking "Cancel", pressing ESC, or clicking the X button closes the modal with no side effects.
### Auto-generate branch name
When the checkbox is checked, a placeholder function generates unique names by incrementing a global counter: `worktree-1`, `worktree-2`, etc. This counter resets on app restart (session-scoped). Future iterations may use more sophisticated naming.
## Edge Cases
1. **No repos available**: The repo picker shows an empty list with only the "+ Add new repo..." footer.
2. **No git branches (no commits)**: If the selected repo has no commits yet (e.g. freshly `git init`), `git for-each-ref` returns no refs. The branch picker falls back to `detect_current_branch` (which uses `git branch --show-current`) so the user can still select the initial branch (e.g. "main").
3. **Branch loading state**: While branches are being fetched, the dropdown displays "Fetching branches…" as placeholder text inside the dropdown itself (rather than as a separate label below it) so the modal layout does not shift.
4. **Repo changed mid-modal**: When the user changes the repo selection, the branch picker refetches branches for the new repo and clears any prior branch selection. The dropdown correctly clears stale display text from the previous repo.
5. **Duplicate filenames**: `find_unused_worktree_config_path` appends `_1`, `_2`, etc. to avoid collisions.
6. **Non-local_fs builds (WASM)**: The submit handler is a no-op on WASM — the modal still opens but "Open" does nothing beyond closing the modal.
7. **Invalid TOML parse**: If the generated TOML fails to parse (should not happen), a warning is logged and no tab opens.
## Success Criteria
1. Both horizontal and vertical tab menus show "+ New Worktree" as the last item with a Plus icon.
2. Clicking "+ New Worktree" opens a modal matching the Figma design.
3. The "Open" button is disabled until a repo is selected.
4. Submitting the modal writes a `.toml` file to `~/.warp/tab_configs/` with the correct worktree commands.
5. The new tab config appears in the menu on subsequent menu opens (via filesystem watcher).
6. The new tab opens immediately with the correct worktree commands.
7. The autogenerate checkbox produces unique branch names.
8. Cancel / ESC / X closes the modal without side effects.
## Validation
- Build and run Warp locally; click "+ New Worktree" from both horizontal and vertical tab menus.
- Verify the modal layout matches the Figma mock (repo picker, branch picker, checkbox, footer buttons).
- Select a repo, select a branch, click "Open" — confirm a `.toml` file appears in `~/.warp/tab_configs/` and a new tab opens.
- Re-open the menu — confirm the saved worktree config appears.
- Click the saved worktree config — confirm it opens a new tab with the same worktree commands.
- Verify autogenerate checkbox produces `worktree-1`, `worktree-2`, etc.
- Verify Cancel/ESC/X close the modal.
## Open Questions
(None outstanding.)
+111
View File
@@ -0,0 +1,111 @@
# APP-3679: New Worktree Modal — Tech Spec
## Problem
The "Create new tab config..." / "New worktree" menu items open a raw TOML template in an editor. This requires users to understand the TOML schema to create worktree configs. The new worktree modal provides a GUI-based "worktree factory" that generates, persists, and opens worktree tab configs without manual TOML editing.
## Relevant Code
- `app/src/tab_configs/new_worktree_modal.rs`**new** modal body view (created in this change)
- `app/src/tab_configs/mod.rs` — module registration
- `app/src/tab_configs/params_modal.rs` — reference pattern for modal body views
- `app/src/tab_configs/repo_picker.rs` — reused `RepoPicker` component
- `app/src/tab_configs/branch_picker.rs` — reused `BranchPicker` component (includes no-commit fallback and loading placeholder)
- `app/src/view_components/filterable_dropdown.rs``FilterableDropdown` (stale selection fix in `set_items`)
- `app/src/workspace/view.rs (4636-4690, 4776-4816)` — menu item definitions for horizontal and vertical tabs
- `app/src/workspace/view.rs (7057-7196)` — event handlers and TOML generation
- `app/src/workspace/view.rs (1447-1476)``build_new_worktree_modal`
- `app/src/workspace/action.rs:554-557``OpenNewWorktreeModal` and `OpenNewWorktreeRepoPicker` action variants
- `app/src/workspace/util.rs:123``is_new_worktree_modal_open` workspace state flag
- `app/src/user_config/mod.rs:188-209``find_unused_worktree_config_path` utility
- `app/src/modal.rs``Modal<T>` and `ModalViewState<T>` wrappers
- `ui/src/ui_components/checkbox.rs``Checkbox` component
## Current State
The horizontal tab menu's last item is `"Create new tab config..."` which calls `create_and_open_new_tab_config` — writes a TOML template to `~/.warp/tab_configs/` and opens it in the user's editor. The vertical tab menu has the same action under the label `"New worktree"`.
Existing infrastructure:
- `RepoPicker`: filterable dropdown of known repos from `PersistedWorkspace`, with "+ Add new repo..." footer.
- `BranchPicker`: filterable dropdown of git branches for a repo, with async fetch and main-branch sorting.
- `Modal<T>` / `ModalViewState<T>`: standard modal pattern used by `TabConfigParamsModal`.
- Tab config filesystem watcher in `user_config/native.rs` automatically reloads `~/.warp/tab_configs/` on file changes.
## Proposed Changes
### 1. New view: `NewWorktreeModal`
File: `app/src/tab_configs/new_worktree_modal.rs`
A `View` + `TypedActionView` body for use inside `Modal<NewWorktreeModal>`:
- **State**: `RepoPicker`, `BranchPicker`, `autogenerate_branch_name: bool`, `selected_repo/branch: Option<String>`, mouse states for checkbox/cancel/open buttons.
- **Events**: `NewWorktreeModalEvent::{ Close, Submit { repo, branch, autogenerate_name }, PickNewRepo }`.
- **Actions**: `NewWorktreeModalAction::{ Cancel, Open, ToggleAutogenerate }`.
- **Render**: Column layout with repo picker, branch picker, checkbox row, and footer bar with Cancel/Open buttons. Body padding handled by the modal body view (not the `Modal` wrapper, which has `padding: 0`).
- **`on_open`**: Rebuilds pickers fresh, resets checkbox to checked, focuses the repo picker.
- **Repo → Branch sync**: When `RepoPickerEvent::Selected` fires, calls `branch_picker.refetch_branches(path)`.
- **`generate_worktree_branch_name()`**: Static `AtomicU32` counter producing `worktree-1`, `worktree-2`, etc.
### BranchPicker improvements
- **Loading placeholder**: When a branch fetch starts, the dropdown shows "Fetching branches…" as a selected placeholder item (inside the dropdown top bar) rather than as a separate text element below the dropdown. This prevents the modal from shifting layout. `selected_value()` returns `None` while `is_loading` is true so the placeholder is never treated as a real branch selection.
- **No-commit repo fallback**: `git for-each-ref refs/heads` only lists refs backed by actual commits, so a freshly initialised repo (`git init`, no commits) returns an empty branch list. The `fetch_branches` async block detects this and falls back to `detect_current_branch` (which uses `git branch --show-current`) to populate the picker with the initial branch (e.g. "main").
- **FilterableDropdown stale selection fix**: `FilterableDropdown::set_items` now clears the cached `selected_item` when the old selection's label is absent from the replacement item list. Previously, calling `set_items(vec![])` left a stale `selected_item` that caused the dropdown top bar to show a ghost label and `selected_item_label()` to return a value for a non-existent item.
### 2. Workspace wiring
Follows the exact same pattern as `tab_config_params_modal`:
- Field: `new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>` on `Workspace`.
- Builder: `build_new_worktree_modal(ctx)` — creates body, subscribes to body and modal events, wraps in `Modal` with compact header, 460×400px size, zero body padding.
- **Action**: `OpenNewWorktreeModal` — gets CWD from active terminal, calls `body.on_open(cwd)`, opens the modal.
- **Action**: `OpenNewWorktreeRepoPicker` — opens a folder picker, registers the new path in `PersistedWorkspace`, updates the modal's repo picker.
- **Render**: `if self.new_worktree_modal.is_open() { stack.add_child(...) }` in the overlay section.
### 3. Menu updates
Both `new_session_menu_items` (horizontal) and `vertical_tabs_new_session_menu_items` (vertical) replace their last "tab config creation" item with `"+ New Worktree"``WorkspaceAction::OpenNewWorktreeModal` with `icons::Icon::Plus`.
### 4. TOML generation on submit
`handle_new_worktree_submit(repo, branch, autogenerate_name)`:
1. Determine branch name: use `generate_worktree_branch_name()` if autogenerate, else use the selected branch (fallback to generated name if none).
2. Build TOML string with `name = "Worktree: {branch_name}"`, single `[[panes]]` with `type = "terminal"`, `cwd`, `worktree_name_autogenerated`, and `commands = ["git worktree add ...", "cd ..."]`.
3. Write to `~/.warp/tab_configs/worktree_{sanitized_branch}.toml` using `find_unused_worktree_config_path`.
4. Parse the TOML back into a `TabConfig` and call `open_tab_config(config, ctx)`.
5. The filesystem watcher picks up the new file and makes it available in the menu.
### 5. Utility: `find_unused_worktree_config_path`
In `user_config/mod.rs`. Sanitizes branch name for filename safety (alphanumeric, `-`, `_` only), tries `worktree_{name}.toml`, then `worktree_{name}_1.toml`, etc.
## End-to-End Flow
1. User clicks `+ New Worktree` in either tab menu.
2. `WorkspaceAction::OpenNewWorktreeModal` dispatched.
3. Workspace calls `body.on_open(cwd)`, opens the `ModalViewState`.
4. User interacts with repo/branch pickers and checkbox.
5. User clicks "Open" → `NewWorktreeModalAction::Open``try_submit()` → emits `NewWorktreeModalEvent::Submit`.
6. Workspace receives `Submit` event → `handle_new_worktree_submit()` generates TOML, writes file, parses it, calls `open_tab_config()`.
7. New tab opens with the worktree commands. File watcher detects the new `.toml` and adds it to `WarpConfig.tab_configs`.
8. Modal closes.
## Risks and Mitigations
- **TOML format drift**: The generated TOML must match what `TabConfig::deserialize` expects. Mitigation: the TOML is immediately parsed back after writing; any mismatch is caught and logged.
- **Branch name sanitization**: Special characters in branch names could produce invalid filenames. Mitigation: `find_unused_worktree_config_path` sanitizes to alphanumeric + `-` + `_`.
- **Counter reset**: `WORKTREE_COUNTER` is a static `AtomicU32` that resets on app restart, so branch names may collide across sessions. Mitigation: the TOML content includes the full branch name so collisions only affect filenames (handled by the `_N` suffix).
## Testing and Validation
- **Build check**: `cargo check -p warp` passes with no errors or warnings.
- **Manual testing**: Open both horizontal and vertical tab menus, verify "+ New Worktree" appears, opens the modal, and the full flow works.
- **Filesystem**: Verify `.toml` files appear in `~/.warp/tab_configs/` with correct content.
- **Re-use**: Verify saved worktree configs appear in the menu and can be re-opened.
## Follow-ups
- Replace the placeholder `generate_worktree_branch_name()` with a more sophisticated naming scheme (e.g., based on date, repo name, or user initials).
- Add validation that the selected repo is a valid git repository before allowing submission.
- Consider adding a text input for manual branch name entry when autogenerate is unchecked.
- Explore support for worktree deletion or management from the menu.
- Consider showing a distinct empty state in the branch picker when the repo genuinely has no branches (i.e. the `detect_current_branch` fallback also fails).
+194
View File
@@ -0,0 +1,194 @@
# Onboarding Tab Config Modal — Product Spec
Linear: [APP-3680](https://linear.app/warpdotdev/issue/APP-3680/onboarding-tab-config-flow-new-tab-config-created-for-user)
Figma: [House of Agents — node 7077-23101](https://figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7077-23101&m=dev)
## Problem
After completing onboarding, users land in an empty terminal tab with no guidance on configuring their first working session. There is no streamlined way to pick a session type (terminal vs. agent), choose a project directory, enable worktree support, and optionally persist that setup as a reusable tab config — all in a single flow.
## Summary
A new modal ("Create your default tab config") appears after onboarding completes, overlayed on the terminal. It collects three inputs — session type, directory, and worktree preference — then creates a persistent tab config TOML in `~/.warp/tab_configs/` and opens it in the current tab.
## Goals
- Let users configure their first session immediately after onboarding in a single modal.
- Support Built in agent (Oz), third-party CLI agents (Claude, Codex, Gemini), and Terminal as session types.
- Always persist the configuration as a reusable tab config TOML in `~/.warp/tab_configs/`.
- Keep the modal implementation reusable so it can be surfaced in other contexts later (e.g., from a menu or command palette).
## Non-goals
- Worktree name generation (Moira is handling this in parallel; hardcode the branch name for now).
- Editing or managing existing tab configs from this modal.
- Showing this modal on every new tab — it only appears post-onboarding in the open source onboarding rev.
- Web/WASM support — this is local-only (`local_fs`).
## User Experience
### When the modal appears
The modal appears once, immediately after the user completes the onboarding slide flow. It only appears when `OpenWarpNewSettingsModes` is enabled — this is the new onboarding path. Users on the old onboarding flow never see this modal. It is rendered as a centered overlay on top of the terminal workspace (not as a full-screen onboarding slide). The user cannot interact with the terminal behind it while the modal is open.
### Modal layout (per Figma)
- **Title:** "Create your default tab config"
- **Subtitle:** "Select if you'd like to work in the terminal versus with an agent of your choosing."
- **Session type:** A row of selectable pill-style buttons that wrap. Options (in order): Built in agent, Claude, Codex, Gemini, Terminal. Only one can be selected at a time. Built in agent is selected by default.
- **Select directory:** A button that opens a native OS folder picker. Displays the selected path left-aligned (defaults to `~`). Text is semibold, no folder icon.
- **Enable worktree support:** A checkbox. Disabled with a tooltip ("Select a git repository to enable worktree support") when the selected directory is not a git repository. Unchecked by default.
- **"Get warping" button:** Primary action button with Enter keyboard shortcut.
### Session type behavior
Each session type determines what happens when the user clicks "Get warping":
- **Terminal:** Opens a terminal session in the selected directory. No command is auto-run.
- **Oz:** Opens the tab into agent view / Oz agent mode (not a CLI command). The tab starts in the selected directory with the Oz agent UI active.
- **Claude / Codex / Gemini:** Opens a terminal session in the selected directory and auto-runs the corresponding CLI command (`claude`, `codex`, `gemini`).
### Directory selection
Clicking the directory button opens a native OS folder picker (folders only, same as the existing project slide picker). After selection, the button text updates to show the selected path. The default before any selection is `~` (the user's home directory).
### Enable worktree support
When checked and a git repo is selected, the session (and tab config, if saved) will include git worktree creation commands. The worktree branch name is hardcoded for now (e.g., `"my-feature-branch"`).
**Disabled state:** The checkbox is visually disabled and non-interactive when the selected directory does not contain a `.git` directory (or is not inside a git repo). A tooltip explains: "Select a git repository to enable worktree support." If the user changes the directory from a git repo to a non-git directory, the checkbox unchecks automatically and becomes disabled.
### "Get warping"
Clicking "Get warping" always saves a tab config and opens it:
1. Writes a new TOML file to `~/.warp/tab_configs/`. The file is named `startup_config.toml` (or `startup_config_1.toml`, `startup_config_2.toml`, etc. if the name is taken).
2. The TOML file contains:
- `name = "Startup Config"` (or with a numeric suffix matching the file name).
- A single `[[panes]]` entry with `type`, `cwd`, and optional `commands` (see "Tab config TOML generation" below).
- If worktree is enabled, `[params]` section with `worktree_branch_name` (type `text`, default `"my-feature-branch"`).
3. Dismisses the modal.
4. Opens the newly created tab config in the current tab (replacing it), using the same flow as `open_tab_config` — which means if the config has params (worktree case), the params modal appears first.
5. If write fails, falls back to opening the tab config without persisting it.
### Tab config TOML generation
The TOML structure depends on the combination of selections:
**Terminal only (no worktree):**
```toml
name = "Startup Config"
[[panes]]
id = "main"
type = "terminal"
cwd = "/absolute/path/to/dir"
```
**CLI agent (e.g., Claude), no worktree:**
```toml
name = "Startup Config"
[[panes]]
id = "main"
type = "terminal"
cwd = "/absolute/path/to/dir"
commands = ["claude"]
```
**Terminal + worktree:**
```toml
name = "Startup Config"
title = "{{worktree_branch_name}}"
[[panes]]
id = "main"
type = "terminal"
cwd = "/absolute/path/to/dir"
worktree_name_autogenerated = true
commands = [
"git worktree add -b {{worktree_branch_name}} ../{{worktree_branch_name}}",
"cd ../{{worktree_branch_name}}",
]
[params.worktree_branch_name]
type = "text"
description = "New worktree branch name"
default = "my-feature-branch"
```
**CLI agent + worktree:**
```toml
name = "Startup Config"
title = "{{worktree_branch_name}}"
[[panes]]
id = "main"
type = "terminal"
cwd = "/absolute/path/to/dir"
worktree_name_autogenerated = true
commands = [
"git worktree add -b {{worktree_branch_name}} ../{{worktree_branch_name}}",
"cd ../{{worktree_branch_name}}",
"claude",
]
[params.worktree_branch_name]
type = "text"
description = "New worktree branch name"
default = "my-feature-branch"
```
**Oz session:** Uses `type = "agent"` on the pane, which causes the tab to open in agent view via `PaneMode::Agent`. The `DefaultSessionMode` setting is also set to `Agent` so future new tabs default to agent view. When the feature flag for this new onboarding modal is off, the existing behavior (where selecting `AgentDrivenDevelopment` sets the mode to `Agent`) remains unchanged.
```toml
name = "Startup Config"
[[panes]]
id = "main"
type = "agent"
cwd = "/absolute/path/to/dir"
```
### Keyboard interaction
- **Enter:** Activates "Get warping" (same as clicking the button).
- **Escape:** Closes the modal without taking any action — the user lands on an empty terminal tab.
- Arrow keys / Tab: Navigate between session type pills and checkboxes.
### Dismissal
The modal can be dismissed by:
- Clicking "Get warping" (takes action).
- Pressing Escape (no action taken).
- Clicking outside the modal (no action taken).
- There is no explicit close/X button in the Figma.
### Reusability
The modal's core logic (collecting session type, directory, worktree preference, save preference) should be a self-contained view that produces a structured output (e.g., a struct with all selected values). The caller decides what to do with that output. For the onboarding flow, the caller replaces the current tab and optionally writes a tab config. This makes the modal reusable in other contexts.
## Success Criteria
1. After completing onboarding, the modal appears overlayed on the terminal.
2. Selecting "Terminal" + a directory + "Get warping" writes a tab config TOML to `~/.warp/tab_configs/` and replaces the current tab with a session in that directory.
3. Selecting a CLI agent + a directory + "Get warping" writes a tab config TOML, replaces the current tab, sets the working directory, and auto-runs the agent CLI command.
4. Selecting "Built in agent" + a directory + "Get warping" writes a tab config TOML, replaces the current tab, and opens Oz agent view in that directory.
5. The written TOML appears in the + tab menu.
6. The worktree checkbox is disabled when the selected directory is not a git repo, and enabled when it is.
7. Checking worktree + save produces a TOML with `{{worktree_branch_name}}` params and worktree commands.
8. File naming avoids collisions (appends `_1`, `_2`, etc.).
9. Escape dismisses the modal without side effects.
10. The modal does not appear again after the user completes it once (or dismisses via Escape).
## Validation
- **Unit tests:** TOML generation for each combination of session type × worktree × directory produces the expected output.
- **Manual testing:** Walk through the full onboarding flow, verify the modal appears, select each session type, toggle worktree and save, confirm correct tab behavior.
- **Tab config integration:** After saving, verify the config appears in the + menu, can be opened, and the params modal works for worktree configs.
- **UI verification:** Compare the rendered modal against the Figma mock (session type pills, directory button, checkbox, CTA button).
## Resolved Decisions
1. **Oz in tab config TOML:** Oz uses `type = "agent"` on the pane node, which maps to `PaneMode::Agent` and causes the pane to enter agent view automatically. `DefaultSessionMode` is also set to `Agent` so future new tabs default to agent view. This must not break the existing behavior when the feature flag for this modal is disabled.
2. **Worktree base branch:** The `{{branch}}` param is omitted. The `git worktree add` command uses HEAD implicitly (no base branch argument). The worktree branch name is hardcoded to `"my-feature-branch"` as a default. **TODO(moira):** Once worktree name generation is ready, replace the hardcoded `"my-feature-branch"` default with the generated name and revisit whether a base branch param should be added.
3. **Session type list:** Hardcoded to Built in agent, Claude, Codex, Gemini, Terminal (in that order). Not dynamically derived from the `CLIAgent` enum.
+251
View File
@@ -0,0 +1,251 @@
# Onboarding Tab Config Modal — Tech Spec
Product spec: `specs/APP-3680/PRODUCT.md`
## Current State
**Onboarding completion:** `handle_agent_onboarding_event` (`app/src/root_view.rs:2062`) handles `OnboardingCompleted`. It applies settings, then calls `start_agent_onboarding_tutorial` on the workspace, which dispatches the legacy guided-tour flow to the terminal view.
**Tab replacement:** There is no existing "replace current tab" API. `add_tab_with_pane_layout` (`app/src/workspace/view.rs:8157`) always adds a new tab. `close_tab` (`app/src/workspace/view.rs:7805`) removes by index.
**Tab config TOML writing:** `create_and_open_new_tab_config` (`app/src/workspace/view.rs:4739`) writes the template to `~/.warp/tab_configs/` via `find_unused_tab_config_path` (`app/src/user_config/mod.rs:173`). The filesystem watcher (`app/src/user_config/native.rs:266`) auto-reloads tab configs.
**DefaultSessionMode:** `DefaultSessionMode` (`app/src/settings/ai.rs:252`) has `Terminal` and `Agent` variants. Set during onboarding via `apply_agent_settings` (`app/src/settings/onboarding.rs:122`).
**Feature flags:** `TabConfigs`, `AgentOnboarding`, `OpenWarpNewSettingsModes`, and `AgentView` are the relevant flags (`warp_core/src/features.rs`). We'll add a new flag for this modal.
## Relevant Code
- `app/src/root_view.rs:2062``handle_agent_onboarding_event`, where `OnboardingCompleted` is handled
- `app/src/workspace/view/onboarding.rs``OnboardingTutorial` enum and `start_agent_onboarding_tutorial`
- `app/src/workspace/view.rs:8157``add_tab_with_pane_layout`
- `app/src/workspace/view.rs:4688-4734``open_tab_config_with_params` and `open_tab_config`
- `app/src/workspace/view.rs:4739``create_and_open_new_tab_config`
- `app/src/tab_configs/tab_config.rs``TabConfig`, `TabConfigPaneNode`, `TabConfigPaneType`, `render_tab_config`
- `app/src/user_config/mod.rs:173``find_unused_tab_config_path`
- `app/src/settings/onboarding.rs:122``apply_agent_settings`, where `DefaultSessionMode` is set
- `app/src/settings/ai.rs:252``DefaultSessionMode` enum
- `app/src/terminal/cli_agent.rs:94``CLIAgent::command_prefix()`
- `app/src/modal.rs``Modal<T>` and `ModalViewState<T>` pattern
- `app/src/workspace/one_time_modal_model.rs` — one-time modal tracking pattern
## Proposed Changes
### 1. New feature flag
No new feature flag needed. Gate the modal behind both `FeatureFlag::OpenWarpNewSettingsModes` (this is the new onboarding path) and `FeatureFlag::TabConfigs` (the modal produces a tab config, so the tab config system must be enabled). Both flags must be on for the modal to appear. When either is off, the old onboarding flow runs unchanged.
### 2. Add `Serialize` to tab config types
`TabConfigParamType` already derives both `Serialize` and `Deserialize`. Add `Serialize` to:
- `TabConfigPaneType` — so pane type is included in serialized TOML
- `TabConfigPaneNode` — so pane nodes can be serialized
- `TabConfig` — so full configs can be written to disk
These are simple data structs — adding `Serialize` is a natural extension that enables writing tab configs programmatically (not just reading them from TOML).
### 3. `SessionType` enum
Add a small enum in `app/src/tab_configs/mod.rs` (or a new submodule) that reuses `CLIAgent`:
```rust
pub enum SessionType {
Terminal,
Oz,
CliAgent(CLIAgent),
}
```
This wraps the existing `CLIAgent` (`app/src/terminal/cli_agent.rs:82`) and adds Terminal/Oz as first-class variants. `SessionType` provides helpers:
- `command_prefix() -> Option<&str>` — delegates to `CLIAgent::command_prefix()` for CLI agents, `None` for Terminal/Oz.
- `icon() -> Icon` — delegates to `CLIAgent::icon()`, with `Icon::Terminal` for Terminal and `Icon::Oz` for Oz.
- `display_name() -> &str` — delegates to `CLIAgent::display_name()` for CLI agents.
- `pill_label() -> &str` — short label for the modal pills (e.g., "Claude" instead of "Claude Code").
### 4. `TabConfig` builder: `build_tab_config`
Add a function in `app/src/tab_configs/session_config.rs`:
```rust
fn build_tab_config(
session_type: &SessionType,
directory: &Path,
enable_worktree: bool,
) -> TabConfig
```
This builds a `TabConfig` with a single `TabConfigPaneNode` using the new flat `[[panes]]` schema. The logic:
- Sets `name = "Startup Config"`
- Creates a single pane with `id = "main"`, `cwd` set to the absolute directory path
- Sets `pane_type` to `TabConfigPaneType::Agent` for Oz, `TabConfigPaneType::Terminal` for Terminal and CLI agents
- Appends worktree commands + `worktree_branch_name` param when `enable_worktree` is true, with `worktree_name_autogenerated = true`
- Appends `session_type.command_prefix()` to commands when it's a CLI agent
- Sets `title = "{{worktree_branch_name}}"` when worktree is enabled
Pure function, easily unit-tested. The existing `render_tab_config` and `TabConfig::default_param_values` work on its output unchanged.
### 5. `write_tab_config`
Add a function in `app/src/tab_configs/` to serialize and write:
```rust
fn write_tab_config(config: &TabConfig, dir: &Path) -> Result<PathBuf>
```
Uses `toml::to_string_pretty(config)` (now possible with `Serialize`), finds an unused path via the shared `find_unused_toml_path(dir, "startup_config")` helper (generalized from `find_unused_tab_config_path` in `user_config/mod.rs`), and writes. Returns the path. The filesystem watcher auto-reloads.
### 6. Modal view: `SessionConfigModal`
Create `app/src/tab_configs/session_config_modal.rs`. This is a self-contained `View` that renders the Figma layout:
- Session type pill buttons using `Wrap::row()` for flex-wrap (hardcoded list in order: Built in agent (Oz), Claude, Codex, Gemini, Terminal)
- Directory picker button (opens native `FilePickerConfiguration::folders_only()`), displays `~` via `warp_util::path::user_friendly_path()`, left-aligned text with semibold weight, no folder icon
- "Enable worktree support" checkbox (disabled when directory is not a git repo)
- "Get warping" button using `ActionButton` with `PrimaryTheme` and `with_full_width(true)`, includes Enter keystroke badge via `with_keybinding()`
The modal always saves a tab config — there is no "Save as tab config" checkbox.
**State:** The modal holds:
- `selected_session_type: SessionType`
- `selected_directory: PathBuf` (default: home dir)
- `is_git_repo: bool` (recomputed on directory change via `std::path::Path::join(".git").is_dir()`)
- `enable_worktree: bool`
- `MouseStateHandle` for each interactive element
**Output struct:** The modal collects its inputs into a plain struct:
```rust
pub struct SessionConfigSelection {
pub session_type: SessionType,
pub directory: PathBuf,
pub enable_worktree: bool,
}
```
**Event:** The modal emits:
```rust
pub enum SessionConfigModalEvent {
Completed(SessionConfigSelection),
Dismissed,
}
```
The caller converts the selection into a `TabConfig` via `build_tab_config` when needed. The modal does not know what the caller does with the selection.
**Git repo detection:** When the directory changes, check if `selected_directory.join(".git").is_dir()` or walk up parents looking for `.git`. If not a git repo, set `is_git_repo = false`, force `enable_worktree = false`, and render the worktree checkbox as disabled with a tooltip.
### 7. Hosting the modal in `Workspace`
Add to `Workspace`:
```rust
session_config_modal: ModalViewState<Modal<SessionConfigModal>>,
```
Follow the same pattern as `tab_config_params_modal` (`app/src/workspace/view.rs:4723`). The workspace subscribes to `SessionConfigModalEvent` and handles both variants.
### 8. Handling `SessionConfigModalEvent::Completed`
The workspace handler in a new method `handle_session_config_completed`:
**Step 1: Apply DefaultSessionMode.** If `session_type == Oz`, set `DefaultSessionMode::Agent`. Otherwise, set `DefaultSessionMode::Terminal`. (Only when the feature flag is on — when off, the existing onboarding path handles this.)
**Step 2: Build a `TabConfig`.** Call `build_tab_config(&selection.session_type, &selection.directory, selection.enable_worktree)`. This produces the canonical `TabConfig` regardless of the save path.
**Step 3: Open the tab.** Always save: call `write_tab_config(&config, &tab_configs_dir())` to persist the TOML, then call `open_tab_config(config)`, which handles the params modal flow for worktree configs (user gets to pick branch name). If write fails, fall back to `open_tab_config_with_params` without persisting.
Agent view entry for Oz is handled automatically by `PaneMode::Agent` in the tab config pane node — `pane_tree_from_template` enters agent view when it sees `PaneMode::Agent`. No manual `enter_agent_view_on_active_tab()` call is needed.
**Step 4: Replace current tab.**
**Step 4: Replace current tab.** After adding the new tab, use `remove_tab` directly (not `close_tab`) to remove the old empty tab. `close_tab` would trigger a window close when it's the last tab, but by this point there are always 2+ tabs since the new one was just added. The old tab is at `old_tab_index` (captured before step 3).
### 9. Triggering the modal after onboarding
In `handle_agent_onboarding_event` (`app/src/root_view.rs:2080`), after the existing `OnboardingCompleted` handling, when both `FeatureFlag::OpenWarpNewSettingsModes.is_enabled()` and `FeatureFlag::TabConfigs.is_enabled()`:
Instead of calling `start_agent_onboarding_tutorial` directly, dispatch a new `WorkspaceAction::ShowSessionConfigModal`. The workspace opens the modal. On `Completed`, the workspace replaces the tab and applies settings. On `Dismissed`, fall through to the existing tutorial path (or just leave the empty tab).
When either flag is off (old onboarding), the existing path (`start_agent_onboarding_tutorial`) runs unchanged.
## End-to-End Flow
1. User completes onboarding slides → `OnboardingCompleted` fires.
2. `root_view` applies settings, transitions to `Terminal` state with the workspace.
3. `root_view` dispatches `WorkspaceAction::ShowSessionConfigModal` (flag-gated).
4. Workspace opens `session_config_modal` as a centered overlay.
5. User selects session type, picks directory, optionally toggles worktree, clicks "Get warping".
6. Modal emits `SessionConfigModalEvent::Completed(selection)`.
7. Workspace calls `handle_session_config_completed`:
- Sets `DefaultSessionMode` if Oz.
- Calls `build_tab_config` to produce a `TabConfig`.
- Calls `write_tab_config` then `open_tab_config` (always saves).
- Closes the old empty tab.
8. Modal is dismissed. User is in their configured session.
## Risks and Mitigations
**Risk: Breaking existing onboarding.** All new behavior is gated behind both `FeatureFlag::OpenWarpNewSettingsModes` and `FeatureFlag::TabConfigs`. When either is off, `handle_agent_onboarding_event` follows the identical code path as today. No changes to `OnboardingTutorial`, `SelectedSettings`, or `apply_onboarding_settings`.
**Risk: Tab index math when replacing.** Closing the wrong tab index would lose user work. Mitigated by: the old tab is always empty (just created by onboarding), and we close with `skip_confirmation = true`. We also use the tab index arithmetic described above, which can be validated in tests.
**Risk: Git repo detection on directory change.** Checking `.git` is synchronous I/O. For the onboarding modal (called once), this is acceptable. If reused in a hot path later, it should be made async.
**Risk: Adding `Serialize` to `TabConfig`.** Low risk — these are plain data structs with simple fields. Adding `Serialize` alongside existing `Deserialize` is a standard pattern. No behavioral change to existing deserialization paths.
## Testing and Validation
### `build_tab_config` (unit tests)
These enforce the TOML generation rules from the product spec:
- Terminal + directory, no worktree → `TabConfig` with `cwd` set, empty commands, no params.
- CLI agent (Claude) + directory, no worktree → commands = `["claude"]`, no params.
- Terminal + directory + worktree → commands include worktree creation + cd, params contain `worktree_branch_name` with default `"my-feature-branch"`, title = `"{{worktree_branch_name}}"`.
- CLI agent (Gemini) + directory + worktree → commands include worktree creation + cd + `"gemini"` (in that order), params contain `worktree_branch_name`.
- Oz + directory, no worktree → `cwd` set, `pane_type = Agent`, no commands, no params.
- Oz + directory + worktree → `pane_type = Agent` with worktree commands, no agent CLI command.
- Directory path is always absolute in `panes[0].cwd`.
### TOML round-trip (unit tests)
- For each `build_tab_config` output, serialize via `toml::to_string_pretty`, deserialize back as `TabConfig`, verify all fields match.
- Validates that `Serialize` on `TabConfig` produces TOML that the existing `Deserialize` path can read — catches any drift between the two.
### `write_tab_config` (unit tests with temp dir)
- Write to an empty temp dir → file is `startup_config.toml`.
- Write again → file is `startup_config_1.toml`.
- Write a third time → file is `startup_config_2.toml`.
- Written file content deserializes to a valid `TabConfig` matching the input.
- Directory is created if it doesn't exist.
### `SessionType` helpers (unit tests)
- `SessionType::Terminal.command_prefix()``None`.
- `SessionType::Oz.command_prefix()``None`.
- `SessionType::CliAgent(CLIAgent::Claude).command_prefix()``Some("claude")`.
- Display names and icons return the expected values for each variant.
### `render_tab_config` integration (unit tests)
These verify the full pipeline from `build_tab_config``render_tab_config` produces the correct `PaneTemplateType`:
- Terminal + directory → `PaneTemplate` with correct `cwd`, empty commands.
- CLI agent + directory → `PaneTemplate` with correct `cwd`, commands = `["claude"]`.
- Worktree config with default param values → commands have `"my-feature-branch"` substituted in.
### Git repo detection (unit tests with temp dir)
- Create a temp dir with `.git/``is_git_repo` returns true.
- Temp dir without `.git/` → returns false.
- Switching from a git dir to a non-git dir forces `enable_worktree` to false.
### DefaultSessionMode (unit test or integration)
- Selecting Oz sets `DefaultSessionMode::Agent`.
- Selecting Terminal sets `DefaultSessionMode::Terminal`.
- Selecting a CLI agent sets `DefaultSessionMode::Terminal`.
- When `OpenWarpNewSettingsModes` is off, `DefaultSessionMode` is not touched by this code path.
### Feature flag gating (integration)
- When either `OpenWarpNewSettingsModes` or `TabConfigs` is off, `OnboardingCompleted` follows the old tutorial path — modal is never shown.
- When both `OpenWarpNewSettingsModes` and `TabConfigs` are on, `OnboardingCompleted` dispatches `ShowSessionConfigModal`.
### UI verification
- Compare rendered modal against Figma mock.
- Verify worktree checkbox is visually disabled when directory is not a git repo.
## Follow-ups
- **Worktree name generation:** Replace hardcoded `"my-feature-branch"` once Moira's worktree name generation is ready.
- **Reusability:** Surface the modal from the + tab menu or command palette.
- **Async git detection:** If the modal is reused in hot paths, make `.git` detection async.
- **Programmatic tab config editing:** With `Serialize` on `TabConfig`, future features could read → modify → write tab configs (e.g., a tab config editor UI).
+135
View File
@@ -0,0 +1,135 @@
# Product Spec: OpenCode Plugin Install & Update Flow
Linear: [APP-3690](https://linear.app/warpdotdev/issue/APP-3690)
Figma: none provided
## Summary
Add install and update chip support for the OpenCode Warp plugin (`opencode-warp`), matching the existing Claude Code chip UX. Clicking the chip opens a modal with manual installation/update instructions.
## Problem
When a user runs OpenCode inside Warp, the Warp plugin enriches the experience with native notifications and session status tracking. Today, Warp has no way to prompt OpenCode users to install or update this plugin — `plugin_manager_for(CLIAgent::OpenCode)` returns `None`.
## Goals
- OpenCode users see install/update chips that prompt them to set up the plugin.
- Clicking the chip opens a modal with clear, copy-pasteable instructions.
- Outdated plugin versions are detected via the `SessionStart` event and users are prompted to update.
## Prerequisites
The `opencode-warp` npm package must be published before this feature ships. The name is currently available on npm.
## Non-Goals
- Auto-install (one-click install that runs commands automatically). See "Why no auto-install" below.
- Filesystem-based version detection. All version information comes from the plugin's `SessionStart` event.
## Why No Auto-Install
Claude Code has a CLI for plugin management (`claude plugin install`, `claude plugin update`), so the Claude flow shells out those commands for one-click install/update. OpenCode has no equivalent — there are no CLI commands for managing plugins.
OpenCode plugins are installed by either:
1. Adding the npm package name to the `"plugin"` array in `opencode.json` (requires JSON config editing).
2. Placing a `.js`/`.ts` file in `~/.config/opencode/plugins/` (requires downloading a file).
Both paths require either manipulating JSON config files from Rust (fragile — OpenCode supports JSONC with comments, multiple config locations, etc.) or downloading files via curl/npm from a CDN (fragile — depends on curl availability, CDN reachability, correct URL construction). Neither is as clean or reliable as Claude's dedicated CLI.
Since the install is a one-time action and the instructions are simple (add one line to a JSON file), a manual instructions modal is the right tradeoff: reliable, works everywhere (local and SSH), and avoids a class of edge cases.
## How OpenCode Plugins Work
OpenCode has two plugin loading mechanisms:
1. **npm packages** listed in `opencode.json` under the `"plugin"` array. OpenCode auto-installs these via Bun into `~/.cache/opencode/node_modules/` at startup.
2. **Local files** placed in `~/.config/opencode/plugins/` (global) or `.opencode/plugins/` (project). These are loaded directly at startup.
OpenCode has no CLI commands for plugin management (no `/plugin install`, no `/plugin update`, etc.).
**Bun caching behavior:** Once a package is installed in `node_modules`, `bun install` checks that the package exists with an appropriate version and skips re-downloading. This means npm plugins do **not** auto-update — the cached version persists until explicitly cleared.
## User Experience
### Install Chip
When a user starts an OpenCode session and the plugin has never connected (no listener, no `plugin_version` reported):
- A green chip appears: "Notifications setup instructions"
- Clicking opens a modal with manual install instructions.
**Install instructions modal:**
- Title: "Install Warp Plugin for OpenCode"
- Subtitle: "Add the Warp plugin to your OpenCode configuration, then restart OpenCode."
- Steps:
1. "Add the plugin to your config" — copyable snippet:
```json
{ "plugin": ["opencode-warp"] }
```
with explanatory text: "Add `"opencode-warp"` to the `plugin` array in your `opencode.json` (project root) or `~/.config/opencode/opencode.json` (global)."
2. "Restart OpenCode to activate"
### Update Chip
When the plugin is connected but reports a version below `MINIMUM_PLUGIN_VERSION`:
- Label: "Plugin update instructions"
- Same green styling, same dismiss (X) button as install chip.
- Same chip position — install and update never appear simultaneously.
**Why updates don't happen automatically:** Bun caches installed npm packages and does not re-resolve to `latest` on subsequent startups. The cached version persists until the user explicitly clears it.
**Version detection:**
- Sole signal: `plugin_version` field from the `SessionStart` event.
- No filesystem-based version checks. If the plugin hasn't connected, we don't know its version — we show the install chip instead.
- Since the opencode-warp plugin has never been released, there is no legacy version that predates version reporting. Every installed version will report `plugin_version`.
**Update instructions modal:**
- Title: "Update Warp Plugin for OpenCode"
- Subtitle: "Clear the cached plugin and restart OpenCode to pull the latest version."
- Steps:
1. "Remove the cached plugin" — copyable command: `rm -rf ~/.cache/opencode/node_modules/opencode-warp`
2. "Restart OpenCode" — "OpenCode will re-download the latest version on startup."
### Chip Visibility Logic
Simplified from the Claude Code flow since there are no filesystem checks:
1. Plugin connected, version >= minimum → **no chip**
2. Plugin connected, version < minimum or not reported → **update chip**
3. No listener connected → **install chip** (unless dismissed)
4. Chip dismissed → **no chip** (install and update have independent dismiss state)
5. Notifications disabled in settings → **no chip**
Note: without filesystem checks, we cannot distinguish "not installed" from "installed but hasn't connected yet." To avoid a flicker where the install chip appears briefly before the plugin connects, the chip should be debounced — wait a few seconds after session start before showing the install chip. If the plugin connects and sends its `SessionStart` event during that window, the chip never appears.
### Dismiss Behavior
Same as Claude Code: install chip and update chip have independent dismiss state. The update chip tracks which minimum version was dismissed, so a new minimum causes it to reappear.
## Edge Cases
- **Plugin connects after brief delay:** Install chip shows momentarily, then disappears when `SessionStart` arrives. This is the expected startup race.
- **User installs via npm config path:** Plugin connects, reports version, chip disappears. Works identically.
- **User installs via local file path:** Same — plugin connects, reports version.
- **SSH sessions:** Same modal experience as local. The instructions work on any machine.
- **Multiple tabs:** All tabs share the same session state. Dismiss state is shared via the existing settings infrastructure.
## Success Criteria
1. Starting an OpenCode session in Warp when the plugin has never connected shows a green "Notifications setup instructions" chip (after a brief debounce).
2. Clicking the chip opens a modal with install instructions including a copyable config snippet.
3. After the user installs and restarts OpenCode, the plugin connects and the chip disappears.
4. When the plugin reports a version below `MINIMUM_PLUGIN_VERSION`, the "Plugin update instructions" chip appears.
5. Clicking the update chip opens a modal with cache-clear instructions.
6. Dismissing the update chip hides it for the current minimum version; a newer minimum causes reappearance.
7. Dismissing the install chip hides the install chip (independent from update dismiss).
8. The install and update chips never appear simultaneously.
## Validation
- Unit tests: `compare_versions()` (reuse from Claude module or extract to shared utility).
- Unit tests: chip visibility for each state in the chip visibility logic.
- Manual testing: full install flow on a real OpenCode session — verify instructions work, restart activates the plugin, notifications appear.
- Manual testing: update flow — verify cache clear instructions work, restart pulls new version.
- Manual testing: SSH session — verify modal appears and instructions work on the remote.
+249
View File
@@ -0,0 +1,249 @@
# Tech Spec: OpenCode Plugin Install & Update Flow
See `specs/APP-3690/PRODUCT.md` for the product spec.
## 1. Problem
`plugin_manager_for(CLIAgent::OpenCode)` returns `None` (`plugin_manager/mod.rs:90`), so OpenCode sessions never show the install/update chip. We need an `OpenCodePluginManager` that implements the `CliAgentPluginManager` trait and wires into the existing footer/modal infrastructure.
Unlike Claude Code, OpenCode has no CLI for plugin management, so this implementation is manual-only (instructions modal, no auto-install). This requires a small trait refactor to make auto-operations optional and to generalize the per-agent minimum version.
## 2. Relevant Code
- `plugin_manager/mod.rs` — trait definition, `plugin_manager_for()` factory, `PluginInstructions` types
- `plugin_manager/claude.rs` — reference implementation; owns `compare_versions` and `MINIMUM_PLUGIN_VERSION`
- `agent_input_footer/mod.rs (677-744)``plugin_chip_kind()` determines which chip to show
- `agent_input_footer/mod.rs (748-758)``should_use_manual_mode()` determines auto vs modal
- `agent_input_footer/mod.rs (760-849)``handle_plugin_operation()` shared async handler (unused by OpenCode)
- `agent_input_footer/mod.rs (978-999)` — render logic matching `(chip_kind, manual)` to buttons
- `workspace/view/plugin_install_modal.rs` — generic instructions modal (already agent-agnostic, takes `&'static PluginInstructions`)
- `cli_agent_sessions/mod.rs (86-104)``CLIAgentSession` struct with `plugin_version`, `listener`
- `terminal/view.rs:10732-10771``register_cli_agent_listener()` uses `MINIMUM_PLUGIN_VERSION` from `claude.rs`
- `settings/ai.rs:1093-1105``plugin_chip_dismissed_for_version` setting
## 3. Current State
The plugin manager system works well for Claude Code:
- The trait has six required methods, all implemented by `ClaudeCodePluginManager`.
- The footer routes between auto-install and manual-modal based on `should_use_manual_mode()`.
- The modal is already generic (takes `&'static PluginInstructions`, not a `CLIAgent`).
Three things need generalizing:
1. **`compare_versions` and `MINIMUM_PLUGIN_VERSION`** live in `claude.rs` but are imported by `agent_input_footer/mod.rs:65` and `terminal/view.rs:134`. With two agents needing different minimum versions, these should come from the trait.
2. **All trait methods are required.** OpenCode doesn't need `install()`, `update()`, `is_installed()`, or `needs_update()`. These should have sensible defaults.
3. **Install chip flicker.** Claude avoids flicker by checking `is_installed()` on the filesystem. OpenCode has no filesystem checks, so the install chip would flash on session start before the plugin connects. A debounce is needed.
## 4. Proposed Changes
### 4a. Trait Refactor (`plugin_manager/mod.rs`)
**Move `compare_versions` from `claude.rs` to `mod.rs`.** It's a generic semver utility, not Claude-specific. Same signature, just a new home.
**Add two new required methods to the trait:**
```rust
fn minimum_plugin_version(&self) -> &'static str;
fn can_auto_install(&self) -> bool;
```
`minimum_plugin_version()` replaces the hardcoded `MINIMUM_PLUGIN_VERSION` constant at all call sites. Each agent returns its own constant.
`can_auto_install()` tells the footer whether this agent can auto-install/update. Claude returns `true`, OpenCode returns `false`.
**Add default implementations for the four auto-operation methods:**
```rust
fn is_installed(&self) -> bool { false }
fn needs_update(&self) -> bool { false }
async fn install(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-install not supported for this agent".to_owned(),
log: String::new(),
})
}
async fn update(&self) -> Result<(), PluginInstallError> {
Err(PluginInstallError {
message: "Auto-update not supported for this agent".to_owned(),
log: String::new(),
})
}
```
Claude overrides all four. OpenCode uses the defaults. The defaults return `Err` — the caller (`handle_plugin_operation`) already logs errors from failed operations. These should never be reached since `should_use_manual_mode()` returns `true` for agents where `can_auto_install()` is `false`.
### 4b. OpenCode Plugin Manager (`plugin_manager/opencode.rs`)
New file. Minimal implementation — just the two required methods plus instructions:
- `can_auto_install()``false`
- `minimum_plugin_version()``"0.1.0"` (keep in sync with `opencode-warp` npm package version)
- `install_instructions()` → static `PluginInstructions` with title "Install Warp Plugin for OpenCode", steps to add `"opencode-warp"` to the `plugin` array in `opencode.json` + restart
- `update_instructions()` → static `PluginInstructions` with title "Update Warp Plugin for OpenCode", steps to `rm -rf ~/.cache/opencode/node_modules/opencode-warp` + restart
All auto-operation methods (`is_installed`, `needs_update`, `install`, `update`) use the trait defaults.
### 4c. Factory Registration (`plugin_manager/mod.rs`)
```rust
CLIAgent::OpenCode => Some(Box::new(opencode::OpenCodePluginManager)),
```
### 4d. Footer: Generalize `MINIMUM_PLUGIN_VERSION` (`agent_input_footer/mod.rs`)
Replace the import of `claude::{compare_versions, MINIMUM_PLUGIN_VERSION}` with:
- `use super::compare_versions` (from `mod.rs`)
- All references to `MINIMUM_PLUGIN_VERSION` become `manager.minimum_plugin_version()`
This affects three places in `plugin_chip_kind()`:
- `agent_input_footer/mod.rs:704` — version comparison for connected plugin
- `agent_input_footer/mod.rs:712` — dismissed version comparison
- `agent_input_footer/mod.rs:732` — dismissed version comparison (filesystem fallback path)
And one place in the dismiss handler:
- `agent_input_footer/mod.rs:1583` — storing the dismissed version
For the dismiss handler, the manager needs to be resolved to get its `minimum_plugin_version()`. Since `plugin_chip_kind()` already resolves the manager, and the dismiss handler knows whether it's dismissing an update chip, this is straightforward — resolve the manager from the session's agent.
### 4e. Footer: `should_use_manual_mode()` (`agent_input_footer/mod.rs:748`)
Add a check for `can_auto_install()` before the existing conditions:
```rust
fn should_use_manual_mode(&self, app: &AppContext) -> bool {
let sessions_model = CLIAgentSessionsModel::as_ref(app);
let session = match sessions_model.session(self.terminal_view_id) {
Some(s) => s,
None => return false,
};
if let Some(manager) = plugin_manager_for(session.agent) {
if !manager.can_auto_install() {
return true;
}
}
if session.is_remote() {
return true;
}
sessions_model.has_plugin_auto_failed(session.agent, &session.remote_host)
}
```
For OpenCode, this always returns `true`, so the footer always opens the modal instead of attempting auto-install. The `handle_plugin_operation` / `handle_install_plugin` / `handle_update_plugin` code paths are never reached.
### 4e-ii. Footer: Chip Button Labels
The install chip buttons are created at construction time with hardcoded labels:
- `install_plugin_button`: "Enable Claude Code notifications" — Claude-specific
- `plugin_instructions_button`: "Notifications setup instructions"
- `update_plugin_button`: "Update Warp plugin"
- `update_instructions_button`: "Plugin update instructions"
The auto-install button label hardcodes "Claude Code". Make it dynamic using the agent's `display_name()`: format as `"Enable {name} notifications"` where `name` comes from `session.agent.display_name()` (e.g., "Enable Claude Code notifications", "Enable OpenCode notifications"). Since the button is created once at construction, update the label via `set_label` in the render path (or when the session changes), the same way the compose button label is already updated dynamically (`agent_input_footer/mod.rs:374`).
### 4f. `terminal/view.rs`: Generalize `register_cli_agent_listener`
`terminal/view.rs:10753` hardcodes `MINIMUM_PLUGIN_VERSION` from `claude.rs`. Replace with:
```rust
let plugin_version = plugin_manager_for(agent)
.map(|m| m.minimum_plugin_version().to_owned());
```
This returns the correct minimum version for whichever agent is active, and `None` if the agent has no plugin manager (which preserves the existing wasm fallback behavior).
### 4g. Install Chip Debounce (`agent_input_footer/mod.rs`)
**Problem:** For agents without filesystem checks (OpenCode), `plugin_chip_kind()` would show the install chip immediately on session start, before the plugin has time to connect and send `SessionStart`.
**Solution:** Add `plugin_chip_ready: bool` to `AgentInputFooter`. It starts `false` and is set to `true` after a debounce timer fires. When a `Started` event fires for a non-auto-install agent, spawn a one-shot timer via `ctx.spawn(Timer::after(PLUGIN_CHIP_DEBOUNCE), ...)`. When the timer fires, set `plugin_chip_ready = true` and call `ctx.notify()` to trigger a re-render. In the timer callback, check whether a listener has connected in the meantime — if so, skip setting the flag.
In `plugin_chip_kind()`, the "no listener" branch checks:
```rust
if !manager.can_auto_install() && !self.plugin_chip_ready {
return None;
}
```
Reset `plugin_chip_ready = false` when a session ends or when a listener connects.
For Claude (which has `can_auto_install() == true`), this check is skipped — Claude uses `is_installed()` instead.
`PLUGIN_CHIP_DEBOUNCE` is a constant in `agent_input_footer/mod.rs` (`Duration::from_secs(3)`).
### 4h. Claude Module Cleanup (`plugin_manager/claude.rs`)
- Remove `compare_versions` (moved to `mod.rs`)
- Add `minimum_plugin_version()` returning `MINIMUM_PLUGIN_VERSION`
- Add `can_auto_install()` returning `true`
- Keep `MINIMUM_PLUGIN_VERSION` as a module-level constant (still useful for the `needs_update()` and `update()` implementations within this module)
## 5. End-to-End Flow
### Install
1. User starts OpenCode in Warp. Command detection creates a `CLIAgentSession`. The footer's `Started` subscription fires, spawning a debounce timer (`plugin_chip_ready` starts `false`).
2. Footer renders. `plugin_chip_kind()` finds `plugin_manager_for(OpenCode)` = `Some`. No listener. `can_auto_install()` is `false`. `plugin_chip_ready` is `false` → returns `None`. No chip.
3. If plugin is installed: `SessionStart` arrives within ~1s, listener is created, `plugin_version` is set, `plugin_chip_ready` reset to `false`. Footer re-renders. Chip never appears.
4. If plugin is not installed: timer fires after 3s, sets `plugin_chip_ready = true`, calls `ctx.notify()`. `plugin_chip_kind()` returns `Install`. `should_use_manual_mode()` returns `true`. Footer shows the instructions chip.
5. User clicks → modal opens with install steps (add to `opencode.json`, restart).
6. User follows steps, restarts OpenCode. Plugin connects, sends `SessionStart`. Chip disappears.
### Update
1. We bump `MINIMUM_PLUGIN_VERSION` in `opencode.rs` to `"0.2.0"`.
2. Plugin connects with `plugin_version: "0.1.0"`.
3. `plugin_chip_kind()`: listener present, `compare_versions("0.1.0", "0.2.0")` is `Less``Update`.
4. `should_use_manual_mode()` returns `true`. Footer shows update instructions chip.
5. User clicks → modal shows cache-clear + restart steps.
6. User follows steps. On restart, Bun re-resolves `opencode-warp` from npm (cache was cleared), installs latest. Plugin connects with `"0.2.0"`. Chip disappears.
## 6. Risks and Mitigations
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.**
**Risk: `PluginInstructionStep.command` contains JSON, not a shell command.** The install modal's copy button copies the `command` field to clipboard. For OpenCode install, this will be a JSON snippet. **Mitigation:** Already fine — the modal copies any string. A JSON snippet is useful to copy even if it's not a terminal command.
**Risk: Stale `MINIMUM_PLUGIN_VERSION`.** The minimum version is compiled into the Warp binary. **Mitigation:** Same as Claude — by design. We only prompt updates when Warp needs new plugin behavior.
## 7. Testing and Validation
### Unit tests (`plugin_manager/opencode_tests.rs`)
- `opencode_manager_can_auto_install_is_false`
- `opencode_manager_returns_install_instructions` — non-empty steps
- `opencode_manager_returns_update_instructions` — non-empty steps
- `opencode_manager_minimum_version` — returns expected value
### Unit tests (`plugin_manager/mod_tests.rs`)
- Update `returns_none_for_unsupported_agents` — remove `CLIAgent::OpenCode` from assertion
- Add `returns_manager_for_opencode`
- Move `compare_versions` tests here from `claude_tests.rs`
### Unit tests (`plugin_manager/claude_tests.rs`)
- Remove `compare_versions` tests (moved)
- Add `claude_manager_can_auto_install_is_true`
- Add `claude_manager_minimum_version`
### Manual testing
- Start an OpenCode session → install chip appears after ~3s debounce (not immediately)
- If plugin is installed: chip never appears (plugin connects before debounce)
- Click install chip → modal opens with correct OpenCode-specific instructions
- Follow install steps, restart → chip disappears
- Bump minimum version locally → update chip appears
- Click update chip → modal shows cache-clear instructions
- Dismiss install chip → stays hidden
- Dismiss update chip → stays hidden; bump minimum → reappears
- Claude flow unchanged — verify no regressions
## 8. Follow-Ups
- **Auto-install:** If OpenCode adds a plugin management CLI, implement `install()` and `update()` on `OpenCodePluginManager` and flip `can_auto_install()` to `true`.
- **Publish `opencode-warp` to npm:** Must happen before this feature ships.
## 9. Files Changed
- **New:** `plugin_manager/opencode.rs``OpenCodePluginManager`, `MINIMUM_PLUGIN_VERSION`, install/update instructions
- **New:** `plugin_manager/opencode_tests.rs`
- **Modified:** `plugin_manager/mod.rs``compare_versions` moved here, `minimum_plugin_version()` + `can_auto_install()` added to trait, default impls for `is_installed`/`needs_update`/`install`/`update`, factory wires OpenCode
- **Modified:** `plugin_manager/claude.rs` — remove `compare_versions` (moved), add `minimum_plugin_version()` + `can_auto_install()` overrides
- **Modified:** `plugin_manager/claude_tests.rs` — remove `compare_versions` tests (moved), add new trait method tests
- **Modified:** `plugin_manager/mod_tests.rs` — add OpenCode factory test, receive `compare_versions` tests
- **Modified:** `agent_input_footer/mod.rs` — add `plugin_chip_ready: bool` + debounce timer, update imports (`compare_versions` from `mod.rs`), `plugin_chip_kind()` uses `manager.minimum_plugin_version()` + `plugin_chip_ready` guard, `should_use_manual_mode()` checks `can_auto_install()`, dismiss handler resolves minimum version from manager, rename auto-install chip label to be agent-generic
- **Modified:** `terminal/view.rs``register_cli_agent_listener()` uses `plugin_manager_for(agent)?.minimum_plugin_version()` instead of Claude's constant
+111
View File
@@ -0,0 +1,111 @@
# Save Current Tab as New Config — Product Spec
Linear: [APP-3704](https://linear.app/warpdotdev/issue/APP-3704/save-current-tab-as-a-new-config)
Figma: [House of Agents — node 7123-32864](https://figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7123-32864&m=dev)
## Summary
Add a "Save as new config" item to the tab right-click context menu that snapshots the current tab's pane layout (splits, working directories, focus, and color) and writes it as a new tab config TOML file in `~/.warp/tab_configs/`.
## Problem
Users can create complex pane layouts interactively (splits, different working directories per pane, tab color) but have no way to persist that layout as a reusable tab config. The only path today is to hand-author TOML from scratch, which requires understanding the schema and manually transcribing the layout.
## Goals
- Let users save their current tab's pane layout as a new tab config TOML with one click.
- Preserve the full spatial structure: splits, working directories, focus state, and tab color.
- Open the generated file in the user's editor so they can rename it and customize further.
- The saved config appears immediately in the `+` tab menu (the filesystem watcher picks it up).
## Non-goals
- **"Save and update config"** (overwriting an existing tab config the tab was opened from). This requires tracking the source config path per tab and is a separate ticket.
- **"Combine panes into single tab"** — shown greyed out in the Figma, separate feature.
- Saving non-terminal pane content (notebooks, code panes, settings panes). These are not replayable from a TOML config.
- Parameterization. The saved config has no `[params]` section; the user can add params manually.
## Figma
Figma: [House of Agents — node 7123-32864](https://figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7123-32864&m=dev)
The updated tab right-click menu adds a new section between the "close" section and the "color" section:
- **Save as new config** — saves the current tab as a new tab config TOML.
Note: The Figma also shows "Save and update config" and "Combine panes into single tab" in this section. Those are out of scope for this ticket (see Non-goals).
## User Experience
### Triggering the save
1. User right-clicks a tab.
2. The context menu includes "Save as new config" (between the close-tab section and the color section). This item is only visible when the `TabConfigs` feature flag is enabled.
3. User clicks "Save as new config".
### What happens
1. Warp snapshots the tab's pane tree: split directions, each pane's current working directory, focus state, and tab color.
2. Warp writes a new `.toml` file to `~/.warp/tab_configs/` with an auto-generated filename (`my_tab_config.toml`, or `my_tab_config_1.toml` if the name is taken).
3. Warp opens the file in the user's configured editor (same behavior as "Create new tab config...").
4. The filesystem watcher picks up the new file, and it appears in the `+` tab menu as "New Tab: My Tab Config".
### TOML structure
The saved config uses the flat `[[panes]]` schema from APP-3575. Example for a two-pane horizontal split:
```toml
name = "My Tab Config"
color = "blue"
[[panes]]
id = "p1"
split = "horizontal"
children = ["p2", "p3"]
[[panes]]
id = "p2"
type = "terminal"
cwd = "/Users/me/project-a"
is_focused = true
[[panes]]
id = "p3"
type = "terminal"
cwd = "/Users/me/project-b"
```
### Non-terminal pane handling
When the tab contains non-terminal panes (notebook, code, settings, etc.), those panes are replaced with an empty terminal pane in the saved config. This preserves the spatial layout (splits are maintained) while substituting content that cannot be replayed from TOML. The replacement terminal pane has no `cwd` set (omitted from TOML; defaults to the user's home directory when opened).
### Tab title and color
- If the tab has a custom title, it is saved as the `title` field.
- If the tab has a color, it is saved as the `color` field.
- The config `name` is always `"My Tab Config"` — the user can rename it in the file.
### Error handling
- If writing the file fails (permissions, disk full), a warning is logged and no file is opened. No toast or modal is shown — this is a low-probability error.
## Success Criteria
1. Right-clicking a tab with `FeatureFlag::TabConfigs` enabled shows "Save as new config" in the context menu.
2. Clicking "Save as new config" on a single-pane terminal tab writes a valid TOML with one `[[panes]]` entry and the correct `cwd`.
3. Clicking "Save as new config" on a two-pane horizontal split writes a TOML with a split root and two leaf children with correct cwds.
4. Clicking "Save as new config" on a tab with a blue color writes `color = "blue"` in the TOML.
5. The focus state is preserved: the focused pane has `is_focused = true`; unfocused panes omit the field entirely.
6. A tab with a non-terminal pane (e.g., a notebook pane in a split) produces a TOML that replaces the notebook with a terminal pane, preserving the split.
7. The written TOML file round-trips: `toml::from_str::<TabConfig>(toml::to_string_pretty(&config))` produces the same `TabConfig`.
8. The file appears in the `+` menu after saving.
9. Opening the saved config from the `+` menu reproduces the original pane layout.
10. The menu item does not appear when `FeatureFlag::TabConfigs` is off.
## Validation
- **Unit tests:** `tab_config_from_pane_snapshot` for single pane, split, 2x2 grid, non-terminal replacement, focus handling, and TOML round-trip.
- **Manual testing:** Right-click → "Save as new config" on various tab layouts, verify the TOML, open the config from the `+` menu, confirm the layout matches.
## Open Questions
(None outstanding.)
+104
View File
@@ -0,0 +1,104 @@
# Save Current Tab as New Config — Tech Spec
Product spec: `specs/APP-3704/PRODUCT.md`
## Problem
There is no code path to convert a live tab's pane tree into a `TabConfig` TOML. The existing `PaneTemplateType::try_from(PaneNodeSnapshot)` in `launch_configs/launch_config.rs` converts snapshots to the launch config format but drops non-terminal panes entirely and produces a `PaneTemplateType`, not a `TabConfig`.
## Relevant Code
- `app/src/tab_configs/session_config.rs:174``tab_config_from_pane_snapshot` (new)
- `app/src/tab_configs/session_config.rs:194``snapshot_to_flat_panes` helper (new)
- `app/src/tab_configs/session_config.rs:144``write_tab_config` (updated signature)
- `app/src/tab_configs/tab_config.rs``TabConfig`, `TabConfigPaneNode`, `TabConfigPaneType`
- `app/src/pane_group/mod.rs:2029``PaneGroup::snapshot()` returns `PaneNodeSnapshot`
- `app/src/app_state.rs``PaneNodeSnapshot`, `LeafContents`, `TerminalPaneSnapshot`, `BranchSnapshot`
- `app/src/tab.rs:340``save_config_menu_items` (new)
- `app/src/workspace/action.rs:561``WorkspaceAction::SaveCurrentTabAsNewConfig`
- `app/src/workspace/view.rs:4969``save_current_tab_as_new_config` handler (new)
- `app/src/workspace/view.rs:16831` — action dispatch
- `app/src/user_config/mod.rs``find_unused_toml_path`, `tab_configs_dir`
## Current State
**Snapshot infrastructure:** `PaneGroup::snapshot()` recursively walks the pane tree and produces a `PaneNodeSnapshot` with `Branch` and `Leaf` variants. Leaf snapshots carry `LeafContents` which is `Terminal(TerminalPaneSnapshot)`, `Notebook(...)`, `Code(...)`, etc. `TerminalPaneSnapshot` includes `cwd: Option<String>` and `is_active: bool`.
**Tab config serialization:** `TabConfig` derives both `Serialize` and `Deserialize` (added in APP-3575). `write_tab_config` in `session_config.rs` serializes via `toml::to_string_pretty` and writes to disk.
**Tab right-click menu:** Built in `TabData::menu_items()` with four sections: session sharing, modify tab, close tab, and color. Each section is a separate method returning `Vec<MenuItem<WorkspaceAction>>`.
**Open-in-editor pattern:** `create_and_open_new_tab_config` writes a file, resolves the editor target via `EditorSettings`, and calls `open_file_with_target`.
## Changes
### 1. `tab_config_from_pane_snapshot` and `snapshot_to_flat_panes`
Added to `app/src/tab_configs/session_config.rs`. Public function `tab_config_from_pane_snapshot` accepts `&PaneNodeSnapshot`, optional title, and optional color. It delegates to a private recursive helper `snapshot_to_flat_panes` that walks the tree with a `&mut usize` counter for ID generation (`"p1"`, `"p2"`, …).
Branch nodes: reserve their ID, recurse into children to collect child IDs, then insert the split node before the children in the flat list (root-first ordering via `panes.insert(insert_pos, ...)`).
Terminal leaves: extract `cwd` from `TerminalPaneSnapshot.cwd`, set `pane_type = Terminal`, set `is_focused = Some(true)` only when focused (otherwise `None` — omitted from TOML).
Non-terminal leaves: produce a terminal pane with no `cwd`, preserving the split layout.
Returns `TabConfig { name: "My Tab Config", title: custom_title, color, panes, params: HashMap::new() }`.
### 2. `write_tab_config` generalized
Signature changed to accept `base_name: &str` instead of hardcoding `"startup_config"`. Call site in `handle_session_config_completed` updated to pass `"startup_config"`. The new save handler passes `"my_tab_config"`.
### 3. `WorkspaceAction::SaveCurrentTabAsNewConfig(usize)`
Added at `workspace/action.rs:561`. Returns `false` in `should_save_app_state_on_action` — writing a config file doesn't change workspace state.
### 4. `save_config_menu_items` in `TabData`
Added at `tab.rs:340`. Static method gated behind `FeatureFlag::TabConfigs`. Returns a single "Save as new config" item dispatching `SaveCurrentTabAsNewConfig(index)`. Inserted in `menu_items()` between the close-tab section and the color section.
### 5. `save_current_tab_as_new_config` workspace handler
Added at `workspace/view.rs:4969`. Gated behind `#[cfg(feature = "local_fs")]` with a no-op stub for non-local-fs builds. Inline-imports `tab_config_from_pane_snapshot` and `write_tab_config` to match the existing inline import pattern used by `handle_session_config_completed`. Snapshots the tab's pane group, extracts custom title and color, builds the `TabConfig`, writes TOML to `~/.warp/tab_configs/my_tab_config.toml` (collision-safe), and opens the file in the user's editor via `resolve_file_target` + `open_file_with_target`.
Action dispatch wired at `workspace/view.rs:16831`.
## End-to-End Flow
1. User right-clicks a tab → `TabData::menu_items()` runs, includes "Save as new config" (flag-gated).
2. User clicks → `WorkspaceAction::SaveCurrentTabAsNewConfig(tab_index)` dispatched.
3. Workspace handler snapshots the tab's `PaneGroup` via `snapshot()`.
4. `tab_config_from_pane_snapshot` converts the `PaneNodeSnapshot` tree into a flat `TabConfig`.
5. `write_tab_config` serializes to TOML and writes to `~/.warp/tab_configs/my_tab_config.toml`.
6. The file is opened in the user's configured editor.
7. The filesystem watcher detects the new file and adds it to the `+` menu.
## Risks and Mitigations
**Non-terminal pane replacement:** Replacing non-terminal panes with empty terminals is lossy — the user might not expect an empty pane when reopening the config. Mitigated by opening the file in the editor immediately, so the user can see and adjust the result.
**Snapshot timing:** `PaneGroup::snapshot()` is synchronous and captures the current state at call time. If a shell is still initializing, `cwd` might be empty. This is the same risk as the existing launch config save flow, which is acceptable.
## Testing and Validation
### `tab_config_from_pane_snapshot` (unit tests in `session_config_tests.rs`)
- `snapshot_single_terminal_pane` — single leaf produces correct cwd, `is_focused = true`, ID `"p1"`.
- `snapshot_two_pane_horizontal_split` — split root `"p1"` + two leaf children `"p2"` and `"p3"` with correct cwds.
- `snapshot_2x2_grid` — 3 split nodes + 4 leaf nodes = 7 total panes, root is `"p1"`.
- `snapshot_non_terminal_leaf_replaced_with_terminal` — notebook pane replaced with `pane_type = Terminal`, `cwd = None`.
- `snapshot_preserves_custom_title_and_color` — title and color propagated to `TabConfig`.
- `snapshot_round_trip_toml``toml::to_string_pretty``toml::from_str` produces matching `TabConfig`.
### `write_tab_config` with custom base name
- `write_tab_config_custom_base_name` — base name `"my_tab_config"` writes to `my_tab_config.toml`.
- Existing `write_tab_config_creates_file_with_correct_naming` tests updated for new `base_name` parameter.
### Manual verification
- Build and run Warp, create a split layout, right-click → "Save as new config", verify the TOML, open from `+` menu.
## Follow-ups
- **"Save and update config":** Track which tab config a tab was opened from (store path on `TabData` or `PaneGroup`), and overwrite on save.
- **Agent/Cloud pane handling:** Currently all panes save as `type = "terminal"`. Once agent/cloud panes are more common, detect them from the snapshot and save `type = "agent"` or `type = "cloud"`.
+123
View File
@@ -0,0 +1,123 @@
# APP-3709: Auto-Generate Worktree Branch Names
Linear: [APP-3709](https://linear.app/warpdotdev/issue/APP-3709/auto-generate-worktree-branch-names)
## Summary
Replace the placeholder counter-based worktree branch name generator (`worktree-1`, `worktree-2`, …) with a pure function that produces memorable, distinctive names by combining two randomly selected desert/southwest-themed words.
## Problem
The current `generate_worktree_branch_name()` function uses a global `AtomicU32` counter that:
- Produces forgettable, indistinct names (`worktree-1`, `worktree-2`, …).
- Resets to 1 on every app restart, so the same names get reused across sessions.
- Gives no hint about which worktree is which when the user has several open.
Developers often have multiple worktrees active simultaneously. Names like `mesa-coyote` or `obsidian-monsoon` are immediately distinguishable in a tab bar, branch list, or terminal prompt.
## Goals
- Provide a pure, stateless function that returns a random `{word1}-{word2}` branch name drawn from a curated 198-word desert/southwest vocabulary.
- Use this function everywhere a worktree branch name is auto-generated:
1. The "New worktree" modal when the "Autogenerate worktree branch name" checkbox is checked.
2. When a saved worktree tab config with `worktree_name_autogenerated = true` is re-opened from the menu.
- Produce names that are valid git branch names with no further sanitization.
- Guarantee uniqueness: never generate a name that collides with an existing local branch in the target repository.
## Non-goals
- Letting the user configure or extend the word list.
- Changing any UI layout or modal behavior — this is purely a naming-function replacement.
- Persisting a "used names" set across sessions (uniqueness is checked against the repo's actual branch list at generation time).
## Figma
Figma: none (no UI changes).
## User Experience
### Generated name format
Each auto-generated name is two words joined by a hyphen:
```
{word1}-{word2}
```
Both words are drawn uniformly at random from the same 198-word list. The two words must be distinct (no `mesa-mesa`).
Examples: `mesa-coyote`, `obsidian-monsoon`, `saguaro-twilight`, `rimrock-falcon`, `turquoise-arroyo`.
### Word list
198 words across six categories, embedded as a compile-time constant array.
**Landforms & Terrain (50)**
mesa, canyon, arroyo, butte, gulch, plateau, dune, bluff, ridge, ravine, basin, crater, ledge, outcrop, escarpment, badlands, flats, playa, wash, gorge, pinnacle, spire, monolith, arch, chasm, chimney, crag, cuesta, alcove, saddle, rimrock, talus, scree, coulee, caldera, cinder, lava, malpais, pediment, bajada, bolson, inselberg, tepui, mogote, notch, gap, pass, switchback, hogback, caprock
**Desert Plants (35)**
cactus, saguaro, agave, yucca, mesquite, ocotillo, creosote, juniper, pinyon, prickly, cholla, barrel, palo-verde, ironwood, saltbush, brittlebush, lupine, mallow, mariposa, sotol, lechuguilla, candelilla, jojoba, chamisa, rabbitbrush, claret, hedgehog, fishhook, organ-pipe, joshua, tumbleweed, sagebrush, chaparral, manzanita, madrone
**Desert Animals (36)**
armadillo, coyote, roadrunner, jackrabbit, rattler, sidewinder, gila, javelina, pronghorn, bighorn, kit-fox, bobcat, cougar, hawk, vulture, falcon, quail, wren, thrasher, horned-toad, gecko, tortoise, tarantula, scorpion, centipede, kingsnake, coachwhip, racer, ringtail, badger, cottontail, mule-deer, prairie-dog, burrowing-owl, nighthawk, swift
**Minerals
obsidian, flint, granite, quartz, sandstone, limestone, basalt, turquoise, onyx, jasper, agate, garnet, topaz, copper, iron, cobalt, tin, zinc, mica, feldspar, gypsum, calcite, shale, pumice, travertine, petrified, opal, malachite, pyrite, cinnabar
**Southwest Culture & Spanish (24)**
adobe, oz, tinaja, acequia, ramada, portal, ristra, luminaria, mirador, hacienda, viga, latilla, nicho, olla, metate, petroglyph, pictograph, solstice, equinox, siesta, sierra, rio, tierra, cumbre
**Weather & Sky (23)**
monsoon, dust-devil, mirage, sundowner, zephyr, thermal, drought, flash-flood, dry-lightning, haze, shimmer, sundog, corona, twilight, dusk, dawn, starlight, moonrise, ember, smoke, wildfire, brushfire, firestorm
### Where the function is called
1. **New worktree modal** (`new_worktree_modal.rs`): When the user clicks "Open" with the "Autogenerate worktree branch name" checkbox checked, the function is called to produce the branch name for the `git worktree add` command.
2. **Saved worktree tab configs**: When a tab config with `worktree_name_autogenerated = true` on any pane is opened from the menu, the commands' branch-name placeholder is replaced with a freshly generated name so each re-open creates a new worktree.
### Uniqueness guarantee
The generated name must not collide with any existing local branch in the target repository. The function takes the repo path as input and queries the repo's branch list. On collision, it makes up to 2 attempts at the same word count before escalating to more words:
1. Generate a 2-word name (`mesa-coyote`). Up to 2 attempts if taken.
2. If both 2-word attempts collide, generate a 3-word name (`mesa-coyote-obsidian`). Up to 2 attempts.
3. Continue escalating (4 words, up to 5 words max) as needed.
Each additional word exponentially increases the pool of candidates, making exhaustion effectively impossible.
If the repo path is unavailable or the branch list cannot be read (e.g. not a git repo), the function falls back to generating a name without the uniqueness check — the user will see a git error if a collision occurs and can retry.
### Function properties
- **Pure naming core**: The naming function takes a set of existing branch names and returns a name — no I/O. Branch listing is performed by the caller.
- **Stateless**: Uses `rand` for randomness rather than a global counter.
- **Git-safe output**: Every word in the list is already a valid git ref component (lowercase alphanumeric and hyphens only, no leading/trailing hyphens, no consecutive dots or slashes).
## Edge Cases
1. **Hyphenated words**: Words like `palo-verde`, `kit-fox`, `dust-devil` already contain hyphens. A name like `palo-verde-kit-fox` is valid for git and reads naturally.
2. **Deterministic testing**: The function should accept an optional random source so unit tests can assert specific outputs.
3. **Repo with many themed branches**: A user who has generated hundreds of worktrees in the same repo will still have tens of thousands of available pairs. The retry loop handles this transparently.
4. **Non-git directory**: When the repo path doesn't point to a valid git repo, skip the uniqueness check and return a random name. Git will report the error when the worktree command runs.
## Success Criteria
1. `generate_worktree_branch_name()` returns a string matching the pattern `{word}-{word}` where both words come from the 198-word list and are not the same word.
2. Repeated calls produce different names (with overwhelming probability).
3. The generated name does not collide with any existing local branch in the target repository when a repo path is provided.
4. The generated name is used as the branch name in `git worktree add -b {name}` and as the worktree directory name.
5. No global counter or shared mutable state — the function is safe to call from any thread.
6. All 198 words in the list are valid git branch name components.
7. Saved worktree configs with `worktree_name_autogenerated = true` produce a fresh name on each menu open, not the baked-in name from the TOML.
## Validation
- **Unit tests**: Call the function many times, assert format matches `{word}-{word}`, assert both words are in the word list, assert the two words differ.
- **Deterministic test**: Seed the random source and assert a specific output.
- **Uniqueness test**: Mock a set of existing branches, call the function, assert it avoids all of them.
- **Manual test**: Open the "New worktree" modal with autogenerate checked, click "Open" several times — each tab should have a distinct desert-themed branch name visible in the tab title and terminal output.
- **Re-open test**: Save a worktree config, re-open it from the menu — the new tab should use a freshly generated branch name, not the one from the first open.
## Open Questions
(None outstanding.)
+178
View File
@@ -0,0 +1,178 @@
# APP-3709: Auto-Generate Worktree Branch Names — Tech Spec
## Problem
`generate_worktree_branch_name()` in `new_worktree_modal.rs` uses a global `AtomicU32` counter to produce `worktree-1`, `worktree-2`, etc. These names are forgettable, collide across sessions, and provide no uniqueness guarantee against existing branches in the target repo. The product spec calls for a themed naming function that combines two random words from a 198-word desert/southwest vocabulary and guarantees uniqueness by escalating to more words on collision.
## Relevant Code
- `warp_util/src/worktree_names.rs` — pure naming module (word list, `generate_name`, `generate_unique_name`, `generate_worktree_branch_name`)
- `app/src/util/git.rs:57-78``list_local_branches_sync()` (synchronous branch listing)
- `app/src/tab_configs/tab_config.rs:62-91``TabConfigPaneNode` struct (`worktree_name_autogenerated`)
- `app/src/tab_configs/tab_config.rs:139-174``render_tab_config()` (threads `worktree_branch_name` through to resolution)
- `app/src/tab_configs/tab_config.rs:299-324``resolve_pane_node()` leaf branch (template variable substitution)
- `app/src/tab_configs/tab_config.rs:350-403``build_worktree_config_toml()` (TOML generation for both manual and autogenerate modes)
- `app/src/workspace/view.rs:7138-7153``maybe_generate_worktree_name()` (scans panes, fetches branches, generates name)
- `app/src/workspace/view.rs:7163-7230``handle_new_worktree_submit()` (writes TOML, opens config)
- `app/src/workspace/view.rs:4892-4958``open_tab_config_with_params()` / `open_tab_config()` (re-open path)
- `app/src/tab_configs/new_worktree_modal.rs:224-257``try_submit()` (emits `None` for autogenerate, `Some(name)` for manual)
## Current State
**Modal flow**: When the user clicks "Open" with autogenerate checked, `try_submit()` calls `generate_worktree_branch_name()` which returns `worktree-{N}`. The workspace handler then bakes this name directly into the TOML commands (`git worktree add -b worktree-1 ../worktree-1 main`) and writes it to `~/.warp/tab_configs/`.
**Saved config re-open flow**: When a saved worktree config is clicked in the menu, `open_tab_config()``open_tab_config_with_params()``render_tab_config()``resolve_pane_tree()` renders the commands. Currently the branch name is hardcoded in the TOML, so re-opening always tries to create the same branch — which fails if it already exists.
**Branch listing**: `DiffStateModel::get_all_branches()` in `diff_state.rs` runs `git for-each-ref --sort=-committerdate --format=%(refname:short) refs/heads` asynchronously. The `BranchPicker` uses this. There is no synchronous branch-listing utility.
**Rand usage**: The codebase uses `rand::seq::SliceRandom` with `rand::thread_rng()` (e.g. `agent_tips.rs:467`). `rand` is already a workspace dependency in `app/Cargo.toml`.
## Proposed Changes
### 1. Pure naming module in `warp_util`
New file: `warp_util/src/worktree_names.rs`. This module has zero I/O — it takes a set of existing branch names and returns a unique name. Keeping it in `warp_util` isolates it from the `app` crate's compile graph and keeps it trivially testable.
`rand` needs to be added as a dependency of `warp_util` (it's already a workspace dep).
**Word list**: A `const WORDS: &[&str]` array with the 198 desert/southwest words from the product spec, sorted alphabetically within each category section for readability.
**Core function** (pure, deterministic with a seeded RNG):
```rust
fn generate_name(
word_count: usize,
existing_branches: &HashSet<&str>,
rng: &mut impl rand::Rng,
) -> Option<String>
```
- Picks `word_count` distinct words at random from `WORDS`.
- Joins them with `-`.
- If the result is in `existing_branches`, retries (bounded by `MAX_RETRIES_PER_LEVEL = 2`).
- Returns `Some(name)` on success, `None` if all retries collided.
**Escalating uniqueness wrapper** (pure, deterministic with a seeded RNG):
```rust
pub fn generate_unique_name(
existing_branches: &HashSet<&str>,
rng: &mut impl rand::Rng,
) -> String
```
- Starts at `word_count = 2`.
- Calls `generate_name(word_count, existing_branches, rng)`.
- If `None` (all retries collided), increments `word_count` and retries.
- Cap at `word_count = 5` (198^5 ≈ 2.9 × 10^11 possibilities) as a safety bound, then fall back to appending a random numeric suffix.
**Convenience entry point** (uses thread_rng):
```rust
pub fn generate_worktree_branch_name(
existing_branches: &HashSet<&str>,
) -> String
```
- Calls `generate_unique_name(existing_branches, &mut rand::thread_rng())`.
- This is the API call sites use. The `rng`-parameterized version exists for deterministic testing.
### 2. Synchronous git branch listing in `app/src/util/git.rs`
New function alongside the existing async helpers:
```rust
#[cfg(feature = "local_fs")]
pub fn list_local_branches_sync(repo_path: &Path) -> HashSet<String>
```
- Runs `git branch --list --format=%(refname:short)` via `std::process::Command`.
- Returns a `HashSet<String>` of local branch names.
- On failure (not a git repo, git not found, etc.) returns an empty set.
**Why synchronous**: The call sites (`handle_new_worktree_submit`, `maybe_generate_worktree_name` in `open_tab_config`) are synchronous view handlers. `git branch --list` is a local filesystem read (packed-refs + loose refs) that completes in single-digit milliseconds even for repos with thousands of branches. A synchronous `std::process::Command` is the simplest approach and avoids threading async through the view layer.
### 3. Template variable substitution in commands
The `commands` array on `TabConfigPaneNode` already exists. The change is in how commands are rendered at open time.
When `worktree_branch_name` is provided, `render_tab_config` injects it into both the unquoted and quoted Handlebars context maps under the key `autogenerated_branch_name`. The existing `handlebars::render_template(cmd, quoted)` call in `resolve_pane_node` handles substitution — no custom `.replace()` needed. This is consistent with how all other tab config params (`{{branch}}`, `{{repo}}`, etc.) work.
The `commands` array in the TOML uses `{{autogenerated_branch_name}}` as a Handlebars template variable. Users can freely edit the commands — reorder them, add their own (e.g. `gt branch create`, `npm install`), or use `{{autogenerated_branch_name}}` in custom commands. The default commands written by the modal work out-of-the-box without any user editing.
### 4. Plumbing the generated name through `open_tab_config`
Modify `open_tab_config()` in `workspace/view.rs`: before rendering, scan the config's panes for any with `worktree_name_autogenerated = true`. If found:
1. Read the pane's `cwd` to determine the repo path.
2. Call `list_local_branches_sync(repo_path)` to get existing branches.
3. Call `generate_worktree_branch_name(&existing_branches)` to get a fresh name.
4. Pass the generated name into `render_tab_config` (new parameter), which injects it into the Handlebars context so `{{autogenerated_branch_name}}` in commands gets substituted.
`render_tab_config` gains an optional `worktree_branch_name: Option<&str>` parameter. When `Some`, the name is added to the template context; when `None`, no extra context is injected. This keeps the API change minimal for all non-worktree configs.
### 5. TOML generation in `handle_new_worktree_submit`
Change `handle_new_worktree_submit()` in `workspace/view.rs` to write the config with template variables in the commands:
```toml
[[panes]]
id = "main"
type = "terminal"
cwd = "/path/to/repo"
worktree_name_autogenerated = true
commands = [
"git worktree add -b {{autogenerated_branch_name}} ../{{autogenerated_branch_name}} main",
"cd ../{{autogenerated_branch_name}}",
]
```
The `commands` array uses `{{autogenerated_branch_name}}` — the same Handlebars syntax as all other tab config params. Users can edit the TOML to add custom commands (e.g. `gt branch create`, `npm install`) or reorder them.
### 6. Update modal and workspace handler call sites
In `new_worktree_modal.rs`:
- Remove `WORKTREE_COUNTER` and the old `generate_worktree_branch_name()`.
- `try_submit()` emits `worktree_branch_name: None` when autogenerate is on, `Some(name)` when the user typed a name manually. The modal does not generate names — it delegates that to the workspace handler.
In `workspace/view.rs`:
- `handle_new_worktree_submit()` receives `worktree_branch_name: Option<&str>`. When `None`, it calls `list_local_branches_sync` then `generate_worktree_branch_name` to produce a fresh name. This name is used both as the TOML filename hint and as the branch name for the initial open.
- `maybe_generate_worktree_name()` is a shared helper used by both `open_tab_config()` (re-open path) and `handle_tab_config_params_modal_body_event()` (params modal submit). It scans panes for `worktree_name_autogenerated = true`, fetches branches, and generates a name.
### 7. Module registration
Add `pub mod worktree_names;` to `warp_util/src/lib.rs`.
## End-to-End Flow
### Modal flow (new worktree)
1. User clicks "Open" with autogenerate checked.
2. `try_submit()` emits `Submit { repo, branch: "main", worktree_branch_name: None }`.
3. `handle_new_worktree_submit()` receives `None`, calls `list_local_branches_sync(repo_path)` to get existing branches.
4. Passes the branch set to `generate_worktree_branch_name(&branches)`. Naming function generates a 2-word name; on collision, retries at the same word count then escalates.
5. Returns e.g. `mesa-coyote`.
6. Workspace handler writes TOML with `worktree_name_autogenerated = true` and `commands` containing `{{autogenerated_branch_name}}` template variables via `build_worktree_config_toml`.
7. Parses the TOML back into a `TabConfig` and calls `open_tab_config_with_params` directly with the just-generated name — no second generation pass.
### Re-open flow (saved config)
1. User clicks a saved worktree config in the menu.
2. `open_tab_config()` scans panes, finds `worktree_name_autogenerated = true`.
3. Reads `cwd` from the pane to determine the repo path.
4. Calls `list_local_branches_sync(repo_path)``HashSet`.
5. Calls `generate_worktree_branch_name(&branches)` → e.g. `obsidian-monsoon`.
6. Passes the name into `render_tab_config(..., Some("obsidian-monsoon"))`.
7. `render_tab_config` injects `obsidian-monsoon` into the Handlebars context, and `resolve_pane_node` renders `{{autogenerated_branch_name}}``obsidian-monsoon` in all commands via normal Handlebars substitution.
8. New tab opens with the substituted commands.
## Risks and Mitigations
- **Synchronous git call on main thread**: `git branch --list` is a local filesystem read (packed-refs + loose refs). Completes in single-digit milliseconds even for repos with thousands of branches. If this ever becomes a concern, the function can be made async without changing the API surface.
- **Word list staleness**: The 198-word list is a compile-time constant. No runtime mechanism to update it. This is intentional — the list is a curated vocabulary, not a growing dictionary.
## Testing and Validation
- **Unit tests in `warp_util/src/worktree_names.rs`**:
- `generate_name` with a seeded RNG produces deterministic output.
- Generated names match `{word}-{word}` format, both words are in `WORDS`, words are distinct.
- `generate_unique_name` avoids all names in a provided `existing_branches` set.
- Escalation: pre-fill `existing_branches` with enough 2-word combos to force a 3-word name, verify output has 3 words.
- All words in `WORDS` are valid git branch name components (no spaces, no `..`, no control chars, no leading `-`).
- **Unit test for template substitution**: Verify that a pane with `worktree_name_autogenerated = true` and commands containing `{{autogenerated_branch_name}}` produces the expected substituted commands.
- **Unit test for custom commands**: Verify that user-added commands (with and without `{{autogenerated_branch_name}}`) are preserved and substituted correctly.
- **`cargo check`**: Verify no compilation errors after all changes.
## Follow-ups
- Add a test that exercises the synchronous `git branch --list` path against a real temporary git repo.
- Consider supporting a `worktree_path_template` field on the pane for users who want worktrees in a non-default location (e.g. `~/worktrees/{name}` instead of `../{name}`).
+150
View File
@@ -0,0 +1,150 @@
# APP-3713: Vertical Tabs — Primary Info Selector in Settings Popup
## Summary
Replace the placeholder "Group panes by" section in the vertical tabs settings popup with a "Show first" selector that lets users choose which information appears on the primary (top) line of terminal pane rows. The two options are "Command / Conversation" (default, current behavior) and "Directory / Branch" (swaps the current primary and secondary lines). The selected option shows a checkmark. This applies to both expanded and compact view modes.
## Problem
The vertical tabs settings popup currently contains a "Group panes by" section with a single hardcoded "Tab" option that does nothing useful. This section occupies popup real estate without providing value. Meanwhile, users have different workflows: some care most about _what_ is running (the terminal command or agent conversation) while others care most about _where_ they are (the working directory and git branch). There is no way to customize which of these is most prominent in the pane row.
## Goals
- Let users choose whether terminal pane rows prioritize the terminal command / agent conversation title or the working directory / git branch as the primary line.
- Remove the non-functional "Group panes by" section from the settings popup.
- Persist the preference across sessions as a synced cloud setting.
- Apply the preference to both expanded and compact view modes.
## Non-goals
- Adding group-by functionality (the section being removed was a placeholder for future work; re-adding it is a separate concern).
- Changing non-terminal pane rows (code, notebook, settings, etc.) — this only affects terminal pane rows.
- Changing the segmented compact/expanded control in the popup (it remains unchanged below the divider).
## Figma / design references
Figma: none provided
## User experience
### Setting
A new user setting (`VerticalTabsPrimaryInfo`) controls which content appears on the primary line of terminal pane rows. It has two variants:
- **`Command`** (default): Terminal command / agent conversation title is the primary line. Working directory / git branch is the secondary line. This matches the current behavior.
- **`WorkingDirectory`**: Working directory / git branch is the primary line. Terminal command / agent conversation title is the secondary line.
The setting is a synced cloud setting (same sync behavior as `VerticalTabsViewMode`).
### Settings popup layout
The popup replaces the current "Group panes by" section. The new layout is:
```
┌──────────────────────────────────┐
│ Show first │ ← section header (sub-text color)
│ ✓ Command / Conversation │ ← option (selected state shown)
│ Directory / Branch │ ← option
│ ─────────────────────────── │ ← divider (unchanged)
│ [ compact ] [ expanded ] │ ← segmented control (unchanged)
└──────────────────────────────────┘
```
#### Section header
- Text: **"Show first"**
- Styled identically to the current "Group panes by" header: sub-text color, 12px, 16px horizontal padding, 8px bottom margin.
#### Option items
Each option is a single row inside the popup. The currently selected option shows a checkmark icon on the left; the unselected option shows an empty space of the same width (so text stays aligned).
- **"Command / Conversation"**: When selected, terminal pane rows use the current primary/secondary line assignment (terminal command or agent conversation title on top, working directory on the second line).
- **"Directory / Branch"**: When selected, terminal pane rows swap their primary and secondary lines (working directory / git branch on top, terminal command or agent conversation title on the second line).
Clicking an option:
1. Updates the `VerticalTabsPrimaryInfo` setting immediately.
2. The panel re-renders with the new line order.
3. The popup **stays open** (so the user can see the checkmark move and the change take effect, then dismiss manually).
Each option row:
- Has 16px horizontal padding (matching the current "Tab" item).
- Shows the checkmark icon (16×16, main-text color) for the selected option, or a 16×16 transparent spacer for the unselected option.
- 8px gap between the icon/spacer and the label text.
- Label text is 12px in main-text color.
- Has a hover highlight (same `fg_overlay_1` pattern used elsewhere in the popup).
- Cursor changes to pointing hand on hover.
#### Divider and segmented control
Unchanged from the current implementation. The divider separates the "Show first" section from the compact/expanded segmented control.
### Effect on expanded terminal pane rows
#### When `Command` is selected (default — current behavior)
No change from today. The expanded terminal row layout remains:
1. **Primary line** (main text color): terminal title, agent conversation status + title, CLI agent title, or last completed command (per the existing precedence rules from APP-3651).
2. **Secondary line** (sub text color): working directory • git branch.
3. **Tertiary line**: kind badge + badges (unchanged).
#### When `WorkingDirectory` is selected
The primary and secondary lines swap:
1. **Primary line** (main text color): working directory • git branch. Uses the same layout as the current secondary line but rendered in main-text color. Working directory clips from the start; git branch clips from the end.
2. **Secondary line** (sub text color): terminal title, agent conversation status + title, CLI agent title, or last completed command (same precedence rules as the current primary line, but rendered in sub-text color). For agent conversations, the status indicator icon still precedes the title text.
3. **Tertiary line**: kind badge + badges (unchanged).
### Effect on compact terminal pane rows
#### When `Command` is selected (default — current behavior)
No change. The compact row shows the terminal icon + terminal title (or agent status icon + conversation title) as a single line.
#### When `WorkingDirectory` is selected
The compact row shows:
- **Non-agent terminal**: Terminal icon + working directory (instead of terminal title). The working directory clips from the start.
- **Agent terminal (Oz or CLI agent)**: Conversation status icon + working directory (instead of conversation title). The working directory clips from the start.
- **Ambient agent**: `OzCloud` icon + working directory.
#### Icon behavior (both modes)
The kind icon at the start of the compact row is always determined by the pane type and agent state, not by the primary info setting. A non-agent terminal always shows the terminal icon; an agent terminal always shows the conversation status icon; an ambient agent always shows the `OzCloud` icon. Only the *text* portion of the row changes when the setting is toggled.
### Non-terminal panes
Non-terminal pane rows (code, notebook, settings, etc.) are not affected by this setting in either view mode. Their layout remains unchanged.
### Search behavior
The search input already indexes both the primary text and the working directory for terminal panes. This behavior is unchanged — both fields remain searchable regardless of which is shown as the primary line.
## Success criteria
1. The "Group panes by" header and "Tab" item are removed from the settings popup.
2. A "Show first" header appears at the top of the popup with two options: "Command / Conversation" (checked by default) and "Directory / Branch".
3. Clicking "Directory / Branch" moves the checkmark to that option and immediately swaps the primary and secondary lines for all terminal pane rows.
4. Clicking "Command / Conversation" restores the default line order and moves the checkmark back.
5. In expanded mode with "Directory / Branch" selected: the primary line shows the working directory (start-clipped) and git branch in main-text color; the secondary line shows the terminal title or agent conversation title in sub-text color.
6. In expanded mode with "Directory / Branch" selected and an active agent conversation: the secondary line shows the conversation status indicator followed by the conversation title in sub-text color.
7. In compact mode with "Directory / Branch" selected: the single-line row shows the terminal icon + working directory (start-clipped) instead of the terminal title.
8. In compact mode with "Directory / Branch" selected and an agent conversation: the row shows the conversation status icon + working directory.
9. The setting persists across sessions. Quitting and relaunching with "Working directory" selected shows the same line order.
10. The popup stays open after clicking an option, allowing the user to see the change and dismiss manually.
11. The segmented compact/expanded control below the divider is unchanged.
12. Non-terminal panes are unaffected by the setting.
## Validation
- **Manual toggle**: Open the settings popup, switch between "Command / Conversation" and "Directory / Branch". Verify the pane rows update immediately and the checkmark moves.
- **Expanded mode**: With "Directory / Branch" selected, verify the primary line shows the working directory (start-clipped) + git branch in main-text color, and the secondary line shows the terminal title or agent info in sub-text color.
- **Compact mode**: Switch to compact mode with "Directory / Branch" selected. Verify the single-line row shows terminal icon + working directory.
- **Agent panes**: Start an agent conversation. With "Directory / Branch" selected, verify the expanded secondary line shows the status indicator + conversation title. In compact mode, verify the status icon + working directory.
- **Persistence**: Select "Directory / Branch", quit Warp, relaunch, and verify the setting is preserved.
- **Non-terminal panes**: Open a code pane or notebook. Verify changing the primary info setting has no effect on these rows.
- **Search**: With "Directory / Branch" as primary, search for a terminal title string. Verify it still matches (search indexes both fields regardless of display order).
## Open questions
None — all resolved. Agent panes swap uniformly with non-agent terminals (working directory becomes primary when "Directory / Branch" is selected).
+200
View File
@@ -0,0 +1,200 @@
# APP-3713: Tech Spec — Primary Info Selector in Vertical Tabs Settings Popup
## Problem
The vertical tabs settings popup contains a hardcoded "Group panes by" section with a single "Tab" option that has no effect. Per the product spec, this section should be replaced with a "Show first" selector that lets users choose whether terminal pane rows display the terminal command / agent conversation title or the working directory / git branch as the primary (top) line. The setting must affect both expanded and compact view modes and persist across sessions.
## Relevant code
- `app/src/workspace/tab_settings.rs (166-214)``VerticalTabsViewMode` enum and `TabSettings` group; the new setting will live here
- `app/src/workspace/view/vertical_tabs.rs (2004-2143)``render_settings_popup` and the "Group panes by" / "Tab" UI to be replaced
- `app/src/workspace/view/vertical_tabs.rs (1395-1447)``render_terminal_row_content` (expanded mode), assembles primary + secondary + tertiary lines
- `app/src/workspace/view/vertical_tabs.rs (1449-1479)``render_terminal_primary_line_for_view`, builds the primary line element from `TerminalView` data
- `app/src/workspace/view/vertical_tabs.rs (1585-1646)``render_terminal_secondary_line`, builds working directory + git branch line
- `app/src/workspace/view/vertical_tabs.rs (2183-2230)``render_compact_pane_row`, single-line compact rendering for terminal panes
- `app/src/workspace/action.rs (234-235)``ToggleVerticalTabsSettingsPopup` and `SetVerticalTabsViewMode` actions; new action for setting primary info will follow this pattern
- `app/src/workspace/view.rs (16988-17004)` — action handlers for the existing popup actions
- `app/src/workspace/view.rs (18467-18487)` — popup overlay rendering with `Dismiss` wrapper
- `app/src/workspace/view/vertical_tabs_tests.rs` — existing unit tests for primary line data logic
- `app/src/workspace/action_tests.rs` — tests for action `should_save_app_state_on_action`
## Current state
### Setting and persistence
`VerticalTabsViewMode` is a two-variant enum (`Compact`, `Expanded`) registered as a synced cloud setting in `TabSettings`. It uses the `implement_setting_for_enum!` macro with `SyncToCloud::Globally(RespectUserSyncSetting::Yes)` and hierarchy `"appearance.tabs"`. The new primary info setting will follow this exact pattern.
### Settings popup
`render_settings_popup` builds a fixed-width (200px) popup with:
1. A "Group panes by" header (sub-text color, 12px)
2. A "Tab" item (checkmark icon + "Tab" label, always selected)
3. A divider
4. A segmented control for compact/expanded
The "Group panes by" section is purely visual — it dispatches no actions and reads no settings.
### Expanded terminal row rendering
`render_terminal_row_content` builds a three-line column:
- **Primary**: calls `render_terminal_primary_line_for_view``render_terminal_primary_line`, which resolves `TerminalPrimaryLineData` through the precedence cascade (conversation title > CLI agent title > terminal title > last command > "New session"), then renders in main-text color.
- **Secondary**: calls `render_terminal_secondary_line(working_directory, git_branch)`, which renders working directory (start-clipped) + git branch (end-clipped) in sub-text color.
- **Tertiary**: kind badge + right-side badges (unchanged by this feature).
### Compact terminal row rendering
`render_compact_pane_row` calls `render_terminal_primary_line_for_view` with `Some(WarpIcon::Terminal)` as a prefix icon, producing a single-line row with the terminal icon + primary line data text. For agent terminals, the status icon comes from the `StatusText` variant of `TerminalPrimaryLineData`.
### Key render functions and their color contracts
- `render_terminal_primary_line` always uses `main_text_color`.
- `render_terminal_secondary_line` always uses `sub_text_color`.
When swapping lines, we need parameterized color rather than hardcoded main/sub text.
## Proposed changes
### 1. Add `VerticalTabsPrimaryInfo` setting
In `app/src/workspace/tab_settings.rs`, add a new enum and register it in `TabSettings`:
```rust
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsPrimaryInfo {
#[default]
Command,
WorkingDirectory,
}
```
Register with `implement_setting_for_enum!` using the same sync/hierarchy as `VerticalTabsViewMode`:
- `SupportedPlatforms::ALL`
- `SyncToCloud::Globally(RespectUserSyncSetting::Yes)`
- `hierarchy: "appearance.tabs"`
Add `vertical_tabs_primary_info: VerticalTabsPrimaryInfo` to the `TabSettings` group.
### 2. Add `SetVerticalTabsPrimaryInfo` action
In `app/src/workspace/action.rs`:
- Add variant `SetVerticalTabsPrimaryInfo(VerticalTabsPrimaryInfo)` next to `SetVerticalTabsViewMode`.
- In `should_save_app_state_on_action`, add it to the `false` arm (same as `SetVerticalTabsViewMode` — the setting is persisted via the settings framework, not workspace state).
In `app/src/workspace/view.rs`, add the handler in the same block as `SetVerticalTabsViewMode`:
```rust
SetVerticalTabsPrimaryInfo(primary_info) => {
let primary_info = *primary_info;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.vertical_tabs_primary_info.set_value(primary_info, ctx);
});
ctx.notify();
}
```
### 3. Add mouse state handles for option rows
In `VerticalTabsPanelState`, add two new `MouseStateHandle` fields for the popup option rows:
```rust
command_option_mouse_state: MouseStateHandle,
directory_option_mouse_state: MouseStateHandle,
```
Initialize with `Default::default()` in the `Default` impl.
### 4. Replace popup "Group by" section with "Show first" section
In `render_settings_popup`:
**Remove**: The `group_by_header` and `tab_item` variables and their children in the assembled column.
**Replace with**: A "Show first" header and two clickable option rows.
The header reuses the same styling as the current "Group panes by" header but with text `"Show first"`.
Each option row is a `Hoverable` wrapping a `Flex::row` with:
- A 16×16 checkmark icon (`WarpIcon::Check` in main-text color) if selected, or a 16×16 `Empty` spacer if not.
- An 8px gap.
- Label text ("Command / Conversation" or "Directory / Branch") in main-text color, 12px.
On hover: `fg_overlay_1` background, pointing hand cursor.
On click: dispatch `WorkspaceAction::SetVerticalTabsPrimaryInfo(variant)`. The popup does **not** close on click (unlike `ToggleVerticalTabsSettingsPopup`), so the user sees the checkmark move.
Read the current setting via `*TabSettings::as_ref(app).vertical_tabs_primary_info.value()` to determine which option gets the checkmark.
The divider and segmented control below remain unchanged.
### 5. Parameterize text color in terminal line renderers
The existing `render_terminal_primary_line` hardcodes `main_text_color` and `render_terminal_secondary_line` hardcodes `sub_text_color`. To support swapping, add a `text_color: WarpThemeFill` parameter to both functions so the caller controls which color each line uses.
**`render_terminal_primary_line`**: Add `text_color` param. Replace `main_text_color` usage for text rendering with the passed color. The status indicator icon color remains unchanged (it comes from `render_status_element` and is independent of text color).
**`render_terminal_secondary_line`**: Add `text_color` param. Replace `sub_text_color` usage with the passed color.
### 6. Update `render_terminal_row_content` (expanded mode)
Read the primary info setting:
```rust
let primary_info = *TabSettings::as_ref(app).vertical_tabs_primary_info.value();
```
Branch on the setting to decide line order:
- **`Command` (default)**: Call `render_terminal_primary_line_for_view` with `main_text_color` as first child, `render_terminal_secondary_line` with `sub_text_color` as second child. This is the current behavior.
- **`WorkingDirectory`**: Call `render_terminal_secondary_line` with `main_text_color` as first child, `render_terminal_primary_line_for_view` with `sub_text_color` as second child. This swaps the line order and colors.
The tertiary line is always appended last, unchanged.
### 7. Update `render_compact_pane_row` (compact mode)
Read the primary info setting. For terminal panes:
- **`Command`**: Current behavior — call `render_terminal_primary_line_for_view` with `Some(WarpIcon::Terminal)` prefix icon.
- **`WorkingDirectory`**: Render a single-line row with the terminal icon (or agent status icon) + working directory text (start-clipped). This can be done by building a `Flex::row` directly:
- Add the kind icon (terminal icon for non-agent, status element for agent, `OzCloud` for ambient agent) — same icon logic as the current compact rendering.
- Add the working directory text with `ClipConfig::start()`.
The icon is always determined by pane type / agent state, not by the primary info setting.
### 8. Update `render_terminal_primary_line_for_view`
Add a `text_color: WarpThemeFill` parameter. Pass it through to `render_terminal_primary_line`.
All existing call sites pass `theme.main_text_color(theme.background())` to preserve current behavior unless the caller is in the swapped path.
## End-to-end flow
1. User clicks the settings icon button in the vertical tabs control bar → `ToggleVerticalTabsSettingsPopup` is dispatched → `show_settings_popup` toggles to `true` → popup renders via `render_settings_popup`.
2. Popup shows "Show first" header with "Command / Conversation" checked (default). Segmented control below.
3. User clicks "Directory / Branch" → `SetVerticalTabsPrimaryInfo(WorkingDirectory)` is dispatched → `TabSettings.vertical_tabs_primary_info` is updated → `ctx.notify()` triggers re-render.
4. Popup stays open; checkmark moves to "Directory / Branch".
5. All terminal pane rows in the panel re-render: expanded rows swap primary/secondary lines; compact rows show working directory text.
6. Setting is persisted to cloud via the settings sync framework.
7. On next launch, `TabSettings::as_ref(app).vertical_tabs_primary_info.value()` returns `WorkingDirectory`, so rows render in the swapped order immediately.
## Risks and mitigations
- **Color regression**: Parameterizing text color introduces risk of passing the wrong color at call sites. Mitigation: only two call sites per function (one for each branch of the setting match). Unit test the data logic; visual testing catches color regressions.
- **Compact mode icon/text mismatch**: In `WorkingDirectory` compact mode, an agent terminal must still show the status icon (not the terminal icon) but with the working directory text. The icon resolution logic is already separated from the text logic in the current code, so this is straightforward.
- **Search correctness**: `terminal_pane_search_text_fragments` indexes both the primary text and working directory regardless of display order. No changes needed; search stays correct.
## Testing and validation
### Unit tests (`vertical_tabs_tests.rs`)
- Add tests for the "Show first" setting variants confirming `VerticalTabsPrimaryInfo::Command` and `VerticalTabsPrimaryInfo::WorkingDirectory` are `Default` and `Copy`/`Clone`/`PartialEq` as expected.
### Action tests (`action_tests.rs`)
- `SetVerticalTabsPrimaryInfo` should return `false` from `should_save_app_state_on_action` (same pattern as the existing `SetVerticalTabsViewMode` test).
### Manual validation
Per the product spec validation section: toggle between options in the popup, verify expanded/compact rendering, agent panes, persistence across restart, search correctness, and non-terminal pane immunity.
## Follow-ups
- When group-by functionality is implemented in the future, the "Show first" section and the group-by section can coexist in the same popup. The popup layout would then be: "Show first" options → divider → "Group by" options → divider → segmented control.
- A keyboard shortcut for toggling the primary info setting could be added later.
+153
View File
@@ -0,0 +1,153 @@
# APP-3736: Allow specifying tab-close behavior in tab configs
Linear: [APP-3736](https://linear.app/warpdotdev/issue/APP-3736/allow-for-specifying-closing-of-tab-behavior)
## Summary
Add an optional top-level close hook to tab configs so a tab instance opened from a config can run best-effort cleanup behavior when the tab is explicitly closed. The primary use case is worktree cleanup: deleting the worktree directory on close, with an optional second step that also deletes the worktree branch.
## Problem
Tab configs can already create a worktree when a tab opens, but they cannot declare what should happen when that tab is later closed. Users who use tab configs as ephemeral worktree environments have to remember to manually clean up the worktree directory and branch. The current template and bundled `tab-configs` skill teach creation-time worktree commands, but not cleanup.
## Goals
- Let tab config authors opt into tab-close behavior on a per-config basis.
- Make worktree cleanup a first-class documented tab-config pattern.
- Support both cleanup variants:
- remove the worktree only
- remove the worktree and delete the branch
- Use the actual resolved values for the specific tab instance at close time, including params and any auto-generated worktree branch name.
- Update the default template comments and bundled `tab-configs` skill examples to show the new close behavior.
## Non-goals
- Per-pane close hooks.
- Guaranteeing cleanup when Warp crashes, is force-quit, or loses power.
- Guaranteeing cleanup after app restore or undo-close. Close behavior is resolved for the live tab instance when the tab opens and is not persisted into workspace snapshots.
- Managing worktrees created outside the tab config flow.
- New confirmation UI or modal flow for close cleanup.
- Automatically inferring cleanup commands from open-time commands; config authors must declare close behavior explicitly.
## Figma / design references
Figma: none provided.
## User experience
### Authoring the config
Tab configs may optionally include a top-level `[on_close]` table.
- `on_close` applies to the tab as a whole, not to individual panes.
- `[on_close]` may specify:
- `directory` (optional): working directory used for close-time commands.
- `commands` (required when `[on_close]` is present): ordered shell commands to run when the tab closes.
- `directory` and `commands` support the same template variables that tab configs already support for `title`, pane `directory`, and pane `commands`.
- Close-time template expansion uses the values that were used when the tab instance was opened. Warp must not re-prompt for params when the tab closes.
- Template rendering follows the same quoting rules as open-time config rendering: `directory` receives unquoted values so paths remain valid, and `commands` receive shell-quoted values.
### Triggering close behavior
- Close behavior runs when the user explicitly closes a tab instance opened from a tab config, such as from the close button, tab context menu, or keyboard shortcut.
- If a config does not define `[on_close]`, closing behaves exactly as it does today.
- Close behavior runs once per closing tab instance, even if the tab contains multiple panes.
- There is no additional confirmation prompt at close time.
- Close cleanup starts asynchronously during tab close, and the tab is removed immediately without waiting for cleanup commands to finish.
### Command execution semantics
- Warp runs `on_close.commands` in order.
- If a command fails, Warp still closes the tab. Failures are best-effort and do not block close.
- If a command fails, Warp stops running the remaining close commands for that tab instance, logs the failure, and shows a persistent non-blocking error toast.
- If Warp cannot access local shell state for cleanup, close commands are skipped, the tab still closes, and Warp shows a persistent non-blocking error toast.
- For worktree configs, the common pattern is to set `on_close.directory` to the repo root so cleanup does not depend on the tab's live shell state.
### Worktree example: remove the worktree, keep the branch
```toml
name = "New Worktree"
title = "{{worktree_branch_name}}"
[[panes]]
id = "main"
type = "terminal"
directory = "{{repo}}"
commands = [
"git worktree add -b {{worktree_branch_name}} ../{{worktree_branch_name}} {{branch}}",
"cd ../{{worktree_branch_name}}",
]
[on_close]
directory = "{{repo}}"
commands = [
"git worktree remove ../{{worktree_branch_name}}",
]
```
Closing a tab opened from this config removes the worktree directory but leaves the branch in place.
### Worktree example: remove the worktree and delete the branch
```toml
name = "Ephemeral Worktree"
title = "{{worktree_branch_name}}"
[[panes]]
id = "main"
type = "terminal"
directory = "{{repo}}"
commands = [
"git worktree add -b {{worktree_branch_name}} ../{{worktree_branch_name}} {{branch}}",
"cd ../{{worktree_branch_name}}",
]
[on_close]
directory = "{{repo}}"
commands = [
"git worktree remove ../{{worktree_branch_name}}",
"git branch -D {{worktree_branch_name}}",
]
```
Closing a tab opened from this config first removes the worktree and then deletes the associated branch.
### Autogenerated worktree names
If a worktree config relies on an auto-generated branch name, close behavior uses the resolved branch name for that tab instance. Authors can reference that runtime value in `[on_close]` with `{{autogenerated_branch_name}}`, matching the open-time placeholder used by autogenerate worktree configs.
### Template and skill updates
- `app/resources/tab_configs/new_tab_config_template.toml` updates its commented worktree example to show close cleanup.
- `resources/bundled/skills/tab-configs/SKILL.md` updates its schema reference and examples to include `[on_close]`.
- Both documentation surfaces should show both worktree variants:
- remove the worktree only
- remove the worktree and delete the branch
- The branch-deleting example must clearly read as destructive and opt-in.
## Success criteria
1. A tab config without `[on_close]` closes with no behavior change from today.
2. A tab config with `[on_close]` runs its close commands once when the user explicitly closes the tab.
3. Close-time commands use the resolved values from the tab instance that is closing; Warp does not reopen the param modal.
4. A worktree config can remove only the worktree on close while leaving the branch untouched.
5. A worktree config can remove the worktree and then delete the branch on close.
6. If the worktree removal step fails, Warp still closes the tab immediately, shows a persistent error toast, logs the cleanup failure, and does not run later branch-deletion commands.
7. The default tab config template includes a commented example of close cleanup for worktrees.
8. The bundled `tab-configs` skill documentation describes `[on_close]` and includes both worktree cleanup variants.
## Validation
- Unit tests for parsing `[on_close]`, rendering its templated `directory` and `commands`, and preserving per-tab resolved values.
- Unit or integration tests that verify close commands run once per tab close, stop after the first failure, and surface a persistent error toast.
- Manual verification with a real git repo:
- open a config that creates a worktree and removes only the worktree on close
- open a config that creates a worktree and removes both worktree and branch on close
- verify the correct repo, worktree, and branch state after each close
- Manual verification that tabs still close immediately if a cleanup command fails, and that the failure is shown in a persistent toast and logged rather than blocking tab close.
- Manual verification that close cleanup is skipped gracefully if local shell state is unavailable and that a persistent toast is shown.
- Manual verification that the updated template and bundled skill examples are internally consistent and produce valid TOML.
## Open questions
(None outstanding.)
+102
View File
@@ -0,0 +1,102 @@
# APP-3736: Allow specifying tab-close behavior in tab configs — Tech Spec
Product spec: `specs/APP-3736/PRODUCT.md`
## Problem
Tab configs can declare how to open a tab, including pane layout, startup commands, and templated params, but there was no declarative way to run cleanup when a tab created from a config is later closed. This was especially painful for worktree configs, which can create a worktree on open but previously left users to manually remove the worktree directory and branch.
## Relevant Code
- `app/src/tab_configs/tab_config.rs``TabConfigOnClose`, `ResolvedTabCloseBehavior`, `TabConfig::on_close`, `render_tab_close_behavior`, `build_template_contexts`
- `app/src/tab_configs/mod.rs` — exports `render_tab_close_behavior` and `ResolvedTabCloseBehavior`
- `app/src/tab.rs` — stores `resolved_tab_close_behavior` on `TabData`
- `app/src/workspace/view.rs` — resolves close behavior when opening a tab config, runs cleanup during tab close, and executes close commands with `LocalCommandExecutor`
- `app/src/tab_configs/session_config.rs` — initializes `on_close: None` for generated configs that do not define close behavior
- `app/src/tab_configs/tab_config_tests.rs` — parser and template-rendering coverage for `[on_close]`
## Current State
`TabConfig` already supports templated titles, pane `directory`, pane startup commands, and params. Rendering uses two template contexts: unquoted values for paths/titles and shell-quoted values for commands.
`TabData` is the in-memory state container for each tab. Before this change, it tracked pane group state, colors, mouse state, and telemetry flags, but not any tab-config-specific close behavior.
Tab close flows already funnel through `Workspace::close_tabs`, which optionally shows an unsaved-state confirmation dialog, cancels tab renaming, removes tabs in reverse index order, and emits telemetry once tabs are actually closed.
## Changes
### 1. Tab-config schema and resolved close behavior
`TabConfig` now has an optional top-level `on_close: Option<TabConfigOnClose>`.
`TabConfigOnClose` mirrors the close-time command contract:
- `directory: Option<String>` — optional working directory for cleanup commands
- `commands: Vec<String>` — ordered commands to run on tab close
`ResolvedTabCloseBehavior` stores the fully rendered cleanup plan for one opened tab instance:
- `directory: Option<PathBuf>`
- `commands: Vec<String>`
The resolved form is intentionally separate from `TabConfigOnClose` so close-time execution can use the exact values chosen when that tab was opened, without re-reading config files or re-prompting for params.
### 2. Shared template-context builder
`build_template_contexts` now centralizes construction of both template contexts used by tab config rendering:
- `unquoted_context`: raw param values, plus `autogenerated_branch_name` when provided
- `quoted_context`: shell-quoted param values, plus a shell-quoted `autogenerated_branch_name` when provided
`render_tab_config` and `render_tab_close_behavior` both use this helper so open-time and close-time template substitution follow the same quoting rules:
- titles and pane `directory` use unquoted values
- commands use shell-quoted values
This avoids duplicating context setup and preserves existing shell-quoting behavior for command templates with spaces.
### 3. Render and attach close behavior when opening tab configs
`Workspace::open_tab_config_with_params` now resolves both:
- the tab title + pane layout via `render_tab_config`
- the optional close behavior via `render_tab_close_behavior`
After calling `add_tab_with_pane_layout`, the method writes the resolved close behavior into `self.tabs[self.active_tab_index].resolved_tab_close_behavior`. It also applies the tab color from the config as before.
This means each tab instance carries its own rendered cleanup commands, including manual param values and the generated branch name from `maybe_generate_worktree_name`.
### 4. Store close behavior on tab state
`TabData` has a new field:
- `resolved_tab_close_behavior: Option<ResolvedTabCloseBehavior>`
`TabData::new` initializes it to `None`, so ordinary tabs and tabs not opened from configs have no close hook and keep the existing close behavior.
The field is stored only in memory on the live `TabData`; it is not serialized into workspace snapshots.
### 5. Run cleanup from the existing tab-close flow
`Workspace::close_tabs` now collects each closing tab's `resolved_tab_close_behavior` before removing any tabs:
1. Build `tab_indices_vec`
2. Show the existing unsaved-state confirmation dialog if needed
3. Cancel any in-progress tab rename
4. Take (consume) each selected tab's resolved close behavior and mark `cleanup_was_run = true` on the tab
5. Call `run_tab_close_cleanup` for each collected behavior
6. Remove tabs in reverse index order as before
Collecting the close behaviors first avoids depending on tab indices after tabs have been removed.
### 6. Async best-effort cleanup execution on native local-tty builds
On native builds with `local_tty`, `run_tab_close_cleanup` reads the current `LocalShellState` and spawns `execute_tab_close_behavior` with:
- shell type
- shell path
- PATH from the active local shell environment
If `LocalShellState` is unavailable, cleanup is skipped and a warning is logged.
`execute_tab_close_behavior` constructs a `LocalCommandExecutor` and executes the resolved cleanup commands sequentially. For each command, it passes:
- the resolved `directory`, if present
- environment variables containing `HOME` and `PATH` when available
- `ExecuteCommandOptions { run_command_in_same_shell_as_session: true }`
If a command exits successfully, execution continues to the next command. If a command returns a non-zero status, execution stops and returns an error containing the failed command and command output. The spawned callback logs that error with `log::warn!`.
The workspace does not await this task before removing the tab, so cleanup is intentionally best-effort and non-blocking from the user's perspective.
If cleanup fails, or if `LocalShellState` is unavailable, `run_tab_close_cleanup` also adds a persistent `DismissibleToast::error(...)` to the workspace toast stack so the failure is visible without blocking tab close.
On builds without native local-tty support, `run_tab_close_cleanup` is a no-op stub.
### 7. Default `on_close` value for generated configs
`build_tab_config` and `tab_config_from_pane_snapshot` now initialize `on_close: None` so generated startup/session configs and saved-from-live-tab configs preserve current behavior unless a user explicitly edits the TOML to add `[on_close]`.
### 8. Tests
`tab_config_tests.rs` adds coverage for:
- parsing `[on_close]` in a worktree config
- `render_tab_close_behavior` substituting manual params
- `render_tab_close_behavior` substituting `autogenerated_branch_name`
- shell-quoting command params with spaces while leaving pane `directory` unquoted
Existing render tests continue to cover open-time title/layout rendering and fallback behavior for invalid pane trees.
## End-to-End Flow
1. User selects a tab config from the new-session menu.
2. If the config has params, the params modal collects values; otherwise defaults are used. If any pane sets `worktree_name_autogenerated = true`, `maybe_generate_worktree_name` creates a unique branch name.
3. `open_tab_config_with_params` renders the pane template, title, and optional close behavior, opens the tab, and stores the resolved close behavior on the new `TabData`.
4. User later closes that tab.
5. `close_tabs` gathers each tab's resolved close behavior and calls `run_tab_close_cleanup` before removing the tabs.
6. On native local-tty builds, cleanup commands run asynchronously through `LocalCommandExecutor`. Tabs disappear immediately; failures are logged and stop any remaining cleanup commands for that tab.
## Risks and Limitations
- **Single-fire cleanup:** `resolved_tab_close_behavior` is consumed (`.take()`n) before the tab is removed, so re-closing an undo-restored tab does not fire cleanup again. Tabs restored from workspace snapshots also do not recover a prior close hook because the field is not serialized.
- **Undo-close CWD fallback:** when a tab with `cleanup_was_run = true` is restored via undo-close, `restore_closed_tab` iterates its terminal panes and calls `cd_home_if_dir_missing` on each. If the CWD no longer exists (e.g., the worktree was removed by cleanup), the shell falls back to the home directory.
- **Race between cleanup and undo-close:** cleanup runs asynchronously via `ctx.spawn`, so it may not have finished by the time the user triggers undo-close. In that case, the CWD still exists at restoration time and the fallback is a no-op. If the user then runs commands, the directory may disappear mid-session once cleanup finishes.
- **Best-effort async cleanup:** Because tab removal does not wait for cleanup completion, users can immediately create another worktree or branch with the same name and race against the cleanup task.
- **Non-blocking failure toast only:** Cleanup failures show a persistent toast and log a warning, but there is still no blocking retry/undo flow in the close path.
- **Depends on local shell availability:** If `LocalShellState` is unavailable, cleanup is skipped.
- **Command environment is intentionally minimal:** Cleanup gets `HOME` and `PATH`, but not a full clone of the pane's process environment.
## Testing and Validation
- Run targeted tab-config tests covering parser and rendering behavior, especially `[on_close]` and shell-quoting cases.
- Run targeted workspace tests for the close-cleanup failure path to verify persistent error toasts are shown.
- Manually verify a config that removes only a worktree on close.
- Manually verify a config that removes a worktree and then deletes the branch on close.
- Manually verify that a failing cleanup command does not block tab close, shows a persistent error toast, and prevents later cleanup commands from running.
- Manually verify that tabs opened from configs without `[on_close]` keep existing close behavior.
- Run `cargo fmt` and a Rust test/lint pass appropriate for the touched modules before sending the branch for review.
## Follow-ups
- Persist close behavior in tab/workspace snapshots if restored tabs should continue running `[on_close]`.
- Consider generating `[on_close]` automatically in `build_worktree_config_toml` if default worktree configs should clean themselves up without manual edits.
+310
View File
@@ -0,0 +1,310 @@
# APP-3742: Vertical Tabs v2 — Circular Pane Icons and Metadata Slot Rules
## Summary
Redesign the vertical tabs pane item layout with prominent circular per-pane icons and a refined set of per-pane-type rules governing which metadata appears in each "slot" of the item lockup. Both compact and expanded modes get the new icon system and updated slot content.
## Problem
The current vertical tabs pane rows use small inline icons that are hard to distinguish at a glance. Agent sessions, file editors, notebooks, and terminals all look similar until the user reads the text. The new design elevates the pane icon into a prominent, branded circular element that communicates the pane type — and for agent sessions, the agent identity and status — before the user reads any text.
Additionally, the metadata shown per pane type lacks consistent rules: expanded mode shows the same 3-line structure for all panes even when some lines have no meaningful content (e.g. working directory for Settings). This spec defines explicit rules for what each slot contains per pane type.
## Goals
- Replace the current inline pane-type icon with a circular "avatar" icon system that visually distinguishes pane types, agent identities, and agent status at a glance.
- Define deterministic rules for which metadata is shown in each slot for every pane type, in both compact and expanded modes.
- Add an unread-activity indicator (filled dot) for agent panes with new output the user hasn't viewed.
- Ensure pane types without meaningful data for a slot gracefully omit that slot rather than showing empty or misleading content.
## Non-goals
- **Agent status badge design**: The exact set of status badge icons (running, complete, error, etc.) and their colors are defined elsewhere. This spec covers *where* the badge appears, not every status variant.
- **Group headers or group-by changes**: Out of scope.
- **Compact/expanded toggle UI**: Already shipped per APP-3656.
- **Compact mode configuration popup**: The compact mode settings popup (see [compact config mock](https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7363-74353&m=dev)) is out of scope for this spec.
## Figma / design references
- Compact mode: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7363-71448&m=dev
- Expanded mode: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7363-74446&m=dev
- Expanded mode settings popup ("Pane title as"): https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7363-75462&m=dev
### Intentional deviations from Figma
The mocks contain several inconsistencies that are resolved as follows:
1. **"Code editor" pane**: The mock shows a pane titled "Code editor" with a generic `</>` icon and subtitle "Multiple files". There is no separate "Code editor" pane type — this is the Code pane (file editor). The correct title is the active file name (e.g., `view_ui.rs`), and the subtitle should read `and N more` when multiple tabs are open.
2. **"Testing block unfurling"**: The mock uses a file icon but shows "Last updated 5 mins ago by Zach Bai" metadata, which is notebook-specific. This item is a Notebook pane. The icon should be the notebook/file icon, and the metadata is correct for notebooks.
3. **Settings directory line**: The expanded mock shows `~/warp-internal` as the description line for Settings. Settings has no meaningful working directory. The description line should show the active settings page name instead.
4. **"No unsaved changes" text**: The expanded mock shows explicit "No unsaved changes" text for a file pane. This text should not appear. Instead, only show the unsaved-changes dot indicator when the file IS dirty; show nothing when clean.
## User experience
### Layout anatomy
Each pane item consists of a **circular icon** on the left and a **text column** on the right.
**Compact mode** (icon + 2 text lines):
```
[CIRCLE ICON] Title ..................... [indicator]
Subtitle (10px, muted)
```
**Expanded mode** (icon + 3 text lines):
```
[CIRCLE ICON] Title ..................... [indicator]
Description (12px, lighter gray)
metadata-left ............ badges-right
```
Lines that have no content for a given pane type are omitted entirely (the item shrinks vertically). The text column is flexible-width and truncates with ellipsis.
### Circular icon system
The circular icon replaces the current small inline icon. It is the leftmost element in every pane row in both compact and expanded modes.
#### Neutral circle (most pane types)
- Circular background in `fg_overlay_2`
- 16px pane-type icon centered inside
- No status badge
Used for: plain Terminal, Code, File, Notebook, Settings, Workflow, AI Document, AI Fact, MCP Server, Env Var Collection, Environment Management, Execution Profile Editor, Code Diff.
#### Oz agent circle
- Circular background in dark/black (`background` color)
- 10px Oz logo centered inside
- Status badge in the bottom-right corner showing conversation status (clock = running, check = complete, etc.)
Used for: Terminal panes with an active Oz agent conversation.
#### Ambient Oz agent circle
- Same as Oz agent circle but uses the OzCloud icon variant.
Used for: Terminal panes that are ambient agent sessions.
#### CLI agent circle (Claude, Gemini, etc.)
- Circular background in the agent's brand color (e.g., `#e8704e` for Claude)
- 10px agent logo centered inside
- Status badge in the bottom-right corner showing conversation status
Used for: Terminal panes running a recognized CLI agent (Claude Code, Gemini CLI, etc.).
#### Language-specific file icon
For Code panes, the neutral circle's inner icon is replaced with the language-specific file icon derived from the active file's extension (e.g., Rust gear for `.rs`, TypeScript logo for `.ts`). Falls back to the generic `Code2` (`</>`) icon if no language icon is available.
### Status badge
The small overlay anchored at the bottom-right of the circular icon:
- Surrounded by a thin ring matching the panel background (creating a "cutout" effect)
- Contains a 9px status icon:
- `clock_loader`: Agent is running/thinking
- `check`: Agent has completed successfully
- Other icons for error, stopped, etc. (as defined by the existing conversation status system)
- Only shown on terminal panes with an active agent session (Oz or CLI agent)
### "Pane title as" setting
The existing `VerticalTabsPrimaryInfo` setting is extended with a third option and renamed to **"Pane title as"** in the settings popup UI. It controls which piece of terminal metadata occupies the title (line 1). The three options are:
- **Command** (default)
- **Working Directory**
- **Branch**
This setting applies to terminal panes in both compact and expanded modes. Font size and color treatment are coupled to line position, not semantic content: line 1 always uses 12px main text color, line 2 always uses 12px lighter sub-text color.
**Expanded mode mapping:**
In expanded mode, the two remaining metadata categories (the ones not selected as title) are always shown — one as the description (line 2) and one as the metadata left (line 3). There is no additional selection needed.
- **Command**: Title = command/conversation. Description = working directory. Metadata left = git branch.
- **Working Directory**: Title = working directory. Description = command/conversation. Metadata left = git branch.
- **Branch**: Title = git branch. Description = command/conversation. Metadata left = working directory.
**Compact mode mapping:**
In compact mode, only one of the two remaining metadata categories can be shown as the subtitle (line 2). The **"Additional metadata"** setting controls which one is displayed.
### "Additional metadata" setting (compact mode only)
A new synced cloud setting (`VerticalTabsCompactSubtitle`) controls which metadata category is shown as the compact subtitle for terminal panes. The available options depend on the current "Pane title as" selection — the two categories not used as the title are offered as choices.
**Available options per "Pane title as" selection:**
- **Pane title as: Command** → Additional metadata options: Branch (default), Working Directory
- **Pane title as: Working Directory** → Additional metadata options: Branch (default), Command/Conversation
- **Pane title as: Branch** → Additional metadata options: Command/Conversation (default), Working Directory
**Defaults:** Each "Pane title as" selection has a sensible default subtitle so the setting works out of the box:
- Command → Branch
- Working Directory → Branch
- Branch → Command/Conversation
**Settings popup behavior:**
The "Additional metadata" section appears in the settings popup **only when compact mode is active**. When expanded mode is selected, this section is hidden (since expanded mode shows all three metadata categories across its 3 lines).
The section renders as a set of selectable options (same style as "Pane title as") with the header "Additional metadata". Only the two options relevant to the current "Pane title as" selection are shown.
**Subtitle rendering rules:**
- When the subtitle is a git branch: render with `[git-branch icon]` prefix, 10px, sub-text color.
- When the subtitle is a working directory: render as plain text, 10px, sub-text color, clip from start.
- When the subtitle is command/conversation: render using the terminal primary line data (same as title but at 10px sub-text color).
**Persistence:** Synced cloud setting, consistent with `VerticalTabsPrimaryInfo`. If the persisted value is incompatible with the current "Pane title as" selection (e.g., user had Branch subtitle but switches title to Branch), fall back to the default for that title selection.
### Pane-type slot rules
#### Terminal pane
**Icon:** Agent circle (Oz, CLI, or ambient) if an agent session is active; neutral circle with Terminal icon otherwise.
**Compact:**
- **Title:** Determined by "Pane title as" setting. Priority for command/conversation: (1) CLI agent title, (2) Oz conversation title, (3) terminal title.
- **Subtitle:** Determined by "Additional metadata" setting. See the setting section above for the full mapping and defaults.
**Expanded (default — "Pane title as: Command"):**
- **Title:** Same as compact title. Optionally includes the unread-activity dot (see Indicators below).
- **Description (line 2):** Working directory path (e.g., `~/warp-internal`).
- **Metadata (line 3):**
- Left: `[git-branch icon] branch-name`
- Right: diff stats badge (`+N -M`, green/red colored) if the working tree has changes; PR badge (`[GitHub icon] #NNNN`) if a pull request is associated.
**Expanded — "Pane title as: Working Directory":** Line 1 and line 2 swap: the working directory becomes the title and the command/conversation title becomes the description. Metadata row is unchanged.
**Expanded — "Pane title as: Branch":** Line 1 becomes the git branch name. Line 2 becomes the command/conversation title. Line 3 shows the working directory on the left (with git branch icon) and badges on the right.
#### Code pane (file editor)
**Icon:** Neutral circle with language-specific file icon from the active file's extension. Falls back to `Code2` if no match.
**Single file open — Compact:**
- **Title:** Filename (e.g., `shared_sessions.rs`). Includes unsaved-changes dot if dirty.
- **Subtitle:** File path (e.g., `/peterrajani/warp-internal/src`).
**Single file open — Expanded:**
- **Title:** Filename. Includes unsaved-changes dot if dirty.
- **Description:** File path.
- **Metadata:** Omitted.
**Multiple tabs open — Compact:**
- **Title:** Active filename. Includes unsaved-changes dot if any tab is dirty.
- **Subtitle:** `and N more` (where N = total tab count 1).
**Multiple tabs open — Expanded:**
- **Title:** Active filename. Includes unsaved-changes dot if any tab is dirty.
- **Description:** `and N more`.
- **Metadata:** Diff stats badge if available.
#### Notebook pane
**Icon:** Neutral circle with Notebook icon.
**Compact:**
- **Title:** Notebook name.
- **Subtitle:** `Last updated X ago by Author` (if last-updated metadata is available); otherwise the pane's secondary title.
**Expanded:**
- **Title:** Notebook name.
- **Description:** `Last updated X ago by Author` if available; otherwise the pane's secondary title.
- **Metadata:** Omitted.
#### Settings pane
**Icon:** Neutral circle with Gear icon.
**Compact:**
- **Title:** "Settings".
- **Subtitle:** Active settings page name (e.g., "MCP servers", "Appearance", "AI").
**Expanded:**
- **Title:** "Settings".
- **Description:** Active settings page name.
- **Metadata:** Omitted.
#### All other pane types
Applies to: Workflow, AI Document, AI Fact, MCP Server, Code Diff, Env Var Collection, Environment Management, Execution Profile Editor, File (non-code), and any future pane types.
**Icon:** Neutral circle with the pane type's icon (from the existing `TypedPane::icon()` mapping).
**Compact:**
- **Title:** Pane configuration title (falls back to type label, e.g., "Plan", "Workflow").
- **Subtitle:** Pane configuration secondary title (the existing `title_secondary()` value).
**Expanded:**
- **Title:** Pane configuration title.
- **Description:** Pane configuration secondary title.
- **Metadata:** Omitted.
### Indicators
#### Unread-activity dot
- **Visual:** Filled circle icon (`CircleFilled`, 16px) rendered inline with the title text, right-aligned within the title row. Uses a blue/accent color.
- **When shown:** A terminal pane has an agent conversation that produced new output since the user last focused that pane (e.g., agent completed a task, new streaming output arrived).
- **Cleared:** When the user activates/focuses the pane.
- **Scope:** Terminal panes with agent sessions only. Does not apply to plain terminals or non-terminal panes.
#### Unsaved-changes dot
- **Visual:** Same filled circle icon (`CircleFilled`, 16px), inline with the title text, right-aligned. Same visual treatment as the unread-activity dot.
- **When shown:** A Code pane has at least one tab with unsaved changes.
- **Cleared:** When all tabs in the Code pane are saved.
- **No "clean" text:** When there are no unsaved changes, nothing is shown — no "No unsaved changes" text, no indicator.
### Interactions
All row interactions remain unchanged from the current implementation:
- **Click:** Focus the pane.
- **Right-click:** Open the tab context menu.
- **Hover:** Row background highlight.
- **Selected state:** Focused pane in active tab has `fg_overlay_2` background + border.
- **Drag:** Only tab groups are draggable, not individual pane rows.
Diff stats and PR badges in the metadata row are interactive (clickable) — diff stats opens the code review panel, PR badge opens the PR URL in the browser. These behaviors are unchanged.
## Success criteria
1. Every pane row in both compact and expanded modes displays a circular icon to the left of the text column.
2. Plain terminal panes show a neutral circle with Terminal icon. Oz agent terminals show the Oz circle with status badge. CLI agent terminals show the branded agent circle with status badge.
3. Code panes show a language-specific file icon in the neutral circle, falling back to the generic code icon.
4. Terminal compact mode title and subtitle respect the "Pane title as" and "Additional metadata" settings. Default: command/conversation title + git branch subtitle.
5. Terminal panes in expanded mode show working directory as the description line and git branch + badges as the metadata line (default). The "Pane title as" setting with Command, Working Directory, and Branch options correctly controls which data occupies line 1 vs line 2.
6. Code panes with a single file show the filename as title and file path as subtitle/description. Code panes with multiple tabs show the active filename and `and N more`.
7. Notebook panes show "Last updated X ago by Author" as the subtitle/description when available.
8. Settings panes show the active settings page name as subtitle/description, NOT a directory path.
9. Pane types without meaningful metadata for a slot omit that slot — no empty lines or placeholder text.
10. The unread-activity dot appears on agent terminal panes with new unviewed output and clears on focus.
11. The unsaved-changes dot appears on Code panes with dirty tabs and clears on save. No "No unsaved changes" text is shown.
12. Agent status badges (clock, check, etc.) appear on the circular icon for all agent terminal panes and update in real time as the agent status changes.
13. All existing row interactions (click, right-click, hover, drag) and badge interactions (diff stats click, PR badge click) continue to work.
## Validation
- **Icon differentiation:** Open a mix of terminal, agent, code, notebook, and settings panes. Verify each has the correct circular icon variant (neutral vs branded, with or without status badge).
- **Language icons:** Open `.rs`, `.ts`, `.py`, and `.json` files. Verify each gets the appropriate language icon in the circle.
- **Agent status badge:** Start an Oz agent conversation. Verify the clock badge appears while running and changes to check on completion. Start a Claude Code session and verify the orange-branded circle appears.
- **Compact slot content:** Switch to compact mode. Verify terminal shows command + branch, code shows filename + path, settings shows "Settings" + page name, notebook shows name + "Last updated...".
- **Expanded slot content:** Switch to expanded mode. Verify terminal shows 3 lines (command, directory, branch+badges), code shows 2 lines (filename, path), settings shows 2 lines (Settings, page name).
- **"Pane title as" setting:** In expanded mode, switch between Command, Working Directory, and Branch. Verify terminal line 1 content changes to the selected data type. Verify font size/color treatment stays coupled to line position (line 1 is always main text, line 2 is always lighter sub-text). Verify Branch mode omits the redundant git branch from the metadata row.
- **Multi-tab code pane:** Open multiple files in a code editor pane. Verify title is the active filename and subtitle/description reads `and N more`.
- **Unread dot:** Start an agent, switch to another pane, let the agent complete. Verify the blue dot appears on the agent pane. Click the pane — verify the dot clears.
- **Unsaved dot:** Open a code file, make an edit without saving. Verify the blue dot appears. Save the file — verify the dot clears.
- **No "No unsaved changes" text:** Open a code file with no unsaved changes in expanded mode. Verify no third-line metadata text appears.
- **Settings no directory:** Open the Settings pane in expanded mode. Verify the description line shows the settings page name, not a directory path.
## Resolved decisions
1. **Unread-activity tracking:** Existing infrastructure from the notifications modal provides the unread/viewed state for agent conversations. No new tracking state is needed.
2. **Line styling vs content:** Font size and color treatment are coupled to line position, not semantic content. Line 1 always renders as 12px main text, line 2 always renders as 12px lighter sub-text, regardless of which data type (command, directory, branch) occupies each line.
## Open questions
None.
+322
View File
@@ -0,0 +1,322 @@
# 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.
+148
View File
@@ -0,0 +1,148 @@
# APP-3743: Unified New Tab Menu
Linear: [APP-3743](https://linear.app/warpdotdev/issue/APP-3743/new-worktree-ui)
## Summary
Unify the horizontal tab bar's chevron menu and the vertical tab bar's `+` icon menu into a single menu structure. Add a "Worktree in" item with a repo sidecar for one-click worktree creation, including a scrollable search row at the top of the sidecar content and a pinned "Add new repo" footer. On Windows, Terminal gets a sidecar for shell selection; on other platforms Terminal is a simple menu item. The "New Tab Config" item opens the starter TOML template directly as the V0 experience.
## Problem
The horizontal and vertical tab menus are diverging — they show different items, in different orders, with different labels. This is confusing for users who switch between layouts. Additionally, creating a worktree requires opening a modal and filling in multiple fields (repo, branch, checkbox). Power users want a faster flow: pick a repo, get a worktree immediately. Finally, creating a new tab config requires hand-editing TOML — we can instead invoke the `tab-configs` skill to guide the user interactively.
## Goals
- Unify the horizontal chevron and vertical `+` menus into a single item list.
- Add a "Worktree in" item with a searchable repo sidecar for instant worktree creation.
- On Windows, add a Terminal sidecar for shell selection. On macOS/Linux, Terminal is a regular item with the ⌘T shortcut.
- Introduce a default worktree tab config at `~/.warp/default-tab-configs/` that is parameterized by repo and auto-generates the branch name.
- Add a "New Tab Config" menu item that opens the starter TOML template as the V0 experience.
## Non-goals
- Removing the existing New Worktree modal entirely (it may remain accessible via other paths).
- Changing the right-click tab context menu.
- Pixel-perfect submenu styling (the `Menu` component has hardcoded constants; see Known Limitations from APP-3578).
- Implementing nested submenus beyond one level (Terminal submenu and Worktree in submenu are both one level deep from the top menu).
## Figma
- Main menu item (Agent): https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7447-81155&m=dev
- Terminal submenu item (Default): https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7447-82318&m=dev
- Worktree in repo submenu (Search repos): https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7447-83458&m=dev
## User Experience
### Menu unification
The horizontal tab bar's chevron dropdown and the vertical tab bar's `+` button now open the **same** menu with the **same** items. The `toggle_new_session_dropdown_menu` code path no longer branches on `is_vertical_tabs` for item generation — only for positioning and width.
### Top-level menu items (in order)
1. **Agent** — Opens an agent tab. Shows ⌘T keybinding when default session mode is Agent. Icon: `LayoutAlt01`. Hidden if AI is disabled.
3. **Terminal** — On macOS/Linux, opens a terminal tab directly and shows ⌘T when the default session mode is Terminal. Icon: `LayoutAlt01`. On Windows, this is a submenu parent that shows a sidecar with available shells on hover.
3. **Cloud Oz** — Opens a cloud agent tab. Icon: `LayoutAlt01`. Hidden unless `AgentView` + `CloudMode` flags are enabled.
4. **Worktree in** → opens a repo sidecar on hover (see below). Icon: `Dataflow02`.
5. **[User tab configs]** — One item per loaded tab config from `~/.warp/tab_configs/`. Icon: `LayoutAlt01` for non-worktree configs, `Dataflow02` for worktree configs. (Same detection logic as APP-3578.)
6. **Separator**.
7. **New Tab Config** — Auto-runs the `tab-configs` skill. Icon: `Plus`.
### Items removed from both menus
- "Restore Closed Tab" (moved to right-click context menu / keybinding only).
- "Learn about Launch Configs..." link.
- "New Terminal Tab" as a standalone top-level item (replaced by Terminal submenu → Default).
- Launch config items ("Launch {name}") — removed from this menu entirely.
- The split `[+][v]` button in horizontal tabs — replaced by a single button that opens the unified menu.
### Terminal (platform behavior)
On **macOS and Linux**, Terminal is a regular menu item that opens a terminal tab directly. The ⌘T keyboard shortcut is displayed on the item (when the default session mode is Terminal). No submenu or sidecar is shown.
On **Windows**, Terminal is a submenu parent. Hovering it opens a sidecar with a "Default Terminal" row plus available shells (Cmd, PowerShell, WSL, etc.) from `AvailableShells`. The default entry carries the keyboard shortcut and opens the default shell.
### Worktree in sidecar
Hovering "Worktree in" opens a sidecar panel with:
1. **Search repos row** — The first row of the sidecar content is a compact search field labeled "Search repos". It is part of the scrollable content (not pinned), so it scrolls away with the repo list. Typing filters repos by case-insensitive substring match on the repo path.
2. **Known repos list** — Populated from `PersistedWorkspace.workspaces()`, filtered to repos whose path exists and to the current search query. Each item shows the repo path. Icon: `Folder`.
**Clicking a repo**:
1. The system loads the default worktree tab config from `~/.warp/default-tab-configs/worktree.toml`.
2. The `repo` parameter is filled with the selected repo path.
3. The branch name is auto-generated (using the existing `generate_worktree_branch_name()` logic, producing `worktree-1`, `worktree-2`, etc.).
4. The tab config is executed immediately — a new tab opens running `git worktree add` and `cd` commands.
5. The menu closes.
No modal is shown. This is the "fast path" for worktree creation.
**Pinned "Add new repo" footer**: The sidecar keeps an "Add new repo" action pinned to the bottom of the panel while the repo list scrolls independently above it. Clicking it opens a folder picker to register a new repo in `PersistedWorkspace`. After selection, the repo appears in the list.
### Default worktree tab config
A new directory `~/.warp/default-tab-configs/` stores built-in default tab configs that ship with Warp (distinct from user-created configs in `~/.warp/tab_configs/`).
The default worktree config at `~/.warp/default-tab-configs/worktree.toml`:
```toml
name = "Worktree"
[[panes]]
id = "main"
type = "terminal"
cwd = "{{repo}}"
worktree_name_autogenerated = true
commands = [
"git worktree add -b {{branch_name}} ../{{branch_name}}",
"cd ../{{branch_name}}",
]
[params.repo]
type = "repo"
description = "Repository to create worktree in"
```
When invoked from the "Worktree in" submenu, the `repo` param is pre-filled with the selected repo path and the `branch_name` is auto-generated (because `worktree_name_autogenerated = true`). The params modal is skipped entirely.
If this file does not exist at `~/.warp/default-tab-configs/worktree.toml`, it is created on first use from an embedded template (similar to how `new_tab_config_template.toml` works). The file is user-editable — users can customize the worktree commands, add additional panes, etc. Warp does not overwrite user modifications on updates.
### New Tab Config menu item
Clicking "New Tab Config" in the menu writes the starter tab-config template to the next unused file under `~/.warp/tab_configs/` and opens it in the user's configured editor. The filesystem watcher then picks it up and it appears in the menu once saved.
## Edge Cases
1. **No repos in PersistedWorkspace**: The "Worktree in" sidecar still shows the search row and the pinned "Add new repo" footer, with no repo rows in between.
2. **Default worktree config missing**: If `~/.warp/default-tab-configs/worktree.toml` doesn't exist, it is created from an embedded template on first invocation.
3. **AI disabled**: The "Agent" item is hidden. "New Tab Config" still appears and opens the TOML template file directly.
4. **No shells detected (Windows)**: The Terminal sidecar shows a single "Terminal" fallback item.
5. **Worktree creation fails**: If `git worktree add` fails (e.g., branch already exists, not a git repo), the error is shown in the terminal output — same behavior as today when a tab config command fails.
6. **Sidecar positioning**: Sidecars open to the right of the parent item, anchored to the hovered item's position.
7. **Feature flags**: Terminal sidecar shells are gated behind `ShellSelector` (Windows only). Cloud Oz is gated behind `AgentView` + `CloudMode`. Tab configs section and Worktree in are gated behind `TabConfigs`.
## Success Criteria
1. The horizontal chevron menu and vertical `+` menu show identical items.
2. On macOS/Linux, Terminal is a regular item with ⌘T. On Windows, Terminal has a sidecar with a default terminal row plus available shells.
3. "Worktree in" shows a sidecar with a scrollable "Search repos" row, filtered known repos from `PersistedWorkspace`, and a pinned "Add new repo" footer.
4. Typing in the sidecar search field filters repo items live.
5. Clicking a repo in the "Worktree in" sidecar immediately opens a new tab with a worktree, using an auto-generated branch name — no modal.
6. The default worktree tab config exists at `~/.warp/default-tab-configs/worktree.toml` and is created from a template if missing.
7. "New Tab Config" creates and opens the starter template under `~/.warp/tab_configs/`.
8. Generated or saved tab configs appear in the menu via the filesystem watcher.
## Validation
- Open both horizontal and vertical tab menus — verify they show the same items.
- Click "Terminal" — verify a terminal tab opens (macOS/Linux). On Windows, verify sidecar shows the default terminal row plus shells.
- Hover "Worktree in" — verify sidecar shows the search row, known repos, and the pinned footer.
- Type in "Search repos" — verify repo rows filter live and the footer stays pinned.
- Move mouse diagonally toward sidecar — verify safe triangle prevents premature closing.
- Click a repo — verify a new tab opens running `git worktree add` with an auto-generated branch name.
- Click "New Tab Config" — verify a starter tab-config file is created and opened in the configured editor.
- Re-open the menu — verify new tab configs appear in the list.
## Open Questions
(None outstanding — all resolved.)
+165
View File
@@ -0,0 +1,165 @@
# APP-3743: Unified New Tab Menu — Tech Spec
## Problem
The horizontal tab bar's chevron menu and the vertical tab bar's `+` menu showed different items. This change unifies them into a single menu with a "Worktree in" submenu-parent item that shows a sidecar panel on hover, following the proven model picker pattern. The worktree sidecar now includes a scrollable search row at the top of its content, live repo filtering, and a pinned footer for "Add new repo". On Windows, Terminal also gets a sidecar for shell selection; on other platforms it's a regular menu item. Clicking a repo in the "Worktree in" sidecar creates a worktree immediately via a default tab config.
## Relevant Code
- `app/src/menu.rs``Menu`, `MenuItem`, `MenuItemFields`, safe triangle handling, custom item padding overrides, and content padding overrides for sidecar layout
- `app/src/workspace/view.rs``unified_new_session_menu_items()`, `build_menus()`, `build_worktree_sidecar_search_input()`, `build_worktree_sidecar_items()`, `configure_worktree_new_session_sidecar()`, `refresh_new_session_sidecar_for_active_kind()`, `update_new_session_sidecar()`, and sidecar overlay rendering
- `app/src/workspace/action.rs``OpenWorktreeInRepo`, `OpenWorktreeAddRepoPicker`
- `app/src/user_config/mod.rs``default_tab_configs_dir()`, `ensure_default_worktree_config()`
- `app/resources/tab_configs/default_worktree.toml` — embedded worktree template
- `app/src/terminal/profile_model_selector.rs` — reference implementation of the sidecar pattern
## Current State
Before this change, the horizontal tab bar chevron and vertical tab bar `+` button generated different menu items via separate functions (`new_session_menu_items()` and `vertical_tabs_new_session_menu_items()`). There was no submenu/sidecar support for grouping shells under "Terminal" or repos under "Worktree in".
The `Menu` component had a `MenuItem::Submenu` variant (added in PR #13305 by Andrew Sweet, Jan 2025) marked `#[deprecated("Submenus are not ready for use yet")]`. A SafeZone attempt was made and reverted (PRs #15171 / #15422, May 2025). The safe triangle infrastructure was later added (PR #22158, Feb 2026) but only wired up for the model picker's external sidecar approach, never for built-in `MenuItem::Submenu`.
## Key Design Decision: Sidecar vs. MenuItem::Submenu
We initially attempted to use the built-in `MenuItem::Submenu` variant. After extensive debugging, we identified fundamental issues:
1. **No safe triangle wiring**: `MenuItem::Submenu` renders the child menu as an overlay inside the same `Menu` view. The safe triangle infrastructure (`with_safe_triangle()`, `set_safe_zone_target()`) was designed for external callers (model picker), not for internal submenu rendering. Wiring it internally would require the `Menu`'s render method to feed back the submenu panel's bounding rect to the action handler — a cross-concern that doesn't fit the render-then-act model.
2. **Hover event routing**: All submenu item actions (`HoverSubmenuLeafNode`, `Select`) are dispatched to the single `Menu<A>` view. The depth-0 `SubMenu` handles them, but depth-1 submenu items share the same action namespace. `MenuAction::Select` has no depth parameter, so a click on submenu item at row 0 sets `selected_row_index = 0` on the depth-0 menu, highlighting the wrong item ("Agent" instead of the first repo).
3. **Race conditions in hover callbacks**: The `on_hover` callback fired `HoverSubmenuLeafNode` on both hover-in AND hover-out (due to `is_hovered || is_enabled`). When moving between items, the unhover event from item A fired after the hover-in event from item B, resetting the selection back to A. (We fixed this specific bug: `is_hovered` only.)
4. **`UnhoverSubmenuParent` immediately closes the submenu**: When the mouse leaves a submenu parent to move toward the child panel, intermediate items trigger `HoverSubmenuLeafNode` which closes the submenu. Without a safe triangle, diagonal mouse movement is impossible.
**Decision**: Pivot to the model picker's proven sidecar pattern — two separate `Menu` views managed by the Workspace, with the existing safe triangle infrastructure wired up externally.
## Proposed Changes
### 1. Unified menu items (`unified_new_session_menu_items`)
Replaced both `new_session_menu_items()` and `vertical_tabs_new_session_menu_items()` with a single function. Menu order: Agent → Terminal → Cloud Oz → Worktree in (submenu parent) → [user tab configs] → separator → New Tab Config.
On macOS/Linux, Terminal is a regular `MenuItemFields::new("Terminal")` with `AddTerminalTab` as its action and the ⌘T shortcut. On Windows (`#[cfg(target_os = "windows")]`), Terminal itself is a submenu parent using `MenuItemFields::new_submenu()` — this shows a sidecar with a "Default Terminal" row plus available shells on hover.
"Worktree in" uses `MenuItemFields::new_submenu()` which sets `has_submenu = true` → renders a chevron `>` indicator. It has no `on_select_action` since it's activated by hover, not click.
### 2. Sidecar menu (`new_session_sidecar_menu`)
New field on `Workspace`: `new_session_sidecar_menu: ViewHandle<Menu<WorkspaceAction>>`. Created in `build_menus()` as a plain `Menu::new()` with width `NEW_SESSION_SIDECAR_WIDTH` (currently 300px), scrollable variant, and max height 400px.
### 3. Main menu configuration
The main `new_session_dropdown_menu` is created with `.with_safe_triangle().with_ignore_hover_when_covered()`. This enables:
- **Safe triangle**: Suppresses `HoverSubmenuLeafNode` events when the mouse is moving within the triangular safe zone toward the sidecar panel.
- **Ignore hover when covered**: Prevents depth-0 items under the sidecar overlay from firing hover events.
### 4. Hover-driven sidecar orchestration
In `handle_new_session_menu_event`, on `MenuEvent::ItemHovered` or `MenuEvent::ItemSelected`:
1. Read `menu.hovered_index()` (not `selected_index()` — see bug fix below) and the hovered item's label.
2. If label is "Terminal" (Windows only, `#[cfg(target_os = "windows")]`): populate sidecar with available shells.
3. If label is "Worktree in": populate sidecar with a custom search row, filtered repos from `PersistedWorkspace`, and a pinned "Add new repo" footer.
4. If label is None (separator): hide sidecar.
5. Otherwise: hide sidecar, clear safe zone and `submenu_being_shown_for_item_index`.
6. If hovered is None (mouse left menu, possibly onto sidecar): keep current state.
7. Read the sidecar panel's rect from the previous frame via `element_position_by_id_at_last_frame(window_id, "new_session_sidecar")`.
8. Set `main_menu.set_safe_zone_target(sidecar_rect)` and `main_menu.set_submenu_being_shown_for_item_index(Some(hovered_index))`.
### 5. Worktree search row and filtering
The worktree sidecar owns three new pieces of state on `Workspace`:
- `new_session_sidecar_kind` — tracks whether the current sidecar is Terminal or Worktree
- `worktree_sidecar_search_editor` — a dedicated `EditorView`
- `worktree_sidecar_search_query` — the current filter text
`build_worktree_sidecar_search_input()` constructs the single-line editor and subscribes to:
- `EditorEvent::Edited(_)` → updates `worktree_sidecar_search_query` and rebuilds the active sidecar
- `EditorEvent::Escape` → clears the query, clears the buffer, and rebuilds the sidecar
`build_worktree_sidecar_items()` prepends a custom first row implemented via `MenuItemFields::new_with_custom_label(...)`. This row is:
- non-interactive
- not hover-highlighted
- rendered with custom menu item padding overrides
- styled as a compact bordered search field
The search row is part of the normal scrollable menu content rather than a pinned header, so it scrolls away with the repo list. Repo rows are filtered by checking whether the lowercased repo path contains the lowercased trimmed query string.
### 6. Sidecar rendering
### 5. Sidecar rendering
When `show_new_session_sidecar` is true, the workspace render adds a positioned overlay child:
- Anchored to the hovered item's `SavePosition` (each `MenuItemFields` wraps its element in `SavePosition(label)` at render time).
- Wrapped in `SavePosition("new_session_sidecar")` so the safe zone rect can be read on the next frame.
- Positioned at TopRight → TopLeft with 4px gap.
### 7. Pinned footer and menu layout tweaks
The worktree sidecar keeps "Add new repo" visible via `set_pinned_footer_builder(...)` on the sidecar menu. The repo list scrolls independently above it.
To match the current design:
- the menu uses `set_content_padding_overrides(Some(0.), None)` so the first content row sits flush with the top of the sidecar
- the search field uses top-only rounded corners
- the pinned footer uses bottom-only rounded corners
Supporting this required a small `Menu` enhancement in `app/src/menu.rs` to allow depth-0 content top/bottom padding overrides in addition to the existing per-item padding overrides.
### 8. Sidecar event handling
### 6. Sidecar event handling
On `MenuEvent::Close { via_select_item: true }`: item clicked in sidecar → also close the main menu.
On `MenuEvent::Close { via_select_item: false }`: dismissed without selecting → hide sidecar only.
### 9. Worktree-in-repo action
`OpenWorktreeInRepo { repo_path }`: loads `~/.warp/default-tab-configs/worktree.toml` (created from embedded template if missing), substitutes template variables, and opens the tab immediately.
The worktree template parameterizes the pane type via `{{pane_type}}` (instead of hardcoding `type = "terminal"`). The `open_worktree_in_repo` handler reads the user's `DefaultSessionMode` setting and sets `pane_type` to `"agent"` when AI is enabled and the default is Agent, or `"terminal"` otherwise. This means worktree sessions respect the user's preference — if they prefer Agent mode, the worktree opens in Agent mode.
**Important**: Template variables (`{{repo}}`, `{{branch_name}}`, `{{pane_type}}`) are substituted in the raw TOML string BEFORE parsing into `TabConfig`, because the TOML deserializer validates enum fields like `type` against known variants (`terminal`, `agent`, `cloud`) and would reject `{{pane_type}}` as invalid.
Params substituted: `repo` (selected path), `branch_name` (auto-generated via `generate_worktree_branch_name()`), `pane_type` (from default session mode). On macOS, the data directory is channel-specific (`~/.warp-local/` for Local, `~/.warp/` for Stable).
### 10. Bug fixes in `menu.rs` and search-row layout
**on_hover race condition**: Changed `MenuItemFields::render` on_hover callback from `is_hovered || is_enabled` to `is_hovered` only. Previously, when a leaf node was unhovered (`is_hovered=false`), it dispatched `HoverSubmenuLeafNode` with a stale `row_index`. If this event was processed after the entering item's hover event, it overwrote `hovered_row_index` with the wrong value — causing the sidecar to not show when entering a submenu parent from above. Continuous position tracking for the safe triangle is handled by the separate `on_mouse_in` handler.
**`hovered_index` over `selected_index`**: `update_new_session_sidecar` uses `hovered_index()` (not `selected_index()`) as the source of truth. `hovered_row_index` accurately tracks the mouse and survives `reset_selection()` (which only clears `selected_row_index`/`selected_item_index`). Previously, `selected_index` got stuck on a submenu parent because `UnhoverSubmenuParent` was suppressed when `submenu_being_shown_for_item_index` was set — the sidecar persisted even when the user moved to unrelated items.
**Removed `UnhoverSubmenuParent` suppression**: The blanket suppression of `UnhoverSubmenuParent` when `submenu_being_shown_for_item_index.is_some()` was removed. The safe triangle already handles diagonal mouse movement toward the sidecar (by suppressing `HoverSubmenuLeafNode`). The blanket suppression was redundant and prevented `selected_index` from ever clearing when the user moved away.
**Finite-width search editor layout**: The custom worktree search row originally rendered the editor through an extra clipped container path that could hand the editor an infinite width constraint at runtime. The final implementation uses the same `icon + Shrinkable::new(1., ChildView::new(&editor))` pattern used elsewhere in the codebase, which keeps the search field visually compact while ensuring the editor receives a finite width.
## End-to-End Flow
1. User clicks `+` button (vertical tabs) or chevron (horizontal tabs).
2. `toggle_new_session_dropdown_menu()` calls `unified_new_session_menu_items()` and populates the main menu.
3. User hovers "Worktree in" → `MenuEvent::ItemHovered` fires → `update_new_session_sidecar()` reads `hovered_index()`, sees label is "Worktree in", populates sidecar with repos, sets safe zone target, shows sidecar overlay.
4. Sidecar renders a scrollable search row followed by repo items, with a pinned footer at the bottom.
5. User types in the search field → `EditorEvent::Edited(_)` updates `worktree_sidecar_search_query``refresh_new_session_sidecar_for_active_kind()` rebuilds the sidecar with filtered repo rows.
6. User moves mouse toward sidecar → safe triangle suppresses intermediate `HoverSubmenuLeafNode` events → sidecar stays visible.
7. User clicks a repo in sidecar → `OpenWorktreeInRepo` action dispatched → sidecar close event fires with `via_select_item: true` → main menu also closes.
## Risks and Mitigations
- **Safe zone first-frame delay**: On the first hover that opens a sidecar, the sidecar rect from the previous frame is `None` (panel wasn't rendered yet). The safe zone is set to `None`, meaning the first frame has no safe triangle protection. On the next frame, the rect is available. Mitigation: the delay is one frame (~16ms), imperceptible in practice.
- **Label-based item identification**: The hover handler identifies submenu parents by comparing the hovered item's label string ("Terminal", "Worktree in"). If labels change, the sidecar won't show. Mitigation: these are hardcoded UI strings unlikely to change without updating the handler.
- **Sidecar dismiss**: Clicking outside both menus triggers the main menu's `Dismiss` handler, which closes everything. The sidecar's own `Dismiss` is not active since it's not wrapped in one — it's a positioned overlay within the main menu's dismiss scope.
## Testing and Validation
- Build check: `cargo check -p warp` passes with no errors.
- Manual testing: Open both horizontal and vertical tab menus → verify identical items. On macOS/Linux: click Terminal → verify terminal tab opens. On Windows: hover Terminal submenu parent → verify sidecar shows the default terminal row plus shells.
- Hover Worktree in → verify the sidecar shows the search row, repo items, and pinned footer.
- Type into "Search repos" → verify repo rows filter live and the footer remains pinned.
- Move mouse diagonally to sidecar → verify safe triangle prevents premature closing.
- Click a sidecar item → verify the action fires and both menus close.
- Verify that existing menus (tab right-click, overflow, model picker) are unaffected by the `is_hovered` fix and menu padding overrides.
## Follow-ups
- **New Tab Config skill invocation**: V0 opens the TOML template. Follow-up: auto-invoke the `tab-configs` skill via Oz agent.
- **`MenuItem::Submenu` cleanup**: The built-in submenu variant remains in the codebase (deprecated). Consider removing it or completing the safe-triangle wiring if a future use case requires inline submenus.
- **Sidecar left-fade for long paths**: `ClipConfig::start()` exists in the text layout system but right-aligns the text. A proper left-aligned + left-fade clip mode would need UI framework work.
- **macOS/Linux shell selector**: Currently only Windows shows the Terminal sidecar with shell choices. If shell selection is desired on other platforms, this can be re-enabled by removing the `#[cfg(target_os = "windows")]` gate.
+45
View File
@@ -0,0 +1,45 @@
# APP-3773: Image attachments in the feedback skill
Linear: https://linear.app/warpdotdev/issue/APP-3773/add-support-for-image-uploads-in-the-feedback-skill
## Summary
When a user runs `/feedback` with one or more image attachments, the drafted GitHub issue reflects what's in those images, and the user is led through a simple flow that results in the images rendering inline on the filed issue. The first phase of this feature does not upload images from Warp itself; it uses the GitHub web UI's native drag-and-drop upload as the delivery path. Later phases may adopt first-party `gh` CLI attachment support or a Warp-hosted persistent image service. See "Future delivery paths" for what changes for the user under each alternative.
## Problem
Today, attaching an image to a slash-command invocation silently drops the image before the skill's agent runs. The `/feedback` skill therefore files issues that make no reference to screenshots the user tried to attach, even though in-app feedback about visual bugs is one of the highest-value attachment use cases. Fixing this has two parts: the attachments must reach the skill's agent at all, and the drafted issue must end up with the images actually rendered on GitHub.
## Behavior
1. When the user invokes the feedback skill (for example via `/feedback`) with one or more pending image attachments, the agent receiving the skill context sees those images as multimodal input, exactly as it would in a non-skill query.
2. The agent uses what it can see in the attached images when drafting the issue. At minimum, it describes the relevant visual content in the issue body (for example, in a "Problem" or "Actual behavior" section) so the report is coherent even if no image is attached to the final GitHub issue.
3. When one or more images are attached, the skill chooses the browser-based filing path over the `gh issue create` path, regardless of whether `gh` is installed and authenticated. This is the only behavior that differs from the no-attachments case at the filing step.
4. When zero images are attached, the existing filing behavior is unchanged: `gh issue create` is used when available, and the browser fallback is used otherwise.
5. In the browser path with images, the drafted issue body includes one visible placeholder per attached image, positioned in the artifacts/screenshots area of the body. Each placeholder is clearly a placeholder to a human reader (for example, a single line such as `_Paste screenshot here_`) so the user knows where to drop the file.
6. The skill's final agent-authored response to the user explicitly states that (a) the new-issue page has been opened in their browser, (b) they should paste or drag their attached image(s) into the body at the placeholder line(s), and (c) they should review and submit the issue to complete filing. It does not claim the issue has been filed until the user submits.
7. After the user drops or pastes an image into the issue body in the browser, the image renders inline in the filed GitHub issue using GitHub's standard `user-attachments` URL. Warp does not produce, host, or embed the image itself, and does not require any new backend.
8. When the drafted body is short enough to fit in the new-issue URL's prefill capacity, the body (including placeholders) is prefilled in the URL, and the user only needs to paste/drop images and submit.
9. When the drafted body exceeds the URL prefill capacity, the existing clipboard fallback applies: only the title is prefilled, the body is copied to the system clipboard, and the user is told (in the agent's response) to paste the body into the issue form first, then paste/drop their image(s) into the placeholder line(s), then submit.
10. When the system cannot open a browser (for example, a headless Linux session with no display server), the skill falls back to the `gh issue create` CLI flow. If `gh` is installed and authenticated, the issue is filed programmatically with the available text contents and a clear message informs the user that the browser could not be opened and that image attachments were not uploaded to the filed issue. If `gh` is also unavailable, filing fails with a clear error message that notes both failures. No image is silently lost without acknowledgement in either case.
11. No image bytes are written to disk by the feedback skill or its helper script. There is no temp-file folder, no cleanup requirement, and no privacy footprint beyond what already exists for the user's original attachment.
12. The user is never asked to re-select or rebrowse for their image. They paste or drag the image they already attached to Warp. If the image is no longer available on their system (clipboard overwritten, file deleted), they can still submit the issue with the agent's description of the image and no rendered screenshot — the issue is degraded but still useful.
13. When multiple images are attached, the drafted body contains one placeholder per image in the order the agent encountered them. The user can drop images in any order into any placeholder; the feature does not require per-image matching.
14. Attaching images does not change the issue's classification, title format, label (`in-app-feedback`), or target repository (`warpdotdev/warp-external`). It only changes the filing path and the body's artifacts section.
15. Duplicate-issue detection runs before filing regardless of whether images are attached. If a likely duplicate is found, no new issue is filed, no browser is opened, and the user is pointed at the existing issue — the image-specific flow is short-circuited.
16. No telemetry is captured from this feature. The feedback skill does not emit events when images are attached, when the browser is forced, when the user drops images in the browser, or when filing succeeds or fails. The feature's usage and outcomes are not measured by Warp, and neither the drafted issue nor the helper script's exit payload is exfiltrated to a telemetry backend. If future measurement is wanted, it is a separate, explicitly-scoped change and not an invariant of this spec.
17. The change is scoped to the feedback skill's filing flow. Other slash commands, other skills, and non-skill agent queries are unaffected by any skill-specific body or script changes. The underlying platform fix that lets skill invocations see user-attached images may benefit other skills as a side effect, but no other skill's behavior is redefined by this spec.
18. The user-visible outcome — an issue filed to `warpdotdev/warp-external` whose attached images render inline on the final issue — is stable across delivery paths. The issue's title format, body structure, classification, label (`in-app-feedback`), target repository, and duplicate-detection behavior do not change if the underlying delivery path changes in a later phase.
19. When a later delivery path removes the need for a manual drag-and-drop step, the agent's final response stops instructing the user to drop images, the drafted body stops containing placeholder lines, and image-bearing feedback completes in a single agent turn. The user is never asked to do work that a later delivery path has made unnecessary.
20. Selection between available delivery paths is not user-visible and does not require user configuration. When more than one path is available, the feature uses whichever completes filing with the fewest user actions. The user is not asked to pick a delivery path, and no Warp setting governs the choice.
## Future delivery paths
The current drag-and-drop design is a deliberate phase-1 choice. It exists because there is no first-party GitHub API for attaching images to issues via `gh`, and Warp's existing server-side image storage (ambient-agent inputs, AI conversation artifacts) uses private GCS buckets with short-lived presigned URLs and task-scoped authorization — none of which are suitable for permanent embedding in public GitHub issues. Two alternative delivery paths are anticipated for later phases. Each preserves the invariants in Behavior and only changes how the image reaches GitHub.
### First-party `gh` CLI attachment support
If GitHub later exposes a public API for attaching images during issue creation (for example, a `gh issue create --attach <path>` flag) or equivalent coverage in the GitHub MCP server, the feedback skill should use that path in place of the forced browser flow.
User-visible effects under this path:
- No browser is opened for image-bearing feedback; filing completes in a single agent turn, matching today's text-only happy path.
- The drafted body contains no placeholder lines for images.
- The agent's final response reports the filed issue URL directly and does not instruct the user to paste or drop anything.
- The filed issue's attached images render the same way they do today in drag-and-drop-authored issues (standard `user-attachments` URLs or whatever shape the new API produces), so existing issues remain indistinguishable from new ones visually.
### Warp-hosted persistent image service
If Warp later stands up a public, long-lived, unauthenticated image-hosting service distinct from today's ambient-agent and conversation-artifact buckets — one that returns stable URLs suitable for GitHub markdown embedding — the skill should upload each attached image through that service and embed `![alt](<persistent-url>)` directly in the drafted issue body before filing.
User-visible effects under this path:
- Filing completes in a single agent turn. No browser is opened, no drag-and-drop is required, and the drafted body contains rendered image references rather than placeholders.
- The filed issue shows rendered images on first load without any user action beyond running `/feedback`.
- If the hosted service later removes or expires an image, the filed issue will degrade to a broken image reference. The service must be designed with retention that matches or exceeds GitHub issue longevity to satisfy invariant 18 (stable user-visible outcome); any tighter retention is a regression relative to today's `user-attachments` lifetime and is out of scope for this feature.
- Users are not asked to opt in to their images being uploaded to a Warp-hosted service; the skill's privacy posture and user-visible consent flow for feedback submission remains the same as it is for text-only `/feedback` today. Any consent or review step added here applies uniformly, not only to image-bearing feedback.
### Cross-path invariants
Across all current and future delivery paths, the feedback workflow — classification, clarifying questions, grounded references, duplicate detection, issue structure, and the `in-app-feedback` label on `warpdotdev/warp-external` — is unchanged. Only the final filing step and the corresponding final user-visible message differ. If any path cannot deliver the image to the filed issue for any reason, the failure is surfaced explicitly to the user rather than silently producing an image-less issue that claims success (invariant 10 continues to hold: when the browser cannot be opened, the fallback to `gh issue create` files the text-only issue and explicitly tells the user the images were not uploaded).
+64
View File
@@ -0,0 +1,64 @@
# APP-3773: Image attachments in the feedback skill — technical plan
Linear: https://linear.app/warpdotdev/issue/APP-3773/add-support-for-image-uploads-in-the-feedback-skill
See `PRODUCT.md` for user-facing behavior.
## Context
This feature touches three surfaces: the Warp client's slash-command → skill invocation pipeline, the bundled feedback skill's instructions, and the feedback skill's filing helper script.
The skill-invocation entry point is in the slash-command controller. `app/src/ai/blocklist/controller/slash_command.rs:74` builds the context that accompanies every slash-command request:
```rust path=null start=null
let context = input_context_for_request(
/* is_user_query = */ false,
controller.context_model.as_ref(ctx),
...
);
```
The `is_user_query: false` argument is the root cause of the "attachments don't reach the skill" bug. `input_context_for_request` in the same module delegates to `BlocklistAIContextModel::pending_context` in `app/src/ai/blocklist/context_model.rs`, which gates user-attached items — including `AIAgentContext::Image` entries built from `pending_attachments` — behind that flag. Non-slash-command user queries pass `true` here; all slash commands, including `InvokeSkill`, pass `false`. As a result, pending images, pending selected text, pending context blocks, and auto-attached agent-view blocks are stripped from the skill's context before it reaches the agent.
The relevant image carrier is `ImageContext` in `app/src/ai/agent/mod.rs:1893`:
```rust path=null start=null
pub struct ImageContext {
pub data: String, // base64-encoded image data
pub mime_type: String,
pub file_name: String,
pub is_figma: bool,
}
```
It holds an in-memory base64 blob and a filename string, but no on-disk path. This is why the filing script cannot receive a path to the attached image — there isn't one. The scoped design in `PRODUCT.md` avoids this by never passing image bytes or paths to the script at all.
The feedback skill lives at `resources/channel-gated-skills/dogfood/feedback/` and ships two artifacts that matter here: `SKILL.md` (agent instructions) and `scripts/file_feedback_issue.py` (the filing helper). The script's top-level control flow is in `main()`:
- If `gh_path_if_authenticated()` returns a usable path, call `create_issue_with_gh` and print a `created` (or `failed`) result.
- Otherwise, print an `unavailable` result and exit.
The filing script currently has no concept of "attachments are present," no browser path at all, and no way for the agent to pick a filing method explicitly. It also exposes a `--dry-run` flag that no caller actually uses.
## Proposed changes
Three landable changes, ordered by how independently each can ship.
### 1. Platform: let skill invocations see user-attached images
In `app/src/ai/blocklist/controller/slash_command.rs`, change the `is_user_query` argument passed to `input_context_for_request` for `SlashCommandRequest::InvokeSkill` specifically. Other slash-command variants continue to pass `false`.
There are two reasonable shapes; pick the narrower one:
- **Preferred:** Branch on `SlashCommandRequest::InvokeSkill` when building `context` and pass `true` only for that variant. Smallest behavior change; no new parameters on `input_context_for_request`.
- **Alternative:** Add an `include_user_attachments: bool` parameter to `input_context_for_request` (or to `pending_context`) that is orthogonal to `is_user_query`, and pass `true` for `InvokeSkill`. Use this only if we discover we want images but not blocks or selected text on skill invocations. The product spec doesn't currently require that separation, so the preferred path is simpler.
Reads / writes on `pending_attachments` in `BlocklistAIContextModel` do not need to change. `pending_context` already emits `AIAgentContext::Image(image.clone())` for each `PendingAttachment::Image` entry; the fix just stops hiding that output behind the slash-command gate.
No server-side change is required. `AIAgentContext::Image` is already serialized over the existing multi-agent API and rendered as multimodal input to the model.
### 2. Script: required `--use {gh|browser}` flag in `file_feedback_issue.py`
Replace the current implicit fallback logic in `main()` with an explicit, caller-selected method:
- New CLI argument: `--use` (required, `choices=["gh", "browser"]`, `dest="use_method"`). The caller (the skill) must pass one of the two values; `argparse` enforces the constraint and rejects anything else.
- Remove the previously-existing `--dry-run` flag. The skill does not use it, and leaving it in creates an unused branch the caller has to reason about.
- Split `main()` into two helper functions: `file_with_gh(title, body)` for the `--use gh` path and `fallback_to_browser(title, body)` for the `--use browser` path. `main()` becomes a thin dispatcher: `if args.use_method == "browser": fallback_to_browser(...)` else `file_with_gh(...)`.
- `file_with_gh` preserves today's behavior: returns `status: "created"` on success, `status: "unavailable"` when `gh` is missing or unauthenticated, and `status: "failed"` with a `gh_error` on error. It does not automatically fall back to the browser; if the caller wants browser, it must pass `--use browser`.
- `fallback_to_browser` is simplified. It no longer takes a `has_attachments` parameter: the browser path is only used by the skill when image attachments are present, so its user-facing `message` text and failure errors always reference pasting/dropping screenshots. The URL-prefill vs. body-in-payload branching is preserved: when the full URL would exceed `MAX_PREFILL_URL_LENGTH`, the body is returned under a `body` field in the JSON result and only the title is prefilled in the URL.
- Do not add a `forced_browser` (or equivalent) field to the JSON result payload. Per PRODUCT.md invariant 16, no telemetry is captured from this feature; keeping the payload shape unchanged avoids creating a latent telemetry hook that would need to be wired up or removed later.
No changes are needed in the script's `gh`, browser, or URL helpers themselves. The existing `browser_is_available()` gate continues to cover headless sessions and will produce a failure payload the agent can surface verbatim; the only adjustment is that the failure message always mentions image attachments, since `--use browser` implies they are present.
### 3. Skill: instruct the agent on the image-attached branch
Edit `SKILL.md` in the feedback skill to add a short, explicit conditional that runs after duplicate detection and before filing, and update the `Output` section to describe the required `--use` flag:
- If the user's query includes one or more image attachments (visible in multimodal context), the agent must:
- Draft the issue body as normal, including a short, plain-language description of each attached image's content in the relevant Behavior/Problem/Actual-behavior/Artifacts section.
- In the `Artifacts` section, emit one placeholder per attached image, one per line, in the order the images were attached. The placeholder text should be recognizable to a human reader as a placeholder (e.g. `_Paste screenshot here_`). The skill does not need to use any particular sentinel; the goal is that the user can see where to drop.
- Invoke `file_feedback_issue.py` with `--use browser` (instead of `--use gh`) alongside the existing `--title` and `--body-file` arguments.
- In the final user-visible response, combine the standard browser-opened (or body-in-payload) messaging with an explicit instruction to paste or drag each attached image into the placeholder line(s) before submitting the issue. Reference the count of attached images so the user knows how many to paste.
- If the user's query has no image attachments, pass `--use gh` and file via the gh CLI path as today. No placeholders, no drag-and-drop instructions.
The rest of the skill's workflow (classification, clarifying questions, grounded references, duplicate detection, issue structure, output handling for `created` / `browser_opened` / `unavailable` / `failed`) is unchanged. The image-attached branch is additive.
## Risks and mitigations
- **Broader effect of the platform flag change.** Passing `is_user_query: true` for `InvokeSkill` also exposes pending blocks and selected text to skills, not just images. This is almost certainly desirable (skills today silently lose that context too), but it is a behavior change for every existing skill invocation. Mitigation: land the platform fix behind a feature flag if the blast radius is a concern, or gate the change to image attachments specifically via the alternative `include_user_attachments` parameter described in Proposed changes #1.
- **User submits with an empty placeholder.** If the user forgets to drop the image, the issue will contain a literal `_Paste screenshot here_` line. Mitigation: keep the placeholder text short and obviously a placeholder; rely on the agent's in-body prose description as the authoritative content. Acceptable failure mode per PRODUCT.md #12.
- **GitHub web UI changes its drag-drop behavior.** The `user-attachments` upload flow is internal to GitHub and not a public API. A change to that surface could break the "drop into the body" step. Mitigation: none required at this layer — if GitHub's web UI stops accepting drops, the whole web-UI workflow breaks for everyone and is not specific to this feature.
- **Forced-browser path is slower than `gh issue create`.** Users with `gh` authenticated lose the one-shot filing speed when they attach images. Mitigation: document in the skill's response so the user understands why the browser opened. No telemetry is captured for this feature (PRODUCT.md invariant 16), so usage-based revisiting relies on qualitative signals (user reports, direct feedback on the feedback skill itself) rather than measurement. If that turns out to be insufficient, adding measurement is a separate, explicitly-scoped change.
## Follow-ups
- If the forced-browser path becomes the dominant filing path due to screenshots being common in feedback, reassess the decision to keep `gh issue create` at all. Unifying on a single path (browser or CLI) reduces user-facing branching in the skill's final response and collapses the test matrix.
- If another skill later wants the same "caller picks filing method" signal (for example, a future `/bug` or `/support` skill), consider lifting `--use {gh|browser}` into a shared filing helper rather than duplicating the flag semantics per skill.
- Consider exposing the image-attached placeholder convention as a named sentinel (for example, `<!-- warp-feedback:image-N -->`) if we ever want to post-process the submitted issue to validate that users replaced the placeholders before submission.
+96
View File
@@ -0,0 +1,96 @@
# APP-3781: Move Plugin Installation Instructions into a Split Pane
Linear: [APP-3781](https://linear.app/warpdotdev/issue/APP-3781/move-plugin-installation-settings-into-a-dedicated-pane)
## Summary
Replace the blocking modal that shows CLI agent plugin install/update instructions with a split terminal pane containing a specialized zero state block. The pane is a real terminal session, so the user can read the instructions and run the commands without switching context.
## Problem
The current plugin install/update instructions are shown in a modal overlay that blocks the entire screen. The user must:
1. Read a step in the modal
2. Copy the command
3. Close the modal
4. Paste the command into the terminal
5. Re-open the modal to see the next step
This is particularly frustrating because the instructions involve multiple sequential commands that the user needs to run in their terminal.
## Goals
- Show plugin install/update instructions in a side-by-side terminal pane instead of a blocking modal.
- Let the user run the instruction commands directly in the new pane without context switching.
- Preserve the existing "copy" affordance for each command step.
- Apply to both install and update instruction flows.
## Non-goals
- Changing the auto-install/auto-update behavior (the one-click install/update chip continues to work as before).
- Modifying the instruction content itself (titles, subtitles, step descriptions, commands remain unchanged).
- Adding "run in terminal" buttons that auto-execute commands — the user pastes/types them manually.
## User Experience
### Entry point
The entry points remain the same buttons in the CLI agent toolbar:
- The install chip (when in the instructions state) → opens install instructions pane.
- The update chip (when in the instructions state) → opens update instructions pane.
### What happens on click
1. A new terminal pane opens as a split in the current tab (using smart split direction, like env var panes do). This is always a terminal pane, not an agent pane, even if the user's default mode for new sessions is Agent Mode.
2. A specialized zero state block is rendered at the top of the new terminal's block list, displaying the plugin instructions.
3. The terminal session in the new pane is fully functional — the user can type and run commands.
### Instructions block content
The instructions block renders the same information as the current modal:
- **Title** (e.g., "Install Warp Plugin for Claude Code")
- **Subtitle** (e.g., "Ensure that jq is installed on your machine. Then, run these commands inside your Claude Code session.")
- **Numbered steps**, each with:
- A step number in a circular badge
- A text description of the step
- A code block with the command and a copy-to-clipboard button
The visual style should match the existing terminal zero state block pattern: bordered container with terminal-consistent styling. Step rendering reuses the same code block rendering pattern from the modal (the `render_code_block_plain` helper).
### Dismissing the instructions block
The instructions block has a close button (X) in the top-right corner. Clicking it hides the block. The instructions block persists across commands — it does not auto-dismiss when the user runs a command, since the user may be following the multi-step instructions in that pane.
### Closing the pane
The user can close the instructions pane like any other split pane (via the pane close button, keyboard shortcut, etc.). No special cleanup is needed.
### Modal removal
The modal (`PluginInstallModal`) is removed entirely. All references to `is_plugin_install_modal_open` in workspace state are cleaned up.
## Edge Cases
1. **Single-pane tab**: If the tab has only one pane, the split creates a second pane. The instructions block appears in the new (right/bottom) pane.
2. **Already-split tab**: The new instructions pane is added as a sibling of the focused pane in the smart split direction, consistent with how env var panes split.
3. **Multiple instruction requests**: Clicking the instructions button again always opens a new split pane (no deduplication).
4. **Pane closed, re-requested**: If the user closes the instructions pane and clicks the button again, a new instructions pane is created from scratch.
## Success Criteria
1. Clicking the info (ⓘ) button next to the install/update chip opens a split terminal pane, not a modal overlay.
2. The new pane shows a zero state block with the full plugin instructions (title, subtitle, numbered steps with copy-able commands).
3. The user can type and run commands in the new pane while the instructions block is visible.
4. The instructions block persists until the user clicks the close (X) button on it.
5. The copy button on each step copies the command to the clipboard and shows a "Copied to clipboard" toast.
6. The modal overlay (`PluginInstallModal`) is fully removed from the codebase.
7. Both install and update instruction flows use the new split pane behavior.
## Validation
- **Manual test**: Click the install info button → verify a split pane appears with instructions. Copy a command → verify clipboard. Run a command in the pane → verify the instructions block remains visible. Click the close (X) button on the block → verify it disappears. Close the pane → verify clean closure.
- **Both flows**: Verify both install and update info buttons open the pane with the correct instructions.
- **Compile check**: Verify no remaining references to the removed modal types.
## Open Questions
(None outstanding.)
+148
View File
@@ -0,0 +1,148 @@
# APP-3781: Tech Spec — Plugin Instructions in a Split Pane
Linear: [APP-3781](https://linear.app/warpdotdev/issue/APP-3781/move-plugin-installation-settings-into-a-dedicated-pane)
## Problem
The plugin install/update instructions are rendered as a modal (`PluginInstallModal`) that overlays the entire workspace. This change replaces the modal with a split terminal pane containing a rich-content instructions block with a manual close button.
## Relevant Code
**Current modal (to be deleted):**
- `app/src/workspace/view/plugin_install_modal.rs` — the modal view, step rendering, copy-to-clipboard, `render_step_number`
- `app/src/workspace/view.rs:851``plugin_install_modal` field on `Workspace`
- `app/src/workspace/view.rs:14027-14064``handle_plugin_install_modal_event` and `open_plugin_install_modal`
- `app/src/workspace/view.rs:19587-19590` — modal render in workspace overlay stack
- `app/src/workspace/util.rs:119,156,197``is_plugin_install_modal_open` in `WorkspaceState`
**Event chain (to be renamed):**
- `app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs:1788,1959``ShowPluginInstallModal` / `ShowPluginInstructionsModal` actions and events
- `app/src/terminal/input.rs:1047``InputEvent::ShowPluginInstructionsModal`
- `app/src/terminal/view.rs:1892``terminal::view::Event::ShowPluginInstructionsModal`
- `app/src/pane_group/pane/terminal_pane.rs:664-668` — forwards to `pane_group::Event`
- `app/src/pane_group/mod.rs:695``pane_group::Event::ShowPluginInstructionsModal`
- `app/src/workspace/view.rs:11231` — workspace handler
**Plugin instructions data:**
- `app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs``PluginInstructions`, `PluginInstructionStep`, `PluginModalKind`
- `app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs:126-170` — static `INSTALL_INSTRUCTIONS` and `UPDATE_INSTRUCTIONS`
**Patterns to follow:**
- `app/src/terminal/view/zero_state_block.rs``TerminalViewZeroStateBlock` (container styling, positioned close/dismiss button via `Stack::with_positioned_child`)
- `app/src/terminal/view/rich_content.rs``RichContent`, `RichContentMetadata`, `TerminalView::insert_rich_content`
- `app/src/terminal/model/rich_content.rs``RichContentType` enum
- `app/src/ai/blocklist/code_block/mod.rs``render_code_block_plain`, `CodeBlockOptions`, `CodeSnippetButtonHandles` (for code block rendering)
- `app/src/pane_group/mod.rs:3750-3766``PaneGroup::add_terminal_pane` returns `TerminalPaneId`
- `app/src/pane_group/mod.rs:6078-6085``PaneGroup::terminal_view_from_pane_id` returns `ViewHandle<TerminalView>`
- `app/src/pane_group/mod.rs:1057-1072``PaneGroup::smart_split_direction`
- `app/src/workspace/view.rs:569``WORKFLOW_AND_ENV_VAR_SPLIT_RATIO`
## Current State
Event chain: info (ⓘ) button click → `AgentInputFooterAction::ShowPluginInstallModal``AgentInputFooterEvent::ShowPluginInstructionsModal(agent, kind)``InputEvent``terminal::view::Event``TerminalPane``pane_group::Event``Workspace::open_plugin_install_modal`.
The workspace resolves `PluginInstructions` from `plugin_manager_for(agent)`, sets them on the modal view, sets `is_plugin_install_modal_open = true`, and focuses the modal. The modal renders as a centered overlay with dimmed background.
## Proposed Changes
### 1. New file: `app/src/terminal/view/plugin_instructions_block.rs`
A `PluginInstructionsBlock` view that renders plugin instructions as terminal rich content with a close button.
**Struct:**
```rust
struct PluginInstructionsBlock {
instructions: &'static PluginInstructions,
close_button_mouse_state: MouseStateHandle,
step_code_handles: Vec<CodeSnippetButtonHandles>,
should_hide: bool,
}
```
**Rendering:** Uses a `Stack` with:
- Main child: a bordered `Container` (matching `TerminalViewZeroStateBlock` style — horizontal terminal padding, vertical padding, top/bottom border) containing a `Flex::column` with title, subtitle, and numbered step rows
- Positioned child: a close button (using `appearance.ui_builder().close_button()`) in the top-right corner via `OffsetPositioning::offset_from_parent`
Each step row reuses the `render_step_number` badge (moved from the modal into this file as a private `fn`, since no other caller exists) and `render_code_block_plain` for the command code block with copy button.
**Actions and events:**
- `PluginInstructionsBlockAction::Close` — sets `should_hide = true`, emits a close event to the owning `TerminalView` so the rich-content item is removed from the blocklist sumtree, and calls `ctx.notify()`
- `PluginInstructionsBlockAction::CopyCommand(usize)` — copies to clipboard via `ctx.clipboard().write()`, shows toast via `ToastStack::handle(ctx)` singleton (same pattern as the modal, `plugin_install_modal.rs:248-257`)
- `Entity::Event = PluginInstructionsBlockEvent``Close` bubbles to `TerminalView` for rich-content cleanup; toast and clipboard are still handled directly in the block
When `should_hide` is true, `render` returns `Empty::new().finish()`.
### 2. New `RichContentType` and `RichContentMetadata` variants
In `app/src/terminal/model/rich_content.rs`, add `PluginInstructionsBlock` to the `RichContentType` enum.
In `app/src/terminal/view/rich_content.rs`, add `PluginInstructionsBlock` to the `RichContentMetadata` enum.
### 3. Rename event variants
Rename across the entire chain to reflect pane-based behavior:
- `AgentInputFooterAction::ShowPluginInstallModal``OpenPluginInstallInstructionsPane`
- `AgentInputFooterAction::ShowPluginInstructionsModal``OpenPluginUpdateInstructionsPane`
- `AgentInputFooterEvent::ShowPluginInstructionsModal``OpenPluginInstructionsPane` (carries `PluginModalKind`)
- `InputEvent::ShowPluginInstructionsModal``OpenPluginInstructionsPane` (carries `PluginModalKind`)
- `terminal::view::Event::ShowPluginInstructionsModal``OpenPluginInstructionsPane` (carries `PluginModalKind`)
- `pane_group::Event::ShowPluginInstructionsModal``OpenPluginInstructionsPane` (carries `PluginModalKind`)
### 4. Workspace: replace modal with split pane creation
Replace `Workspace::open_plugin_install_modal` with `open_plugin_instructions_pane`. The method:
1. Resolves `PluginInstructions` from `plugin_manager_for(agent)` (same as before)
2. Creates a new terminal pane via `PaneGroup::add_terminal_pane_ignoring_default_session_mode(direction, None, ctx)` so the pane stays in terminal mode even if the user's default mode for new sessions is Agent Mode. Split panes do not show the homepage zero-state, so no `hide_homepage` option is needed.
3. Gets the `ViewHandle<TerminalView>` via `PaneGroup::terminal_view_from_pane_id(pane_id, ctx)`
4. Inside `terminal_view.update()`, creates a `PluginInstructionsBlock` view and calls `view.insert_rich_content(...)` to add it
This keeps `TerminalView` fully decoupled from plugin concepts — the workspace owns the orchestration, and the block is just another rich content view.
### 5. Delete modal code
Remove:
- `app/src/workspace/view/plugin_install_modal.rs` (entire file)
- `mod plugin_install_modal` declaration (`view.rs:13`)
- `use crate::workspace::view::plugin_install_modal::{PluginInstallModal, PluginInstallModalEvent}` import (`view.rs:129`)
- `plugin_install_modal` field from `Workspace` struct (`view.rs:851`)
- `plugin_install_modal` field initialization in `Workspace::new` (`view.rs:2393-2394`)
- `is_plugin_install_modal_open` from `WorkspaceState` and all references in `is_any_non_palette_modal_open`, `close_all_modals` (`util.rs:119,156,197`)
- `handle_plugin_install_modal_event` (`view.rs:14027-14040`)
- Modal construction and subscription in `Workspace::new` (`view.rs:1896-1901`)
- Modal render in overlay stack (`view.rs:19587-19590`)
- `view::plugin_install_modal::init(app)` call (`workspace/mod.rs:87`)
## End-to-End Flow
1. User clicks info (ⓘ) button on install/update chip
2. `AgentInputFooterAction::OpenPluginInstallInstructionsPane` (or update variant) dispatched
3. Event bubbles: `AgentInputFooter``Input``TerminalView``TerminalPane``PaneGroup``Workspace`
4. `Workspace::open_plugin_instructions_pane(agent, kind, ctx)` called
5. Workspace resolves `PluginInstructions` from `CliAgentPluginManager`
6. Inside `active_tab_pane_group().update()`:
- Creates terminal pane with `add_terminal_pane_ignoring_default_session_mode(Direction::Right, None, ctx)``TerminalPaneId`
- Gets `ViewHandle<TerminalView>` via `terminal_view_from_pane_id`
- Inside `terminal_view.update()`: creates `PluginInstructionsBlock`, calls `insert_rich_content`
7. New pane renders with instructions block at top; user types/runs commands below it
8. User clicks close (X) button → block hides, the corresponding rich-content item is removed from the blocklist sumtree, and the terminal pane remains functional
## Risks and Mitigations
**Block insertion timing.** `insert_rich_content` appends to the block list model and works before session bootstrap. The block will be visible while the session bootstraps (sub-second). No special handling needed.
**Toast access.** The block accesses `ToastStack::handle(ctx)` directly (it's a singleton), same pattern as the modal. No event bubbling required for toasts.
**`PluginInstructions` visibility.** `PluginInstructions` and `PluginInstructionStep` are currently `pub(crate)` in `plugin_manager/mod.rs`. The new block file is within the same crate, so no visibility changes needed.
## Testing and Validation
- `cargo check` — no remaining references to deleted modal types
- `cargo fmt` and `cargo clippy` per presubmit
- Manual test: click install info button → split pane with instructions. Copy command → clipboard + toast. Run commands → instructions block persists. Click close (X) → block hides. Close pane → clean.
- Both flows: verify install and update info buttons show correct instructions.
## Follow-ups
- The two `AgentInputFooterAction` variants (`OpenPluginInstallInstructionsPane` for install, `OpenPluginUpdateInstructionsPane` for update) could be collapsed into a single variant carrying `PluginModalKind`. Left as-is for minimal diff, can unify later.
+354
View File
@@ -0,0 +1,354 @@
# TECH.md — Remote Server Manager, Host ID Subcommand, and Session Connection Flow
Linear: [APP-3787](https://linear.app/warpdotdev/issue/APP-3787)
## 1. Problem
The Warp client needs a centralized way to manage connections to `remote_server` processes running on remote hosts. Today, each downstream feature (file tree, code review, agent apply diff) would need to independently figure out how to reach the remote server for its session's host. Each SSH session needs its own dedicated connection to the remote server because SSH connections are tied to the parent session's lifecycle — if the parent session dies, all multiplexed connections through it die too. Deduplication to a single long-lived server process happens on the remote host, not on the client.
This spec covers three pieces:
1. A protocol change — the `InitializeResponse` returns a `HostId` so the client can deduplicate per-host models
2. A `RemoteServerManager` singleton model — the global registry that maps sessions to `RemoteServerClient` instances
3. The session connection flow — the manager's internal `connect_session` workflow (server startup, initialization, host identification via the protocol). The specifics of *when and where* `connect_session` is triggered are out of scope and will be addressed in a future spec alongside the binary installation flow.
## 2. Relevant Code
### Remote server crate
- `crates/remote_server/src/client.rs``RemoteServerClient` struct with background reader/writer tasks, `initialize()` handshake
- `crates/remote_server/src/server_model.rs``ServerModel` singleton on the server side, handles `ClientMessage` dispatch
- `crates/remote_server/src/protocol.rs` — length-delimited protobuf read/write helpers, `ProtocolError`, `RequestId`
- `crates/remote_server/proto/remote_server.proto``ClientMessage`/`ServerMessage` envelopes with `Initialize`/`InitializeResponse` (to be extended with `host_id`)
### CLI subcommand dispatch
- `crates/warp_cli/src/lib.rs (384-426)``WorkerCommand` enum with `RemoteServer` variant
- `app/src/lib.rs (542-544)``WorkerCommand::RemoteServer` dispatch calling `remote_server::run()`
### Session bootstrap flow
- `app/src/terminal/model/terminal_model.rs (2874-2919)``init_shell()` handler creates `SessionInfo::create_pending()` with `SessionType`
- `app/src/terminal/model/terminal_model.rs (2820-2858)``bootstrapped()` handler merges pending session info and emits `HandlerEvent::Bootstrapped`
- `app/src/terminal/model_events.rs (89-111)``ModelEventDispatcher` receives `Bootstrapped`, calls `sessions.initialize_bootstrapped_session()`
- `app/src/terminal/model/session.rs (199-309)``Sessions::initialize_bootstrapped_session()` creates `Session`, emits `SessionsEvent::SessionBootstrapped`
- `app/src/terminal/view.rs (11022-11199)``TerminalView::handle_session_bootstrapped()` reacts to the event
### Session and SSH types
- `app/src/terminal/model/session.rs (691-699)``SessionType::Local` / `SessionType::WarpifiedRemote`
- `app/src/terminal/model/session.rs (426-451)``SessionInfo` struct with `hostname`, `user`, `session_type`, `spawning_session_id`
- `app/src/terminal/model/terminal_model.rs (632-647)``SubshellInitializationInfo` with `ssh_connection_info: Option<InteractiveSshCommand>`
- `app/src/terminal/ssh/util.rs (86-89)``InteractiveSshCommand { host, port }`
### Remote command execution over SSH
- `app/src/terminal/model/session/command_executor/remote_command_executor.rs``RemoteCommandExecutor` uses SSH `ControlPath` to run one-off commands over an existing SSH connection
- `app/src/terminal/model/session.rs (388-391)``IsLegacySSHSession::Yes { socket_path }` stores the SSH control socket
### Existing remote host patterns
- `app/src/terminal/view.rs (6094-6105)``active_session_remote_host()` returns `Some("user@hostname")` for remote sessions
- `app/src/terminal/view.rs (9362-9436)``is_block_considered_remote()` checks `session.is_local()`
- `app/src/terminal/cli_agent_sessions/mod.rs (107-111)``CLIAgentSession` stores `remote_host: Option<String>` per session
### Singleton model precedents
- `app/src/terminal/cli_agent_sessions/mod.rs (234-462)``CLIAgentSessionsModel` singleton with `HashMap<EntityId, CLIAgentSession>`, event emission, session lifecycle
- `app/src/ai/mcp/templatable_manager.rs (41-78)``TemplatableMCPServerManager` singleton with per-server state tracking, spawn/abort handles
## 3. Current State
- The `remote_server` crate has a working `RemoteServerClient` and `ServerModel` with `Initialize`/`InitializeResponse` over length-delimited protobuf.
- The `warp remote-server` subcommand boots the headless app and runs the server over stdin/stdout.
- There is no client-side manager. No code exists to spawn the remote server over SSH, track which hosts have running servers, or route feature requests to the right client.
- Sessions know their `hostname` and `session_type` after bootstrap, and SSH sessions have `ssh_connection_info` from the parsed SSH command.
- The `RemoteCommandExecutor` demonstrates how to run commands over an existing SSH connection using the control socket.
## 4. Proposed Changes
### 4.1. Protocol: `HostId` in `InitializeResponse`
Update the protobuf schema so that `InitializeResponse` includes a `host_id` field. The server generates a stable identifier for the host and returns it during the initialize handshake, eliminating the need for a separate host-id probe step.
```protobuf
message InitializeResponse {
string server_version = 1;
string host_id = 2;
}
```
The server generates the `host_id` once when the long-lived server process starts (a v4 UUID). Since the remote host infrastructure deduplicates connections to a single long-lived server process, all clients connecting to the same host receive the same `host_id`.
**Server-side implementation**: The `ServerModel` generates a UUID at construction time and includes it in every `InitializeResponse`. Because the remote host routes multiple incoming connections to the same long-lived `ServerModel` process, all clients receive the same ID.
### 4.2. `HostId` newtype
Add to `crates/remote_server/src/host_id.rs` and re-export from `lib.rs`:
```rust
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct HostId(String);
impl HostId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for HostId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
```
### 4.3. `RemoteServerManager` singleton model
Create `app/src/remote_server_manager.rs` (or `app/src/remote_server_manager/mod.rs` if it grows).
```rust
/// Per-session connection state. Encodes which data is available at each
/// lifecycle stage so the compiler prevents invalid combinations.
#[derive(Clone, Debug)]
pub enum RemoteSessionState {
/// `connect_session` has been called; background task is starting the
/// server process over SSH.
Connecting,
/// Server process spawned, client exists, initialize handshake in progress.
Initializing { client: ModelHandle<RemoteServerClient> },
/// Initialize handshake succeeded. Client is ready for requests.
Connected { client: ModelHandle<RemoteServerClient>, host_id: HostId },
/// Connection dropped (EOF/error from the reader task).
Disconnected,
}
pub struct RemoteServerManager {
/// Per-session connection state. Each SSH session gets its own dedicated
/// connection to the remote server.
sessions: HashMap<SessionId, RemoteSessionState>,
/// Reverse index: host → sessions for O(1) lookup by `HostId`.
host_to_sessions: HashMap<HostId, HashSet<SessionId>>,
/// Spawner for running closures back on the main thread.
spawner: ModelSpawner<Self>,
}
```
Each SSH session gets its own `RemoteSessionState`. The manager maps sessions → state directly, with no host-level connection sharing. The `HostId` lives inside the `Connected` variant — it's only available after the initialize handshake succeeds. The `host_to_sessions` reverse index gives downstream features O(1) lookup of all sessions on a given host, which they need for model deduplication and cleanup. If `Connected` grows beyond 23 fields in the future, we'll consider extracting into a `ConnectedSession` struct.
**Why per-session connections**: SSH control socket multiplexing ties all multiplexed connections to the parent session's lifecycle. If session A starts SSH and session B piggybacks via the control socket, session B's remote server connection dies when session A's SSH exits. Per-session connections ensure each session's remote server survives independently. The remote host infrastructure handles routing multiple connections to the same long-lived server process — dedup is the server's job, not the client's.
**`RemoteServerClient` as an Entity**: The `RemoteServerClient` is a warpui model (implements `Entity`) that can emit events and be subscribed to. It also derives `Clone`, producing a second handle to the same underlying channels — this is used to call async methods (e.g. `initialize`) from a background thread while the original lives inside a `ModelHandle`. The manager holds a `ModelHandle<RemoteServerClient>` in `server_clients`, and downstream features can subscribe directly to a specific client for server-pushed notifications (e.g. file change events, progress updates) rather than routing everything through the manager's event system.
**Entity and events**:
```rust
impl Entity for RemoteServerManager {
type Event = RemoteServerManagerEvent;
}
impl SingletonEntity for RemoteServerManager {}
#[derive(Clone, Debug)]
pub enum RemoteServerManagerEvent {
// --- Session-scoped events ---
/// A connection flow has started for this session.
SessionConnecting { session_id: SessionId },
/// This session's server is connected and ready. Includes the HostId
/// received from the initialize handshake, for model deduplication.
SessionConnected { session_id: SessionId, host_id: HostId },
/// This session's connection dropped.
SessionDisconnected { session_id: SessionId, host_id: HostId },
/// A session was deregistered (torn down).
SessionDeregistered { session_id: SessionId },
// --- Host-scoped events ---
/// The first session for this host reached `Connected`. Downstream
/// features should create per-host models (e.g. RepoMetadataModel).
HostConnected { host_id: HostId },
/// The last session for this host was disconnected or deregistered.
/// Downstream features should tear down per-host models.
HostDisconnected { host_id: HostId },
}
```
Events are emitted at two granularities. Session-scoped events fire for every session lifecycle change. Host-scoped events fire at the boundaries — `HostConnected` when the *first* session for a host reaches `Connected` (checked via `host_to_sessions`), and `HostDisconnected` when the *last* session for a host is disconnected or deregistered. This way downstream features that key on `HostId` can subscribe to host events for model lifecycle without reimplementing first/last tracking themselves. `SessionDisconnected` also carries `host_id` so consumers don't need to look it up from an already-transitioned state.
**Public API**:
```rust
impl RemoteServerManager {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
// Store a ModelSpawner for use by background tasks.
...
}
// --- Public API (called by the trigger layer and downstream features) ---
/// Entry point called when an SSH session needs a remote server connection.
/// Spawns a dedicated connection for this session.
///
/// Immediately sets status to `Connecting` and emits `SessionConnecting`.
/// Then spawns a background task that:
/// 1. Runs `warp remote-server run` over SSH, creates the client
/// 2. Calls `initialize()`, receives HostId, marks Connected
pub fn connect_session(
&mut self,
session_id: SessionId,
socket_path: PathBuf,
ctx: &mut ModelContext<Self>,
) { ... }
/// Removes a session and tears down its connection.
pub fn deregister_session(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) { ... }
/// Returns the client handle for this session, if connected.
pub fn client_for_session(
&self,
session_id: SessionId,
) -> Option<&ModelHandle<RemoteServerClient>> {
match self.sessions.get(&session_id)? {
RemoteSessionState::Connected { client, .. } => Some(client),
_ => None,
}
}
/// Returns the connection state for this session.
pub fn session(
&self,
session_id: SessionId,
) -> Option<&RemoteSessionState> {
self.sessions.get(&session_id)
}
/// Returns the HostId for this session, if the initialize handshake
/// has completed. Used by downstream features for model dedup.
pub fn host_id_for_session(
&self,
session_id: SessionId,
) -> Option<&HostId> {
match self.sessions.get(&session_id)? {
RemoteSessionState::Connected { host_id, .. } => Some(host_id),
_ => None,
}
}
/// Returns all session IDs connected to a given host. O(1) via the
/// reverse index. Used by downstream features for model dedup and
/// cleanup (e.g. tear down RepoMetadataModel when the last session
/// for a host is deregistered).
pub fn sessions_for_host(
&self,
host_id: &HostId,
) -> Option<&HashSet<SessionId>> {
self.host_to_sessions.get(host_id)
}
// --- Private ---
/// Transitions a session from `Initializing` to `Connected`.
/// Moves the client out of the old variant and into the new one.
fn mark_connected(
&mut self,
session_id: SessionId,
host_id: HostId,
ctx: &mut ModelContext<Self>,
) { ... }
/// Transitions a session to `Disconnected`.
fn mark_disconnected(
&mut self,
session_id: SessionId,
ctx: &mut ModelContext<Self>,
) { ... }
}
```
**Registration**: The manager is registered as a singleton during app initialization in `app/src/lib.rs`, alongside other global models. It stores a `ModelSpawner<Self>` at construction time (same pattern as `TemplatableMCPServerManager`) for use by background tasks.
### 4.4. `connect_session` internal workflow
When `connect_session` is called, the manager owns the entire flow from that point. The specifics of *when and where* `connect_session` is triggered (e.g. from the terminal view after SSH bootstrap, or as part of a binary installation flow) are out of scope for this spec and will be addressed in a future iteration.
**Inside `connect_session`**: The manager sets the session status to `Connecting`, emits `SessionConnecting`, and spawns a background task with two phases.
**Phase 1 — Server startup and client creation**:
1. Run `warp remote-server run` over SSH as a long-running child process. This is a standalone `Command::new("ssh").spawn()` call — not routed through the session's `CommandExecutor`. Same SSH args pattern as `RemoteCommandExecutor` — ControlPath multiplexing, password auth disabled, X11 disabled.
```rust
let mut args = ssh_args(&socket_path);
args.extend(["warp", "remote-server", "run"].map(String::from));
let mut child = tokio::process::Command::new("ssh")
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
```
We use `.spawn()` (not `.output()`) because the remote server is long-running — `.output()` would block until the process exits. The spawn happens on the background thread because SSH connection establishment involves network I/O that shouldn't block the main thread.
2. Take ownership of the child's stdin/stdout/stderr.
3. Hop to the main thread via `spawner.spawn()` and:
a. Create `RemoteServerClient::from_child_streams(child_stdin, child_stdout, child_stderr, runtime)` — this internally spawns the stderr forwarder and protocol reader/writer tasks.
b. Subscribe to `RemoteServerClientEvent::Disconnected` on the client handle so the manager is notified when the connection drops.
c. Transition the session to `Initializing { client }` — the client handle is stored early so the disconnect subscription works even if the handshake fails.
d. Clone the `RemoteServerClient` (it derives `Clone`, producing a second handle to the same channels) for use in the async Phase 2.
4. If the server process fails to start: log the error and transition the session to `Disconnected`.
**Phase 2 — Initialize handshake**:
Back on the background thread, call `client.initialize()` on the cloned client. The `InitializeResponse` now includes `host_id`.
- On success: hop to main thread, call `mark_connected(session_id, host_id)` → transitions from `Initializing { client }` to `Connected { client, host_id }` and emits `SessionConnected { session_id, host_id }`.
- On failure: log the error, hop to main thread, call `mark_disconnected(session_id)` → transitions to `Disconnected` and emits `SessionDisconnected`. The session continues without remote server features.
Each session always gets a fresh connection. The remote server infrastructure on the host handles routing multiple connections to the same long-lived server process.
**Disconnection detection**: The `RemoteServerClient`'s reader task detects EOF (server crash or SSH death), clears pending requests, and emits `RemoteServerClientEvent::Disconnected`. During Phase 1, the manager subscribes to this event on each client handle. When the event fires, the subscription callback calls `mark_disconnected(session_id)`, which transitions to `Disconnected` and emits `SessionDisconnected`. Other sessions to the same host are completely unaffected — each has its own independent connection.
**Session teardown**: When `deregister_session(session_id)` is called, the manager removes the session from `sessions`. Dropping the `RemoteSessionState` (and its `ModelHandle<RemoteServerClient>` if present) closes the outbound channel, causing the writer task to exit, which closes stdin on the child process, which triggers EOF on the server side. The server infrastructure handles the lifecycle of the long-lived server process independently.
### 4.5. Client-side model deduplication via `HostId`
After the initialize handshake, the `HostId` is stored inside the `Connected` variant. Downstream features use this to share per-host models:
- When a feature needs a per-host model (e.g. `RepoMetadataModel`), it calls `manager.host_id_for_session(session_id)` and uses the `HostId` as the key into its own model registry.
- If two sessions return the same `HostId`, the feature reuses the existing model rather than creating a duplicate.
- When all sessions for a given `HostId` are deregistered, the feature tears down the per-host model. Use `manager.sessions_for_host(host_id)` to check if any sessions remain.
This keeps the `RemoteServerManager` focused on connection lifecycle while letting each downstream feature manage its own dedup policy.
## 5. End-to-End Flow
### New SSH session connecting to a host with no existing sessions
1. The caller (trigger TBD — see follow-ups) calls `manager.connect_session(session_id, socket_path)`. Manager sets status to `Connecting`, emits `SessionConnecting`.
2. Background task runs `ssh -o ControlPath=<socket> placeholder@placeholder warp remote-server run` → gets child stdin/stdout/stderr.
3. Hops to main thread: creates `RemoteServerClient::from_child_streams(...)`, subscribes to disconnect events, stores client handle. Clones client for async use.
4. Back on background thread: calls `client.initialize()` → receives `InitializeResponse { server_version: "1.2.3", host_id: "abc123" }`.
5. Hops to main thread: transitions session to `Connected { client, host_id: HostId("abc123") }`, emits `SessionConnected { session_id, host_id }`.
6. File tree subscribes to `SessionConnected`, calls `manager.host_id_for_session(session_id)``"abc123"`, creates a `RepoMetadataModel` keyed by `"abc123"`.
### Second SSH session to the same host
1. `manager.connect_session(session_id_2, socket_path_2)` → same flow, **new independent connection**.
2. Background task starts its own `warp remote-server run` over SSH. The remote host routes this connection to the same long-lived server process.
3. `client.initialize()``InitializeResponse { host_id: "abc123" }` (same host ID because it's the same server process).
4. File tree sees `host_id = "abc123"`, finds an existing `RepoMetadataModel` for that host — reuses it instead of creating a duplicate.
5. Session 2's `RemoteServerClient` is fully independent of session 1's. If session 1's SSH dies, session 2 is unaffected.
### Session disconnection
1. Session 1's `RemoteServerClient` reader task hits EOF (SSH session died), emits `RemoteServerClientEvent::Disconnected`.
2. Manager's subscription callback marks session 1 as `Disconnected`, emits `SessionDisconnected { session_id }`.
3. Session 2's connection is completely unaffected — it has its own SSH connection and `RemoteServerClient`.
4. Downstream features see session 1 disconnected but session 2 is still connected with the same `HostId`. Per-host models stay alive.
### Last session to a host deregistered
1. Both sessions are deregistered via `deregister_session`.
2. Manager removes each session's `RemoteSessionState`.
3. Downstream features call `manager.sessions_for_host(host_id)`, see no remaining sessions for `HostId("abc123")`, and tear down the per-host `RepoMetadataModel`.
+331
View File
@@ -0,0 +1,331 @@
# Client-Side Wiring for Remote File Tree — Tech Spec
Linear: [APP-3788](https://linear.app/warpdotdev/issue/APP-3788)
## 1. Problem
The remote server file tree protocol (proto schema, server handlers, client API, Rust↔Proto conversion, incremental sync) is implemented. However, nothing on the client side triggers these flows. When a user SSH's into a remote host and `cd`s around, the Project Explorer shows "not supported in remote sessions."
We need to wire three things:
1. SSH `cd``navigate_to_directory` request to the remote server
2. Server push events (`RepoMetadataSnapshot`, `RepoMetadataUpdatePush`) → populate `RemoteRepoMetadataModel`
3. File tree view renders from `RemoteRepoMetadataModel` for SSH sessions
## 2. Relevant Code
### Remote server client & manager
- `crates/remote_server/src/client.rs:156``navigate_to_directory()` async request method
- `crates/remote_server/src/client.rs:202``send_request()` that all request methods delegate to
- `crates/remote_server/src/client.rs:46``ClientEvent` enum (`Disconnected`, `RepoMetadataSnapshotReceived`, `RepoMetadataUpdated`)
- `crates/remote_server/src/client.rs:109``new()` returns `(Self, async_channel::Receiver<ClientEvent>)` — unified event channel for push events and disconnect
- `app/src/remote_server/manager.rs:91``RemoteServerManager` singleton, maps sessions → hosts → clients
- `app/src/remote_server/manager.rs:262``client_for_session()` lookup
- `app/src/remote_server/manager.rs:181` — event channel drain loop (currently only handles `Disconnected`, TODO for forwarding push events)
### Repo metadata models
- `crates/repo_metadata/src/remote_model.rs:41``RemoteRepoMetadataModel` with `insert_repository()`, `apply_incremental_update()`
- `crates/repo_metadata/src/wrapper_model.rs:52``RepoMetadataModel` wrapper singleton
- `crates/repo_metadata/src/repository_identifier.rs:51``RemoteRepositoryIdentifier { session_id, path }`
### Proto conversion
- `crates/remote_server/src/repo_metadata_proto.rs``proto_snapshot_to_update()` converts `RepoMetadataSnapshot``RepoMetadataUpdate`; `proto_to_repo_metadata_update()` converts `RepoMetadataUpdatePush``RepoMetadataUpdate`; `From` impls for Rust → Proto direction
### File tree view & workspace
- `app/src/code/file_tree/view.rs:235``FileTreeView` struct with `root_directories`, `displayed_directories`, `repository_metadata_model`
- `app/src/code/file_tree/view.rs:660``set_root_directories()` converts `PathBuf` via `try_from_local` (fails for remote paths)
- `app/src/code/file_tree/view.rs:702``update_directory_contents()` looks up `DetectedRepositories` + local model only
- `app/src/code/file_tree/view.rs:351``handle_repository_metadata_event()` matches only `RepositoryIdentifier::Local(..)`
- `app/src/workspace/view.rs:13377``update_active_session()` sets `CodingPanelEnablementState::RemoteSession` for SSH
- `app/src/workspace/view.rs:11964``refresh_working_directories_for_pane_group()` collects CWDs via `pwd_if_local()`
- `app/src/coding_panel_enablement_state.rs:1``CodingPanelEnablementState` enum
- `app/src/terminal/view.rs:20438``pwd()` returns raw CWD (works for remote sessions)
- `app/src/terminal/view.rs:20445``pwd_if_local()` returns `None` for remote sessions
- `app/src/pane_group/working_directories.rs:737``normalize_cwd()` calls `dunce::canonicalize` (fails for remote paths)
### LSP push event pattern (reference)
- `crates/lsp/src/model.rs:268``spawn_stream_local` drains the LSP server notification channel on the main thread, calling `handle_server_notification` for each event
- `crates/lsp/src/model.rs:540``handle_server_notification` dispatches notifications by type, updates model state, and emits domain events via `ctx.emit()`
- `crates/lsp/src/manager.rs:191``LspManagerModel` subscribes to `LspServerModel` events and re-emits them as `LspManagerModelEvent`s for downstream consumers
## 3. Current State
### CWD tracking pipeline
`terminal_view_working_directories()` calls `pwd_if_local()`, which returns `None` for SSH sessions. The raw CWD *is* available via `pwd()` (reads `BlockMetadata::current_working_directory`), but it's never used for remote sessions. The workspace's `refresh_working_directories_for_pane_group` consequently filters remote sessions out entirely.
### File tree enablement
`update_active_session()` sets `CodingPanelEnablementState::RemoteSession` when `is_remote == true`. `FileTreeView::render()` shows "The Project Explorer requires access to your local workspace, which isn't supported in remote sessions." when enablement is `RemoteSession` and `displayed_directories` is empty.
### RemoteServerManager
Singleton that maps sessions → hosts → `RemoteServerClient` handles. Exposes `client_for_session(session_id)`. The event channel from `RemoteServerClient::new()` is drained in a background loop that currently only handles `Disconnected``RepoMetadataSnapshotReceived` and `RepoMetadataUpdated` events have a TODO to forward them.
### RemoteRepoMetadataModel
Has `insert_repository()`, `apply_incremental_update()`, and `update_file_tree_entry()` write APIs. Accessible through the `RepoMetadataModel` wrapper singleton which forwards events as `RepoMetadataEvent` with `RepositoryIdentifier::Remote(..)`. Currently never populated.
### Server push flow
The remote server proactively pushes repo metadata after `NavigatedToDirectory`:
- For non-git directories: server responds with `{ indexed_path, is_git: false }`, then pushes a `RepoMetadataSnapshot` with the lazy tree data.
- For git directories: server responds with `{ indexed_path, is_git: true }`, then pushes a `RepoMetadataSnapshot` once full git indexing completes.
- Incremental updates are pushed as `RepoMetadataUpdatePush` on filesystem watcher changes.
The client parses these in `push_message_to_event()` and delivers them as `ClientEvent::RepoMetadataSnapshotReceived` / `ClientEvent::RepoMetadataUpdated` through the event channel. The `RemoteServerManager` drain loop currently ignores these events.
### FileTreeView local-only assumptions
`set_root_directories()` converts `PathBuf → StandardizedPath` via `try_from_local` (calls `dunce::canonicalize`, fails for non-local paths). `update_directory_contents()` looks up `DetectedRepositories` (local singleton) and calls `load_directory` (local filesystem I/O). `handle_repository_metadata_event` matches only `RepositoryIdentifier::Local(..)` variants and ignores all `Remote(..)` variants.
## 4. Proposed Changes
### Pre-requisite: `RemoteRepositoryIdentifier` keyed by `HostId`
Currently `RemoteRepositoryIdentifier` is `(SessionId, StandardizedPath)`. Multiple SSH sessions to the same host share one remote server, so keying by session would duplicate repo metadata N times.
**Move `HostId`** from `crates/remote_server/src/host_id.rs` to `crates/warp_core/src/host_id.rs` (same pattern as `SessionId` in `warp_core/src/session_id.rs`). Re-export from `remote_server` for backward compatibility.
**Update `RemoteRepositoryIdentifier`**:
```rust
pub struct RemoteRepositoryIdentifier {
pub host_id: HostId,
pub path: StandardizedPath,
}
```
Blast radius is small — `RemoteRepositoryIdentifier` is only used within `repo_metadata` today (repository_identifier.rs, remote_model.rs, wrapper_model.rs).
### 4.1. Feature flag gating
All remote file tree behavior must be gated behind `FeatureFlag::SshRemoteServer`. When the flag is disabled:
- `update_active_session()` should NOT call `navigate_to_directory` for remote sessions
- The file tree view should continue to render the existing "not supported in remote sessions" disabled state
- Push events from the remote server are still forwarded (the server runs regardless of the flag), but `RemoteRepoMetadataModel` should no-op if the flag is off
The flag check should live at the entry points (workspace `update_active_session` and `FileTreeView::render`) rather than deep in the manager or model, so the plumbing is ready for immediate use once the flag is enabled.
### 4.2. Wire SSH `cd` to `navigate_to_directory`
**RemoteServerManager** stays as a thin connection manager. Add:
```rust
pub fn navigate_to_directory(
&mut self,
session_id: SessionId,
path: String,
ctx: &mut ModelContext<Self>,
) {
// 1. Look up client + host_id for this session
// 2. Clone the Arc<RemoteServerClient> and spawn on background executor
// 3. Call client.navigate_to_directory(path).await
// 4. On success, spawner.spawn() back to main thread and emit:
// RemoteServerManagerEvent::NavigatedToDirectory {
// host_id, indexed_path, is_git
// }
}
```
The manager does NOT store state or decide next actions — that's `RemoteRepoMetadataModel`'s job. The server will proactively push `RepoMetadataSnapshot` after responding to `NavigatedToDirectory`, so the client does not need a separate fetch request.
**Caller**: The workspace's `update_active_session()` flow. When the active terminal is remote and has a CWD (via `terminal.pwd()`), call `navigate_to_directory` on the manager instead of skipping.
### 4.3. Forward push events from `RemoteServerManager` (following LSP pattern)
The LSP codebase provides a clean pattern for handling server push messages:
1. `LspServerModel::start()` uses `spawn_stream_local` to drain the notification channel on the main thread
2. Each notification is dispatched to `handle_server_notification`, which updates model state and emits domain events via `ctx.emit()`
3. `LspManagerModel` subscribes to these events and re-emits them as higher-level manager events
We apply the same pattern to `RemoteServerManager`:
**Extend the event drain loop** in `connect_session()` (currently at `app/src/remote_server/manager.rs:181`). Instead of ignoring push events, forward them as `RemoteServerManagerEvent` variants:
```rust
// In the event drain loop (currently the while let Ok(event) block):
while let Ok(event) = event_rx.recv().await {
match event {
ClientEvent::Disconnected => break,
ClientEvent::RepoMetadataSnapshotReceived { update } => {
let _ = spawner.spawn(move |_me, ctx| {
ctx.emit(RemoteServerManagerEvent::RepoMetadataSnapshot {
host_id: host_id.clone(),
update,
});
}).await;
}
ClientEvent::RepoMetadataUpdated { update } => {
let _ = spawner.spawn(move |_me, ctx| {
ctx.emit(RemoteServerManagerEvent::RepoMetadataUpdated {
host_id: host_id.clone(),
update,
});
}).await;
}
}
}
```
**Add new event variants** to `RemoteServerManagerEvent`:
```rust
pub enum RemoteServerManagerEvent {
// ... existing variants ...
/// A full or lazy-loaded repo metadata snapshot was pushed by the server.
RepoMetadataSnapshot {
host_id: HostId,
update: repo_metadata::RepoMetadataUpdate,
},
/// An incremental repo metadata update was pushed by the server.
RepoMetadataUpdated {
host_id: HostId,
update: repo_metadata::RepoMetadataUpdate,
},
/// Response to a navigate_to_directory request.
NavigatedToDirectory {
host_id: HostId,
indexed_path: String,
is_git: bool,
},
}
```
**Note on `host_id` availability**: The event drain loop starts while the session is still in `Initializing` state (before the initialize handshake returns the `host_id`). Push events will only arrive after the handshake completes and `NavigatedToDirectory` is sent, so by that point the session is `Connected` and the `host_id` is known. The drain loop should capture the `host_id` from the `mark_session_connected` transition (e.g., via a shared `watch` channel or by looking it up from session state when emitting).
### 4.4. `RemoteRepoMetadataModel` subscribes to manager events
The remote model subscribes to `RemoteServerManagerEvent` and reacts to push events:
#### On `RemoteServerManagerEvent::RepoMetadataSnapshot { host_id, update }`
Call `self.insert_repository(host_id, update)` to populate the initial tree state.
#### On `RemoteServerManagerEvent::RepoMetadataUpdated { host_id, update }`
Call `self.apply_incremental_update(host_id, update)` to apply watcher-driven changes.
#### On `RemoteServerManagerEvent::HostDisconnected { host_id }`
Clean up remote repositories for that host.
The remote model no longer needs direct access to `RemoteServerClient` — all data arrives through the event channel. This keeps the model decoupled from connection management.
### 4.5. Update file tree view for remote repositories
#### 4.5a. Enablement state
Keep `CodingPanelEnablementState::RemoteSession`. When `FeatureFlag::SshRemoteServer` is enabled, change the file tree view's `render()` to check if remote root directories exist before showing the error. If `displayed_directories` is non-empty (remote roots present), render the tree normally regardless of `RemoteSession` enablement. When the flag is disabled, always render the existing disabled state for remote sessions.
#### 4.5b. Separate entry point for remote roots
The local pipeline (`PathBuf → normalize_cwd → WorkingDirectoriesModel → set_root_directories → try_from_local`) fails for remote paths at `dunce::canonicalize` and `try_from_local`. Rather than migrating that pipeline, add a separate entry point:
```rust
impl FileTreeView {
/// Sets root directories from a remote server.
/// Bypasses the local WorkingDirectoriesModel pipeline entirely.
pub fn set_remote_root_directories(
&mut self,
roots: Vec<(HostId, StandardizedPath)>,
ctx: &mut ViewContext<Self>,
) { /* ... */ }
}
```
Remote paths come from `NavigatedToDirectoryResponse.indexed_path` (a `String`), which converts directly to `StandardizedPath::try_new()` with no I/O.
Each `RootDirectory` gets an optional `RepositoryIdentifier` field so the view knows whether to query the local or remote model when loading contents.
Do NOT migrate `WorkingDirectoriesModel` or `normalize_cwd` to `StandardizedPath` — that's a much larger change with no immediate value for this feature.
#### 4.5c. Remote directory contents
`update_directory_contents()` currently looks up `DetectedRepositories` and calls `load_directory` (local-only). For remote roots:
- Look up `RepositoryIdentifier::Remote(RemoteRepositoryIdentifier { host_id, path })` in `RepoMetadataModel`
- Use the returned `FileTreeState.entry` directly as the root directory's entry
- Skip lazy loading / `DetectedRepositories` lookup entirely — the remote server handles indexing
#### 4.5d. Handle remote `RepoMetadataEvent`s
`handle_repository_metadata_event` currently only matches `RepositoryIdentifier::Local(..)` and ignores remote variants. Add handling for `RepositoryIdentifier::Remote(..)` in:
- `RepositoryUpdated` — triggers `update_directory_contents` for matching remote roots
- `FileTreeEntryUpdated` — refreshes the cached `FileTreeEntry` and calls `rebuild_flattened_items`
## 5. End-to-End Flow
```mermaid
sequenceDiagram
participant User
participant TerminalView
participant Workspace
participant RSManager as RemoteServerManager
participant RSClient as RemoteServerClient
participant RemoteServer
participant RemoteModel as RemoteRepoMetadataModel
participant FileTree as FileTreeView
User->>TerminalView: cd /home/user/project
TerminalView->>Workspace: AppStateChanged (pwd updated)
Workspace->>Workspace: update_active_session()
Workspace->>RSManager: navigate_to_directory(session_id, "/home/user/project")
RSManager->>RSClient: navigate_to_directory("/home/user/project")
RSClient->>RemoteServer: NavigatedToDirectory { path }
RemoteServer-->>RSClient: NavigatedToDirectoryResponse { indexed_path, is_git }
RSManager->>RSManager: emit NavigatedToDirectory event
Note over RemoteServer: Server proactively pushes snapshot
alt is_git = false (lazy tree)
RemoteServer->>RSClient: RepoMetadataSnapshot (push, lazy tree)
RSClient->>RSManager: ClientEvent::RepoMetadataSnapshotReceived
RSManager->>RSManager: emit RepoMetadataSnapshot event
RSManager->>RemoteModel: (via subscription)
RemoteModel->>RemoteModel: insert_repository(host_id, path, state)
RemoteModel->>FileTree: RepoMetadataEvent::RepositoryUpdated { Remote(..) }
else is_git = true (full git index)
RemoteServer->>RSClient: RepoMetadataSnapshot (push, after git indexing)
RSClient->>RSManager: ClientEvent::RepoMetadataSnapshotReceived
RSManager->>RSManager: emit RepoMetadataSnapshot event
RSManager->>RemoteModel: (via subscription)
RemoteModel->>RemoteModel: insert_repository(host_id, path, state)
RemoteModel->>FileTree: RepoMetadataEvent::RepositoryUpdated { Remote(..) }
end
FileTree->>FileTree: set_remote_root_directories + update_directory_contents
FileTree->>User: renders file tree
```
After initial population, incremental updates flow as:
```mermaid
sequenceDiagram
participant RemoteServer
participant RSClient as RemoteServerClient
participant RSManager as RemoteServerManager
participant RemoteModel as RemoteRepoMetadataModel
participant FileTree as FileTreeView
RemoteServer->>RSClient: RepoMetadataUpdatePush (empty request_id)
RSClient->>RSManager: ClientEvent::RepoMetadataUpdated
RSManager->>RSManager: emit RepoMetadataUpdated event
RSManager->>RemoteModel: (via subscription)
RemoteModel->>RemoteModel: apply_incremental_update()
RemoteModel->>FileTree: RepoMetadataEvent::FileTreeEntryUpdated { Remote(..) }
FileTree->>FileTree: rebuild_flattened_items + notify
```
## 6. Risks and Mitigations
**Risk**: Multiple rapid `cd` commands could fire overlapping `navigate_to_directory` requests. The same path could be navigated to before the first response arrives.
**Mitigation**: The manager should debounce or dedup: if a navigation is already in-flight for the same session, skip or cancel the previous one. The remote model can also idempotently handle duplicate `insert_repository` calls.
**Risk**: The remote server disconnects between `NavigatedToDirectoryResponse` and the `RepoMetadataSnapshot` push, leaving the remote model without tree data.
**Mitigation**: On `RemoteServerManagerEvent::HostDisconnected`, clear all remote repositories for that host. The file tree view will fall back to the "not supported" message.
**Risk**: `FileTreeView` rendering code is heavily `#[cfg(feature = "local_fs")]`-gated. Remote rendering needs to work on all platforms including WASM (where `local_fs` is disabled).
**Mitigation**: The remote root directory pipeline (`set_remote_root_directories`, remote `update_directory_contents` branch) should NOT be behind `#[cfg(feature = "local_fs")]` since it performs no local I/O.
**Risk**: The event drain loop starts before the initialize handshake completes, so `host_id` is not yet available when push events arrive.
**Mitigation**: Push events only arrive after `NavigatedToDirectory` is sent, which happens after the session reaches `Connected` state. The drain loop can look up the `host_id` from session state or receive it via a shared channel after the handshake.
## 7. Testing and Validation
- **Unit tests for `RemoteRepoMetadataModel` event handling**: Mock `RemoteServerManagerEvent::RepoMetadataSnapshot` / `RepoMetadataUpdated` events and verify the model calls `insert_repository()` / `apply_incremental_update()` correctly.
- **Unit tests for `FileTreeView` with remote roots**: Construct a `RemoteRepoMetadataModel` with test data, call `set_remote_root_directories`, verify the view queries the correct model and renders entries.
- **Integration test**: End-to-end flow from `navigate_to_directory` through push event delivery to file tree rendering, using the existing in-memory client/server test harness from `client_tests.rs`.
- **Manual testing**: SSH into a remote host, `cd` around, verify the Project Explorer populates with the remote file tree and updates incrementally on filesystem changes.
## 8. Follow-ups
- **Remote `load_directory`**: When a user expands a collapsed directory in the remote file tree, the client needs to send a request to the server for that subtree. Today `load_directory_from_model` is synchronous (local I/O). The remote case requires an async round-trip with a loading spinner. The `loaded: false` field on `FileTreeDirectoryEntryState` can drive this.
- **File tree cleanup on session close**: When all sessions to a host are closed and the remote server is torn down, clean up remote repos from `RemoteRepoMetadataModel`.
- **`WorkingDirectoriesModel` StandardizedPath migration**: The current `PathBuf`-based working directories pipeline could be migrated to `StandardizedPath` for consistency, but this is a larger refactor with no immediate functional benefit.
- **Remote file search**: `FileSearchModel` currently only queries local repos. Extending it to search remote repos requires a separate remote search protocol.
+305
View File
@@ -0,0 +1,305 @@
# Remote Server File Tree Protocol — Tech Spec
Linear: [APP-3788](https://linear.app/warpdotdev/issue/APP-3788)
## Problem
The remote server binary (`crates/remote_server`) currently only handles `Initialize`/`InitializeResponse`. We need to:
1. Boot repo metadata models on the server so it can index directories and keep file trees up to date
2. Let the client tell the server which directories to index (via `NavigatedToDirectory`)
3. Let the client fetch the initial tree and receive subsequent incremental updates as push messages
## Current State
### Remote server (`crates/remote_server`)
- `ServerModel` singleton handles stdin/stdout protobuf I/O
- `run()` boots a headless warpui app with only `ServerModel`
- Proto schema has only `Initialize`/`InitializeResponse`
### repo_metadata crate
- `LocalRepoMetadataModel` — indexes repos, subscribes to `DetectedRepositories` for auto-indexing, has `emit_incremental_updates: bool` field and emits `IncrementalUpdateReady` when enabled
- `DetectedRepositories` singleton — runs async git detection via `detect_possible_git_repo()`, emits `DetectedGitRepo` events. Uses `DirectoryWatcher` to register watch directories.
- `DirectoryWatcher` singleton — manages filesystem watchers and routes changes to `Repository` subscribers via a `TaskQueue`
- `LocalRepoMetadataModel` also supports lazy-loaded non-git directories via `index_lazy_loaded_path()` (first-level-only tree, `loaded: false` on subdirectories)
- The incremental update types (`RepoMetadataUpdate`, `FileTreeEntryUpdate`, etc.) already exist in `file_tree_update.rs`
### Key insight on two separate watchers
`DirectoryWatcher` and `LocalRepoMetadataModel` each own their own `BulkFilesystemWatcher`. `DirectoryWatcher`'s watcher feeds the `Repository` model (git status, etc.), while `LocalRepoMetadataModel`'s watcher feeds the file tree. Both need to be running on the server.
## Proposed Changes
### 1. Proto schema additions (`remote_server.proto`)
Names and fields mirror the Rust types in `repo_metadata/src/file_tree_update.rs` 1:1 for trivial conversion.
```proto
// ── Shared file tree sub-messages ─────────────────────────────────
// Mirror the Rust types in repo_metadata/src/file_tree_update.rs.
message RepoNodeMetadata {
oneof node {
DirectoryNodeMetadata directory = 1;
FileNodeMetadata file = 2;
}
}
message DirectoryNodeMetadata {
string path = 1;
bool ignored = 2;
bool loaded = 3;
}
message FileNodeMetadata {
string path = 1;
optional string extension = 2;
bool ignored = 3;
}
// Mirrors FileTreeEntryUpdate in Rust.
message FileTreeEntryUpdate {
string parent_path_to_replace = 1;
repeated RepoNodeMetadata subtree_metadata = 2;
}
// ── Client → server ───────────────────────────────────────────────
// "I navigated to this directory, please index it."
message NavigatedToDirectory {
string path = 1;
}
// Response after the server has run git detection on the requested path.
//
// - is_git = true: A git repo was found. indexed_path is the repo root.
// Full indexing runs in the background; the client should
// wait for RepositoryIndexedPush before calling FetchFileTree.
// - is_git = false: No git repo. The directory was lazily indexed at first
// level. indexed_path is the standardized input path.
// The client can call FetchFileTree immediately.
message NavigatedToDirectoryResponse {
string indexed_path = 1;
bool is_git = 2;
}
// "Give me the current tree for this repo."
message FetchFileTree {
string repo_path = 1;
}
// Sent as one or more responses for the same request_id.
// Client accumulates entries until sync_complete = true.
message FetchFileTreeResponse {
string repo_path = 1;
repeated FileTreeEntryUpdate entries = 2;
bool sync_complete = 3;
}
// ── Server → client push (empty request_id) ───────────────────────
// Mirrors RepoMetadataUpdate in Rust.
message FileTreeUpdatePush {
string repo_path = 1;
repeated string remove_entries = 2;
repeated FileTreeEntryUpdate update_entries = 3;
}
// A repository finished full indexing and is ready for FetchFileTree.
message RepositoryIndexedPush {
string repo_path = 1;
}
```
Updated envelopes:
```proto
message ClientMessage {
string request_id = 1;
oneof message {
Initialize initialize = 2;
NavigatedToDirectory navigated_to_directory = 3;
FetchFileTree fetch_file_tree = 4;
}
}
message ServerMessage {
string request_id = 1;
oneof message {
InitializeResponse initialize_response = 2;
ErrorResponse error = 3;
NavigatedToDirectoryResponse navigated_to_directory_response = 4;
FetchFileTreeResponse fetch_file_tree_response = 5;
FileTreeUpdatePush file_tree_update = 6;
RepositoryIndexedPush repository_indexed = 7;
}
}
```
Push messages use an empty `request_id` to distinguish them from request/response pairs.
### 2. Server-side model bootstrap
Update `remote_server::run()` to register the repo metadata singletons:
```rust
AppBuilder::new_headless(...).run(|ctx| {
ctx.add_singleton_model(DirectoryWatcher::new);
ctx.add_singleton_model(DetectedRepositories::default_entity);
ctx.add_singleton_model(|ctx| {
let mut model = LocalRepoMetadataModel::new(ctx);
model.set_emit_incremental_updates(true);
model
});
ctx.add_singleton_model(ServerModel::new);
});
```
This automatically wires up the existing `DetectedRepositories``LocalRepoMetadataModel` subscription and the watcher → `LocalRepoMetadataModel` update pipeline.
New API needed: `LocalRepoMetadataModel::set_emit_incremental_updates(&mut self, enabled: bool)` (or a builder-style constructor parameter).
### 3. Server-side message handling
#### `NavigatedToDirectory`
When the server receives `NavigatedToDirectory { path }`:
1. Await `detect_possible_git_repo(path)` — this checks the in-memory cache first (instant if already known), otherwise walks up the directory tree checking for `.git` (fast filesystem metadata, not full indexing)
2. If a git repo was found (`Some(git_root)`):
- Full indexing was already triggered by the `DetectedGitRepo``LocalRepoMetadataModel` subscription inside `detect_possible_git_repo`
- Respond with `{ indexed_path: git_root, is_git: true }`
- Client waits for `RepositoryIndexedPush` before calling `FetchFileTree`
3. If no git repo (`None`):
- Call `index_lazy_loaded_path(path)` for first-level-only data
- Respond with `{ indexed_path: standardized_path, is_git: false }`
- Client can call `FetchFileTree` immediately
#### `FetchFileTree`
When the server receives `FetchFileTree { repo_path }`:
1. Look up the repository in `LocalRepoMetadataModel` via `get_repository(&repo_path)`
2. If `Indexed`: serialize the full `FileTreeEntry` as one or more `FetchFileTreeResponse` chunks (see section on streaming pagination below)
3. If `Pending`: return `ErrorResponse` — the client retries after receiving `RepositoryIndexedPush`
4. If `Failed` or not found: return `ErrorResponse`
Serialization: Walk the `FileTreeEntry`'s `state_map` and `parent_to_child_map` to produce `FileTreeEntryUpdate` entries. This is the same shape as `RepoMetadataUpdate` but for the full tree.
#### Incremental update push
The `ServerModel` subscribes to `LocalRepoMetadataModel`'s `IncrementalUpdateReady` events. On receiving the event:
1. Convert the `RepoMetadataUpdate` to `FileTreeUpdatePush` proto
2. Send as a `ServerMessage` with empty `request_id`
### 4. Conversion layer: Rust types ↔ Proto
Add a new module `crates/remote_server/src/file_tree_proto.rs` with:
- `RepoMetadataUpdate``FileTreeUpdatePush` proto
- `FileTreeEntry``FetchFileTreeResponse` proto (full tree serialization with chunking)
- Proto `FetchFileTreeResponse` / `FileTreeUpdatePush``RepoMetadataUpdate` for client-side application
These conversions are straightforward because the Rust types in `file_tree_update.rs` were designed to mirror the proto schema 1:1.
### 5. Client-side changes
#### `RemoteServerClient` additions
Add methods to the client:
- `navigate_to_directory(&self, path: String) -> Result<NavigatedToDirectoryResponse>`
- `fetch_file_tree(&self, repo_path: String) -> Result<FetchFileTreeResponse>` (accumulates chunked responses)
- Handle push messages (`FileTreeUpdatePush`, `RepositoryIndexedPush`) in the client's reader loop and emit them as client events
#### `RemoteServerClient` event handling
The client's reader task receives `ServerMessage`s. For push messages (empty `request_id`), route to event emission instead of completing a pending request:
```rust
RemoteServerClientEvent::FileTreeUpdated { update: RepoMetadataUpdate }
RemoteServerClientEvent::RepositoryIndexed { repo_path: String }
```
The downstream consumer (future file tree view integration) subscribes to these events and calls `RemoteRepoMetadataModel::apply_incremental_update()` for `FileTreeUpdated`, and `RemoteRepoMetadataModel::insert_repository()` for the initial tree after calling `fetch_file_tree`.
## Design Decisions
### 1. NavigatedToDirectory: await git detection, then branch
The local `FileTreeView::update_directory_contents` (`view.rs:703`) uses a two-pronged approach: check for a git repo first, fall back to lazy-loading if none is found. The remote server mirrors this but runs git detection synchronously within the request handling so the client gets a definitive answer in one round trip:
1. Server awaits `detect_possible_git_repo(path)` — checks in-memory cache first (instant for known repos), otherwise walks up the directory tree (fast filesystem metadata checks, not full indexing)
2. If git repo found: respond with `{ indexed_path: git_root, is_git: true }`. Full indexing was already triggered by `DetectedGitRepo``LocalRepoMetadataModel`. Client waits for `RepositoryIndexedPush` before `FetchFileTree`.
3. If no git repo: server calls `index_lazy_loaded_path(path)` for first-level data, responds with `{ indexed_path: path, is_git: false }`. Client calls `FetchFileTree` immediately.
This avoids the unnecessary eager lazy-load for git repos (which would be thrown away when full indexing completes) and gives the client clear instructions in a single response.
### 2. Initial tree fetch: server-controlled streaming pagination
Pagination is controlled by the server based on actual response size, not tree depth (a flat repo could have huge amounts of data at each level). The protocol:
1. Server serializes the tree top-to-bottom (breadth-first or depth-first pre-order)
2. Each `FetchFileTreeResponse` chunk contains a batch of entries plus a `bool sync_complete` flag
3. The server segments by a target byte budget per chunk (e.g. 256KB)
4. The client renders progressively as chunks arrive, and knows the full tree is loaded when `sync_complete = true`
Multiple `FetchFileTreeResponse` messages are sent for the same `request_id`. The client accumulates them and applies each chunk to the `RemoteRepoMetadataModel` as it arrives.
## End-to-End Flow
### Case A: Directory is inside a git repo
```
Client Server
│ │
User navigates to │ NavigatedToDirectory { path } │
/home/user/project/src │ ────────────────────────────────────────> │
│ │── await detect_possible_git_repo(path)
│ │ → found git root /home/user/project
│ │ (full indexing triggered in bg)
│ Response { indexed_path: .../project, │
│ is_git: true } │
│ <──────────────────────────────────────── │
│ │
Client waits... │ ... full repo indexing completes ... │
│ │
│ RepositoryIndexedPush { repo_path } │
│ <──────────────────────────────────────── │
│ │
Now fetch full tree │ FetchFileTree { repo_path } │
│ ────────────────────────────────────────> │
│ │
│ FetchFileTreeResponse { ... true } │ ← full tree (chunked)
│ <──────────────────────────────────────── │
│ │
│ ... file watcher detects changes ... │
│ │
│ FileTreeUpdatePush { incremental } │ ← push, empty request_id
│ <──────────────────────────────────────── │
```
### Case B: Directory is NOT a git repo
```
Client Server
│ │
User navigates to │ NavigatedToDirectory { path } │
/tmp/some-dir │ ────────────────────────────────────────> │
│ │── await detect_possible_git_repo → None
│ │── index_lazy_loaded_path(path)
│ Response { indexed_path: /tmp/some-dir, │
│ is_git: false } │
│ <──────────────────────────────────────── │
│ │
Fetch immediately │ FetchFileTree { repo_path } │
│ ────────────────────────────────────────> │
│ │
│ FetchFileTreeResponse { ... true } │ ← first-level tree
│ <──────────────────────────────────────── │
```
## Follow-ups (out of scope)
- Wire the client events to `RemoteRepoMetadataModel` and `FileTreeView`
- `LoadDirectory` request for expanding collapsed directories over the network
- Subscription management (unsubscribe from updates when file tree is closed)
+349
View File
@@ -0,0 +1,349 @@
# Incremental Repo Metadata Syncing — Tech Spec
## Problem
The `LocalRepoMetadataModel` on the remote server keeps its file tree up to date via filesystem watchers. The client's `RemoteRepoMetadataModel` has no filesystem access and currently no mechanism to receive incremental updates — its only write API (`update_file_tree_entry`) replaces the entire `FileTreeEntry`, which is too expensive for frequent watcher-driven changes.
We need two new capabilities:
1. **Server side**: After the `LocalRepoMetadataModel` applies watcher-driven mutations, generate a serializable incremental update describing what changed.
2. **Client side**: The `RemoteRepoMetadataModel` applies that incremental update to its own `FileTreeEntry`.
These two APIs form the data layer of the sync protocol. The transport layer (protobuf encoding and SSH streaming) is out of scope for this spec but the Rust types are designed to map 1:1 to the proto schema for trivial conversion.
## Relevant Code
- `crates/repo_metadata/src/local_model.rs:121``FileTreeMutation` enum (the internal mutation representation)
- `crates/repo_metadata/src/local_model.rs:542``compute_file_tree_mutations()` (Phase 1: background I/O)
- `crates/repo_metadata/src/local_model.rs:607``apply_file_tree_mutations()` (Phase 2: main-thread tree ops)
- `crates/repo_metadata/src/local_model.rs:218``handle_watcher_event()` (orchestrates Phase 1 → Phase 2)
- `crates/repo_metadata/src/local_model.rs:699``ensure_parent_directories_exist()` (tree helper, needs extraction)
- `crates/repo_metadata/src/remote_model.rs:96``insert_repository()`, `update_file_tree_entry()` (existing write API)
- `crates/repo_metadata/src/file_tree_store.rs:10``FileTreeEntry` struct and mutation primitives
- `crates/repo_metadata/src/file_tree_store.rs:149``FileTreeEntryState`, `FileTreeFileMetadata`, `FileTreeDirectoryEntryState`
- `crates/repo_metadata/src/wrapper_model.rs:27``RepoMetadataEvent` (unified event enum to extend)
## Current State
### Watcher → mutation flow (server side)
`LocalRepoMetadataModel::handle_watcher_event` receives `BulkFilesystemWatcherEvent`s, groups changes by repository, then runs a two-phase pipeline:
1. **`compute_file_tree_mutations`** (async, background thread) — performs filesystem I/O (`exists()`, `is_dir()`, `build_tree()`, gitignore checks) and produces `Vec<FileTreeMutation>`.
2. **`apply_file_tree_mutations`** (sync, main thread) — walks the mutation list and directly mutates the `FileTreeEntry` using its primitives (`remove`, `insert_child_state`, `insert_entry_at_path`, `find_or_insert_directory`).
The `FileTreeMutation` enum has four variants:
- `Remove(PathBuf)`
- `AddFile { path, is_ignored, extension }`
- `AddDirectorySubtree { dir_path, subtree: Entry }``Entry` is a recursive tree
- `AddEmptyDirectory { path, is_ignored }`
These mutations are consumed internally and never leave the model. There is no mechanism to observe or forward them.
### RemoteRepoMetadataModel (client side)
A stub model with read-only query API and three write methods:
- `insert_repository` — sets full `FileTreeState` for a new repo
- `remove_repository` — drops a repo
- `update_file_tree_entry` — replaces the *entire* `FileTreeEntry`
There is no incremental update path. The `update_file_tree_entry` method is a full replacement, not a patch.
### FileTreeEntry internals
`FileTreeEntry` wraps `FileTreeMapStore`, which stores two flattened hash maps:
- `state_map: HashMap<Arc<Path>, FileTreeEntryState>` — path → metadata
- `parent_to_child_map: HashMap<Arc<Path>, HashSet<Arc<Path>>>` — parent → children
This flat representation is important: the incremental update format should express changes in terms of these same two maps so that applying an update is a direct merge.
## Proposed Changes
### 1. New module: `file_tree_update.rs`
New types that mirror the proto schema 1:1:
```rust
/// Mirrors `RepoMetadataUpdate` proto.
/// A batch of incremental changes for a single repository.
#[derive(Debug, Clone)]
pub struct RepoMetadataUpdate {
/// Which repository this update targets.
pub repo_path: StandardizedPath,
/// Paths to remove from the tree.
pub remove_entries: Vec<PathBuf>,
/// Subtree patches to add or replace.
pub update_entries: Vec<FileTreeEntryUpdate>,
}
/// Mirrors `FileTreeEntry` proto.
/// Describes a subtree patch rooted at a specific parent directory.
#[derive(Debug, Clone)]
pub struct FileTreeEntryUpdate {
/// The parent directory whose subtree is being patched.
pub parent_path_to_replace: PathBuf,
/// Metadata for each node in the subtree.
/// Directories must appear before their children (depth-first pre-order).
pub subtree_metadata: Vec<RepoNodeMetadata>,
}
/// Mirrors `RepoNodeMetadata` proto.
#[derive(Debug, Clone)]
pub enum RepoNodeMetadata {
Directory(DirectoryNodeMetadata),
File(FileNodeMetadata),
}
/// Mirrors `DirectoryNodeMetadata` proto.
#[derive(Debug, Clone)]
pub struct DirectoryNodeMetadata {
pub path: PathBuf,
pub ignored: bool,
pub loaded: bool,
}
/// Mirrors `FileNodeMetadata` proto.
#[derive(Debug, Clone)]
pub struct FileNodeMetadata {
pub path: PathBuf,
pub extension: Option<String>,
pub ignored: bool,
}
```
Each `FileTreeEntryUpdate` represents a subtree patch rooted at a specific parent. Parent→child relationships are not sent explicitly — they are derived implicitly during application because each node's parent is determined by its path, and `insert_child_state` registers the child in `parent_to_child_map`. This simplifies the wire format: only `remove_entries` (paths) and `subtree_metadata` (node metadata in depth-first pre-order) are needed.
### 2. Server side: generate `RepoMetadataUpdate` from `FileTreeMutation`s
#### Configuration flag
Add a field to `LocalRepoMetadataModel`:
```rust
pub struct LocalRepoMetadataModel {
// ... existing fields ...
/// When true, emit `IncrementalUpdateReady` events after applying
/// watcher mutations. Only the remote server variant enables this.
emit_incremental_updates: bool,
}
```
Defaults to `false`. A new constructor or setter enables it for the remote server context.
#### Conversion function
Add a method that converts `Vec<FileTreeMutation>``RepoMetadataUpdate`:
```rust
impl LocalRepoMetadataModel {
/// Converts internal file tree mutations into a serializable
/// `RepoMetadataUpdate` suitable for sending to the remote client.
fn generate_repo_metadata_update(
repo_path: &StandardizedPath,
mutations: &[FileTreeMutation],
) -> RepoMetadataUpdate { ... }
}
```
The conversion logic per variant:
- `Remove(path)` → append to `remove_entries`
- `AddFile { path, is_ignored, extension }` → create a `FileTreeEntryUpdate` with `parent_path_to_replace` = parent of `path`, one `FileNodeMetadata`
- `AddDirectorySubtree { dir_path, subtree }` → flatten the recursive `Entry` into a `Vec<RepoNodeMetadata>` in depth-first pre-order, set `parent_path_to_replace` = parent of `dir_path`
- `AddEmptyDirectory { path, is_ignored }` → same shape as `AddFile` but with `DirectoryNodeMetadata`
The `Entry` flattening walks the recursive tree depth-first, emitting directory metadata before children, so the ordering guarantee is maintained.
#### New event variant
```rust
pub enum RepositoryMetadataEvent {
// ... existing variants ...
/// Emitted after watcher mutations are applied, containing the
/// serializable update for the remote client.
IncrementalUpdateReady {
update: RepoMetadataUpdate,
},
}
```
#### Updated watcher handler flow
`apply_file_tree_mutations` returns the mutations that were actually applied (filtering out any that were skipped due to `lazy_load`). The update is then generated from only the applied mutations, ensuring the remote client never receives entries the server didn't apply.
```rust
let applied = Self::apply_file_tree_mutations(&mut state.entry, mutations, lazy_load);
ctx.emit(RepositoryMetadataEvent::FileTreeEntryUpdated { path: repo_path.clone() });
if model.emit_incremental_updates {
let update = Self::generate_repo_metadata_update(&repo_path, &applied);
ctx.emit(RepositoryMetadataEvent::IncrementalUpdateReady { update });
}
```
#### Lazy-loaded repositories
The remote server indexes both git repositories (via `DetectedRepositories`) and non-git directories (via `index_lazy_loaded_path` for file tree rendering). Lazy-loaded paths have `loaded: false` on unexpanded directories; when `lazy_load` is true, `apply_file_tree_mutations` skips mutations whose parent directory hasn't been expanded.
This filtering is critical for incremental updates: without it, the remote client would receive entries that the server's own tree doesn't contain, causing divergence. By generating the update from the *returned* applied mutations, the update accurately reflects the server's tree state regardless of whether the repository is fully indexed or lazily loaded.
When a user expands a collapsed directory on the remote client, the client calls `load_directory` to eagerly fetch its contents from the server. Today, the local file tree's `load_directory_from_model` is synchronous (local filesystem I/O), so no loading indicator exists. For the remote case this will involve a network round-trip, so a follow-up is needed to add an async flow with a loading/spinner state in the file tree UI while the request is in flight. The `loaded: false` field on `FileTreeDirectoryEntryState` already distinguishes collapsed (not-yet-loaded) directories from expanded ones, which can drive that loading state.
### 3. Client side: apply `RepoMetadataUpdate` on `RemoteRepoMetadataModel`
#### New method on `RemoteRepoMetadataModel`
```rust
impl RemoteRepoMetadataModel {
/// Applies an incremental update received from the remote server.
pub fn apply_incremental_update(
&mut self,
update: RepoMetadataUpdate,
ctx: &mut ModelContext<Self>,
) {
let id = /* look up RemoteRepositoryIdentifier from update.repo_path */;
if let Some(IndexedRepoState::Indexed(state)) = self.repositories.get_mut(&id) {
state.entry.apply_repo_metadata_update(&update);
ctx.emit(RemoteRepositoryMetadataEvent::FileTreeEntryUpdated {
id: id.clone(),
});
}
}
}
```
#### New method on `FileTreeEntry`
The core mutation application logic lives on `FileTreeEntry` so it can be unit-tested independently:
```rust
impl FileTreeEntry {
/// Applies a `RepoMetadataUpdate` to this file tree entry.
pub fn apply_repo_metadata_update(&mut self, update: &RepoMetadataUpdate) {
// 1. Process removals
for path in &update.remove_entries {
self.remove(path);
}
// 2. Process subtree patches
for entry_update in &update.update_entries {
self.apply_entry_update(entry_update);
}
}
fn apply_entry_update(&mut self, update: &FileTreeEntryUpdate) {
// Ensure parent directories exist up to parent_path_to_replace
self.ensure_parent_directories_exist(&update.parent_path_to_replace);
// subtree_metadata is in depth-first pre-order: each directory
// appears before its children. A single pass is sufficient because
// by the time we encounter a file, its parent directory has already
// been inserted. insert_child_state also registers the child in
// parent_to_child_map, so no separate wiring step is needed.
for node in &update.subtree_metadata {
// ... match Directory / File, build state, insert_child_state
}
}
}
```
### 4. Extract `ensure_parent_directories_exist` to `FileTreeEntry`
Currently a static method on `LocalRepoMetadataModel` (`local_model.rs:699`). Move it to `FileTreeEntry` so both the local apply path and the remote apply path can use it:
```rust
impl FileTreeEntry {
/// Ensures all ancestor directories between root and `target_parent`
/// exist in the tree, creating unloaded directory entries as needed.
pub fn ensure_parent_directories_exist(&mut self, target_parent: &Path) { ... }
}
```
The existing `apply_file_tree_mutations` in `local_model.rs` is updated to call `root_entry.ensure_parent_directories_exist(parent)` instead of `Self::ensure_parent_directories_exist(root_entry, parent)`.
### 5. Forward `IncrementalUpdateReady` through the wrapper
Add a new variant to `RepoMetadataEvent` in `wrapper_model.rs`:
```rust
pub enum RepoMetadataEvent {
// ... existing variants ...
IncrementalUpdateReady {
update: RepoMetadataUpdate,
},
}
```
And forward it in `forward_local_event`:
```rust
RepositoryMetadataEvent::IncrementalUpdateReady { update } => {
RepoMetadataEvent::IncrementalUpdateReady {
update: update.clone(),
}
}
```
### 6. Crate structure update
```
crates/repo_metadata/src/
├── file_tree_update.rs (NEW — RepoMetadataUpdate and related types)
├── file_tree_store.rs (MODIFIED — add apply_repo_metadata_update, ensure_parent_directories_exist)
├── local_model.rs (MODIFIED — emit_incremental_updates flag, generate_repo_metadata_update)
├── remote_model.rs (MODIFIED — apply_incremental_update)
├── wrapper_model.rs (MODIFIED — forward IncrementalUpdateReady)
└── lib.rs (MODIFIED — re-export new types)
```
## End-to-End Flow
```mermaid
sequenceDiagram
participant W as File Watcher
participant L as LocalRepoMetadataModel<br/>(remote server)
participant N as Network Layer<br/>(future, out of scope)
participant R as RemoteRepoMetadataModel<br/>(client)
W->>L: BulkFilesystemWatcherEvent
L->>L: compute_file_tree_mutations() [bg thread]
L->>L: apply_file_tree_mutations() [main thread]
alt emit_incremental_updates = true
L->>L: generate_repo_metadata_update()
L-->>N: emit IncrementalUpdateReady { update }
N-->>R: (transport — protobuf over SSH)
R->>R: apply_incremental_update(update)
R->>R: emit FileTreeEntryUpdated
end
```
## Risks and Mitigations
1. **Mutation ordering**: `apply_repo_metadata_update` processes removals before additions. This matches the current `apply_file_tree_mutations` order, which is correct because a "move" is expressed as remove-old + add-new. If ordering changes in the local model, the serialization must preserve that order.
2. **`FileId` preservation**: When a file already exists in the remote tree (e.g., only its `ignored` flag changed), `apply_entry_update` preserves the existing `FileId` via `get_mut` + `set_ignored` rather than creating a new entry. New files get a fresh `FileId`. Cross-environment `FileId` equality is not guaranteed; if needed, `FileId` would be added to the proto.
3. **Entry flattening fidelity**: The `AddDirectorySubtree``FileTreeEntryUpdate` conversion must faithfully reproduce the recursive `Entry`'s parent→child and metadata structure. A mismatch would cause the client tree to diverge from the server. Unit tests comparing round-tripped trees mitigate this.
4. **Large updates**: A single watcher batch could touch many files (e.g., `git checkout` of a branch with many changes). The `RepoMetadataUpdate` for such a batch could be large. The proto schema supports pagination by tree depth (described in the parent design doc) but this spec does not implement it — the full batch is sent as one update. This can be revisited if bandwidth proves problematic.
## Testing and Validation
### Unit tests in `repo_metadata` crate
- **`generate_repo_metadata_update` tests**: Construct `FileTreeMutation` lists covering each variant (Remove, AddFile, AddDirectorySubtree, AddEmptyDirectory) → verify the resulting `RepoMetadataUpdate` has correct `remove_entries` and `update_entries` structure.
- **`Entry` flattening round-trip**: Build a recursive `Entry`, flatten it via the `AddDirectorySubtree` conversion path, apply the resulting `FileTreeEntryUpdate` to an empty `FileTreeEntry`, and verify the tree matches the original.
- **`apply_repo_metadata_update` tests on `FileTreeEntry`**: Start with a known tree state, apply a `RepoMetadataUpdate`, verify the resulting tree matches expectations (correct entries added, removed, parent→child relationships correct).
- **`apply_incremental_update` on `RemoteRepoMetadataModel`**: Verify that applying an update emits `FileTreeEntryUpdated` and that `get_repository` returns the updated state.
- **`ensure_parent_directories_exist` extraction**: Existing local model tests continue to pass after moving the helper to `FileTreeEntry`.
- **Lazy-load filtering**: Mutations targeting unloaded parent directories are excluded from the applied list and therefore excluded from the generated update. Mutations targeting loaded parents pass through.
### Integration tests
- End-to-end test that creates a `LocalRepoMetadataModel` with `emit_incremental_updates = true`, triggers watcher events, captures the emitted `RepoMetadataUpdate`, applies it to a `RemoteRepoMetadataModel`, and verifies both models have equivalent tree state.
## Follow-ups
- **Proto definitions**: Define the actual `.proto` schema and implement `From`/`Into` conversions between the Rust types and generated proto types.
- **Transport layer**: Wire the `IncrementalUpdateReady` event through the SSH protobuf stream.
- **Initial full sync**: The initial tree sync (described in the parent design doc as `FetchInitialRepoMetadata`) uses the same `FileTreeEntryUpdate` shape but as a response rather than a push. Implement this as a separate request/response flow.
- **Paginated sync**: Split large updates by tree depth for progressive rendering.
- **Lazy loading over network**: The client's `load_directory` for remote repos needs a request/response cycle to the server, not covered here.
+159
View File
@@ -0,0 +1,159 @@
# RepoMetadataModel Tech Spec
## Problem Statement
`RepositoryMetadataModel` is a singleton that tracks repositories and their file tree state. It currently only supports local file trees backed by a filesystem watcher. To support remote development (SSH), we need a model that can also hold file tree state sourced from a remote server.
The tech design ("Remote code model sync") proposes a generic wrapper `RepoMetadataModel` that dispatches to environment-specific sub-models. This spec details the implementation of that wrapper, the new `RemoteRepoMetadataModel` (client-side only, no syncing/indexing yet), and the consumer migration path.
## Current State
### Key types (all in `repo_metadata` crate)
* **`RepositoryMetadataModel`** (`model.rs`) — singleton, holds `HashMap<CanonicalizedPath, IndexedRepoState>` + an optional `BulkFilesystemWatcher`. Subscribes to `DetectedRepositories` for auto-indexing and the watcher for incremental updates.
* **`FileTreeState`** — holds a `FileTreeEntry` (the flattened map store), a `Vec<Gitignore>`, and an optional `ModelHandle<Repository>`.
* **`FileTreeEntry`** (`file_tree_store.rs`) — wraps `FileTreeMapStore` (parent→children + path→metadata hash maps) plus a `root_path: Arc<Path>`.
* **`CanonicalizedPath`** (`lib.rs`) — a `PathBuf` wrapper that `dunce::canonicalize`s on construction. Used as the HashMap key for repositories.
* **`SessionId`** (`app/src/terminal/model/session.rs`) — `u64` wrapper identifying a terminal session, already used to distinguish SSH sessions.
### Consumers in `app/`
* **`FileTreeView`** (`code/file_tree/view.rs`) — stores a `ModelHandle<RepositoryMetadataModel>`, subscribes to events, calls `get_repository`, `repository_state`, `is_lazy_loaded_path`, `load_directory`, `index_lazy_loaded_path`, `remove_lazy_loaded_path`.
* **`FileSearchModel`** (`search/files/model.rs`) — subscribes to `RepositoryMetadataEvent`, calls `has_repository`, `get_repo_contents`.
* **`SkillWatcher`** (`ai/skills/file_watchers/skill_watcher.rs`) — subscribes to `RepositoryMetadataEvent`, calls `RepositoryMetadataModel::as_ref(ctx)` for tree queries.
## Proposed Changes
### 1. New types
#### `RepositoryIdentifier`
A discriminated identifier for repositories across local and remote environments.
```rust
/// Identifies a repository across local and remote environments.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RepositoryIdentifier {
Local(CanonicalizedPath),
Remote(RemoteRepositoryIdentifier),
}
```
#### `RemoteRepositoryIdentifier`
Pairs a session ID with the server-side path. Uses raw `PathBuf` because the path lives on the remote machine and cannot be canonicalized locally.
```rust
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RemoteRepositoryIdentifier {
pub session_id: SessionId,
pub path: PathBuf,
}
```
`SessionId` will be moved from `app/src/terminal/model/session.rs` to `warp_core` so that `repo_metadata` can depend on it directly without circular crate dependencies.
### 2. `LocalRepoMetadataModel` (rename of existing model)
The existing `RepositoryMetadataModel` is renamed to `LocalRepoMetadataModel`. Its API is unchanged:
* `new(ctx)` — sets up watcher + `DetectedRepositories` subscription.
* `index_directory`, `index_lazy_loaded_path`, `load_directory`, `remove_lazy_loaded_path`, `remove_repository`.
* `get_repository`, `repository_state`, `has_repository`, `is_lazy_loaded_path`, `get_repo_contents`.
* Emits `RepositoryMetadataEvent` (unchanged).
The rename is mechanical: update the struct name, the `impl Entity`, `impl SingletonEntity`, and all import sites.
### 3. `RemoteRepoMetadataModel` (new, client-side only)
A model that holds file tree state for repositories on remote servers. In this initial phase it has **no syncing or indexing** — state is populated externally (e.g. by a future remote client model or via test helpers).
```rust
pub struct RemoteRepoMetadataModel {
repositories: HashMap<RemoteRepositoryIdentifier, IndexedRepoState>,
}
```
#### Events
Re-uses the same event enum shape but scoped to remote identifiers:
```rust
#[derive(Debug)]
pub enum RemoteRepositoryMetadataEvent {
RepositoryUpdated { id: RemoteRepositoryIdentifier },
RepositoryRemoved { id: RemoteRepositoryIdentifier },
FileTreeUpdated { ids: Vec<RemoteRepositoryIdentifier> },
FileTreeEntryUpdated { id: RemoteRepositoryIdentifier },
}
```
#### Read-only query API
Matches the local model's query surface:
* `get_repository(&self, id: &RemoteRepositoryIdentifier) -> Option<&FileTreeState>`
* `has_repository(&self, id: &RemoteRepositoryIdentifier) -> bool`
* `repository_state(&self, id: &RemoteRepositoryIdentifier) -> Option<&IndexedRepoState>`
* `get_repo_contents(&self, id: &RemoteRepositoryIdentifier, args: GetContentsArgs) -> Option<Vec<RepoContent<'_>>>`
#### Write API (for future sync + test use)
* `insert_repository(&mut self, id: RemoteRepositoryIdentifier, state: FileTreeState, ctx: &mut ModelContext<Self>)` — inserts/replaces state, emits `RepositoryUpdated`.
* `remove_repository(&mut self, id: &RemoteRepositoryIdentifier, ctx: &mut ModelContext<Self>)` — removes state, emits `RepositoryRemoved`.
* `update_file_tree_entry(&mut self, id: &RemoteRepositoryIdentifier, entry: FileTreeEntry, ctx: &mut ModelContext<Self>)` — replaces the entry within an existing `FileTreeState`, emits `FileTreeEntryUpdated`.
These will be the integration points for the future remote sync layer.
### 4. `RepoMetadataModel` wrapper
A singleton that holds handles to both sub-models and provides a unified query API keyed by `RepositoryIdentifier`.
```rust
pub struct RepoMetadataModel {
local: ModelHandle<LocalRepoMetadataModel>,
remote: ModelHandle<RemoteRepoMetadataModel>,
}
```
#### Construction
```rust
impl RepoMetadataModel {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let local = ctx.add_model(|ctx| LocalRepoMetadataModel::new(ctx));
let remote = ctx.add_model(|ctx| RemoteRepoMetadataModel::new(ctx));
// Forward events from both sub-models to a unified event stream.
ctx.subscribe_to_model(&local, Self::forward_local_event);
ctx.subscribe_to_model(&remote, Self::forward_remote_event);
Self { local, remote }
}
}
```
#### Unified events
```rust
#[derive(Debug)]
pub enum RepoMetadataEvent {
RepositoryUpdated { id: RepositoryIdentifier },
RepositoryRemoved { id: RepositoryIdentifier },
FileTreeUpdated { ids: Vec<RepositoryIdentifier> },
FileTreeEntryUpdated { id: RepositoryIdentifier },
UpdatingRepositoryFailed { id: RepositoryIdentifier },
}
```
The wrapper maps sub-model events into the unified enum.
#### Unified query API
Read operations are dispatched to the appropriate sub-model based on the `RepositoryIdentifier` variant:
* `get_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> Option<&FileTreeState>`
* `has_repository(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> bool`
* `repository_state(&self, id: &RepositoryIdentifier, ctx: &AppContext) -> Option<&IndexedRepoState>`
* `get_repo_contents(&self, id: &RepositoryIdentifier, args: GetContentsArgs, ctx: &AppContext) -> Option<Vec<RepoContent<'_>>>`
Note: because the wrapper accesses sub-models through `ModelHandle`, the read APIs require an `AppContext` parameter to dereference the handle. Delegating via `as_ref(ctx)` is simpler than caching and avoids duplication.
#### Local-specific operations
Operations that are inherently local (watcher management, lazy loading, indexing) are exposed directly on the wrapper, which delegates to `LocalRepoMetadataModel` internally via `self.local.update(ctx, ...)`. The sub-model handles are **not** exposed to consumers.
* `index_directory(&self, repository: ModelHandle<Repository>, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>`
* `index_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>`
* `load_directory(&self, repo_root: &Path, dir_path: &Path, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>`
* `remove_lazy_loaded_path(&self, path: &Path, ctx: &mut ModelContext<Self>)`
* `remove_repository(&self, id: &RepositoryIdentifier, ctx: &mut ModelContext<Self>) -> Result<(), RepoMetadataError>` — dispatches to the correct sub-model based on variant.
* `is_lazy_loaded_path(&self, path: &Path, ctx: &AppContext) -> bool`
* `find_repository_for_path(&self, path: &Path, ctx: &AppContext) -> Option<CanonicalizedPath>`
As remote equivalents are needed (e.g. triggering a remote directory load via the sync layer), they can be added to the wrapper with `RepositoryIdentifier`-based signatures.
#### Encapsulation
The wrapper does **not** expose `.local()` or `.remote()` accessors. All consumers interact exclusively through `RepoMetadataModel`'s public API. This ensures:
1. Consumers are decoupled from the local/remote split — they don't know or care which sub-model handles their request.
2. Adding new environment variants (e.g. containers) doesn't require touching consumers.
3. The wrapper can evolve its internal delegation strategy (e.g. caching, batching) without breaking callers.
### 5. Crate structure
All new types live in the `repo_metadata` crate:
* `lib.rs` — re-exports, `CanonicalizedPath`, `RepositoryIdentifier`, `RemoteRepositoryIdentifier`.
* `model.rs` → renamed to `local_model.rs` (contains `LocalRepoMetadataModel`).
* `remote_model.rs` (new, contains `RemoteRepoMetadataModel`).
* `wrapper_model.rs` (new, contains `RepoMetadataModel`).
* `file_tree_store.rs` — unchanged, shared by both models.
### 6. Consumer migration plan
The migration can be done incrementally. The key invariant is that **existing local-only behavior is preserved** — the wrapper simply adds a remote dimension.
#### Phase 1: Introduce types + wrapper (this spec)
1. Add `RepositoryIdentifier`, `RemoteRepositoryIdentifier`, `RemoteRepoMetadataModel`, and `RepoMetadataModel` to `repo_metadata`.
2. Rename `RepositoryMetadataModel``LocalRepoMetadataModel`.
3. Make `RepoMetadataModel` the new singleton; it creates the `LocalRepoMetadataModel` and `RemoteRepoMetadataModel` internally.
4. Update `app/src/lib.rs` to instantiate `RepoMetadataModel` instead of the old singleton.
#### Phase 2: Migrate consumers to wrapper
Consumers construct `RepositoryIdentifier::Local(...)` for their path-based lookups and call all operations through the wrapper's public API. No sub-model handles are accessed directly.
* **`FileTreeView`** — change `ModelHandle<RepositoryMetadataModel>``ModelHandle<RepoMetadataModel>`. Subscribe to `RepoMetadataEvent`. For queries, construct `RepositoryIdentifier::Local(canonicalized_path)` and call `wrapper.get_repository(id, ctx)`, `wrapper.has_repository(id, ctx)`, etc. For local-only operations, call `wrapper.index_lazy_loaded_path(path, ctx)`, `wrapper.load_directory(root, dir, ctx)`, etc. directly on the wrapper.
* **`FileSearchModel`** — change `RepositoryMetadataModel::as_ref(app)``RepoMetadataModel::as_ref(app)`. Construct `RepositoryIdentifier::Local(...)` for query calls. Event subscription migrates to `RepoMetadataEvent`.
* **`SkillWatcher`** — change `RepositoryMetadataModel::as_ref(ctx)``RepoMetadataModel::as_ref(ctx)`. Construct `RepositoryIdentifier::Local(...)` for tree queries. Event subscription migrates.
This phase is purely mechanical and doesn't change behavior — all identifiers are `RepositoryIdentifier::Local(...)` during this phase. A convenience constructor like `RepositoryIdentifier::local(path: impl TryInto<CanonicalizedPath>)` reduces boilerplate at call sites.
#### Phase 3: Wire remote file tree (future, out of scope)
Connect the remote sync layer to `RemoteRepoMetadataModel::insert_repository`. Update `FileTreeView` to display remote repositories using `RepositoryIdentifier::Remote(...)`. This phase requires the remote client model and protobuf sync layer described in the parent tech design.
## Testing Strategy
* Unit tests for `RemoteRepoMetadataModel`: insert/remove/query/event emission.
* Unit tests for `RepoMetadataModel` wrapper: unified query dispatching, event forwarding.
* Existing `RepositoryMetadataModel` (now `LocalRepoMetadataModel`) tests remain unchanged.
* Integration tests in `app/` verify that consumer subscriptions and queries work through the wrapper.
## Decisions
1. **`SessionId` location** — Move `SessionId` to `warp_core` so `repo_metadata` can depend on it directly without circular dependencies.
2. **Event granularity** — The wrapper emits only unified `RepoMetadataEvent`. Consumers subscribe to the wrapper and filter by `RepositoryIdentifier` variant if they only care about local or remote events.
3. **Lifecycle of local-specific operations** — Local-only operations (e.g. `load_directory`) keep their current path-based signatures for now. Remote equivalents will be added to the wrapper once the remote client ↔ server sync layer is in place.
+254
View File
@@ -0,0 +1,254 @@
# APP-3790: Remote Apply Diff
## Problem
When an AI agent runs in an SSH session, the `ApplyFileDiffs` tool is disabled because the diff preprocessing step reads files from the local filesystem (`std::fs::read_to_string`, `std::fs::exists`). The remote host's files are inaccessible to the client. We need to:
1. Route file reads through the remote server during diff application
2. Wire the `CodeDiffView` save/delete/create flow through the remote `FileModel` backend
3. Return accepted buffer content to the LLM without a network re-read
4. Update the agent context so the server knows `ApplyFileDiffs` is available on remote sessions
## Relevant Code
- `crates/remote_server/proto/remote_server.proto` — proto schema; has `WriteFile`/`DeleteFile`, needs `ReadFile`
- `crates/remote_server/src/client.rs (210-244)``RemoteServerClient::write_file` / `delete_file`; pattern for `read_file`
- `app/src/remote_server/server_model.rs (498-573)``handle_write_file` / `handle_delete_file`; async-via-background-executor pattern
- `app/src/ai/blocklist/action_model/execute/request_file_edits/diff_application.rs``apply_edits` / `apply_edits_internal`; all local file I/O
- `app/src/ai/blocklist/action_model/execute/request_file_edits.rs (299-421)``RequestFileEditsExecutor::preprocess_action` / `on_diffs_applied`
- `app/src/ai/blocklist/inline_action/code_diff_view.rs (459-464)``DiffSessionType` enum
- `app/src/ai/blocklist/inline_action/code_diff_view.rs (981-1050)``set_candidate_diffs`; already routes `register_file` vs `register_remote_file`
- `app/src/ai/blocklist/controller.rs (86-121)``SessionContext`
- `app/src/ai/agent/api/impl.rs (146-206)``get_supported_tools`; gates tools on session type
- `crates/remote_server/src/manager.rs (134-139)``RemoteServerManager::client_for_host`
- `crates/warp_files/src/lib.rs (95-106)``FileBackend::Remote`; already supports remote save/delete
## Current State
**Diff application** (`diff_application.rs`): `apply_edits_internal` parses `FileEdit` into grouped maps (search-replace, v4a, create, delete), then calls helpers (`apply_search_replace`, `apply_v4a_update`, `apply_create_file`, `apply_delete_file`) that each call `std::fs::read_to_string` or `std::fs::exists`. Purely local I/O — no code path exists for remote files.
**CodeDiffView save/delete/create**: `DiffSessionType` already exists with `Local` and `Remote(HostId)` variants. `set_candidate_diffs` routes to `register_file` (local) or `register_remote_file` (remote). `FileModel` has `FileBackend::Remote` that dispatches save/delete through `RemoteServerClient`. However, `RequestFileEditsExecutor` never sets `diff_session_type` — it defaults to `Local`.
**Agent tool gating**: `get_supported_tools` excludes `ApplyFileDiffs`, `ReadFiles`, and `SearchCodebase` when `session_type` is `WarpifiedRemote`. There is no field on `SessionContext` to indicate whether a `RemoteServerClient` is available.
**Post-accept context**: After diffs are accepted, `execute` re-reads files from disk via `read_local_file_context` and sends updated content to the LLM. This would require a network round-trip for remote sessions.
**Proto**: `remote_server.proto` has `WriteFile` and `DeleteFile` but no `ReadFile`.
## Proposed Changes
### 1. Add `ReadFile` proto message
Add `ReadFile` / `ReadFileResponse` to the remote server protocol, following the same `oneof result { success, error }` pattern as `WriteFileResponse` and `DeleteFileResponse`.
**`remote_server.proto`**:
```protobuf
message ReadFile {
string path = 1;
}
message ReadFileResponse {
oneof result {
ReadFileSuccess success = 1;
FileOperationError error = 2;
}
}
message ReadFileSuccess {
string content = 1;
bool exists = 2;
}
```
`ReadFile` is field 9 in `ClientMessage`, `ReadFileResponse` is field 10 in `ServerMessage`.
**`server_model.rs`**: `handle_read_file` spawns the read onto the background executor, returns `None` from the handler, and sends the response asynchronously through `response_tx`. If the file doesn't exist, returns `ReadFileSuccess { content: "", exists: false }`. I/O errors return `FileOperationError` (not the generic `ErrorResponse`).
**`client.rs`**: `read_file(&self, path: String) -> Result<ReadFileSuccess, ClientError>` unwraps the `oneof`, mapping `FileOperationError` to `ClientError::FileOperationFailed`.
### 2. Async-parameterized `apply_edits` + `ApplyDiffModel` dispatch
The core insight is that the local and remote diff application paths differ **only** in how they read file contents. Everything else — parsing edits, iteration, conflict checking, fuzzy matching, building `AIRequestedCodeDiff` — is identical. Rather than duplicating the application logic, we parameterize `apply_edits` over a file-reading closure so a single codepath handles both.
#### `FileReadResult` and `apply_edits` signature
A new enum in `diff_application.rs` abstracts over the file-read outcome:
```rust
pub(crate) enum FileReadResult {
Found(String),
NotFound,
ReadError(String),
}
impl From<std::io::Result<String>> for FileReadResult { ... }
```
`apply_edits` (the public entry point with telemetry) and `apply_edits_internal` (the core logic) become async and generic over the reader:
```rust
pub(crate) async fn apply_edits<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
ai_identifiers: &AIIdentifiers,
background_executor: Arc<Background>,
auth_state: Arc<AuthState>,
passive_diff: bool,
read_file: F,
) -> Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
```
The four leaf helpers (`apply_search_replace`, `apply_v4a_update`, `apply_create_file`, `apply_delete_file`) each take `&F` and call `read_file(absolute_path).await` instead of `std::fs` directly. They match on `FileReadResult` variants instead of `io::Result`.
The edit parsing/grouping stays inline in `apply_edits_internal` (no `GroupedEdits` struct) — this keeps the code close to the original master version.
#### Unified error variant
The previous `UnreadableFile { source: io::Error, file }` and `RemoteReadFailed { file, message }` variants are merged into a single `ReadFailed { file, message }` that works for both local and remote I/O errors.
#### `ApplyDiffModel` (Entity submodel)
**New file**: `app/src/ai/blocklist/action_model/execute/request_file_edits/apply_diff_model.rs`
`ApplyDiffModel` is a thin Entity that holds `ModelHandle<ActiveSession>`. Its `apply_diffs` method resolves session context, remote client, background executor, and auth state from its `ModelContext`, then passes the appropriate closure to `apply_edits`:
- **Local**: `|path| async { FileReadResult::from(std::fs::read_to_string(path)) }`
- **Remote**: `|path| { let client = client.clone(); async move { read_remote_file(&client, &path).await } }` where `read_remote_file` is a small adapter (~10 lines) that maps `RemoteServerClient::read_file``FileReadResult`.
The local-vs-remote dispatch is a single unified code path with no `cfg` gating — `RemoteServerManager` and `RemoteServerClient` compile on all targets including WASM. On WASM, `RemoteServerManager::connect_session` is a no-op, so `client_for_host` returns `None` and the local closure is used.
The executor creates `ApplyDiffModel` in its constructor and delegates via `self.apply_diff_model.update(ctx, |model, ctx| model.apply_diffs(...))`.
### 3. Wire `RequestFileEditsExecutor` through `ApplyDiffModel`
- `preprocess_action` calls `ApplyDiffModel::apply_diffs` instead of `apply_edits` directly.
- `on_diffs_applied`: when `session_context.host_id()` is `Some`, set `diff_session_type` on the `CodeDiffView` to `DiffSessionType::Remote(host_id)` before calling `set_candidate_diffs`. This ensures save/delete/create routes through `FileModel`'s remote backend.
- For local sessions, behavior is identical to today.
### 4. Post-accept context reads
After diffs are accepted and saved, `execute` calls `read_local_file_context` to re-read files from disk and send updated content to the LLM. For remote sessions, instead of round-tripping to the server, we build `ReadFileContextResult` directly from the `InlineDiffView` editor buffers.
The buffer content is the accepted state — it's what we just wrote via `FileModel::save` — so this is both correct and avoids a network round-trip.
In the `SavedAcceptedDiffs` handler inside `execute`, when `session_context.host_id()` is `Some` (remote), extract text from each `InlineDiffView`'s editor and construct `FileContext` entries directly. For local sessions, the existing `read_local_file_context` path remains unchanged.
### 5. Split `BootstrapSessionType` / `SessionType` and agent tool gating
Session type is modeled as two distinct enums to separate immutable bootstrap-time data from mutable runtime state:
**`BootstrapSessionType`** — immutable, determined at bootstrap, lives on `SessionInfo`:
```rust
pub enum BootstrapSessionType {
Local,
WarpifiedRemote,
}
```
**`SessionType`** — the authoritative runtime type, lives on `Session` behind a `parking_lot::Mutex`:
```rust
pub enum SessionType {
Local,
WarpifiedRemote { host_id: Option<HostId> },
}
```
`Session::new()` converts `BootstrapSessionType``SessionType` via a `From` impl (remote maps to `host_id: None`). `Session::session_type()` returns an owned `SessionType` from the mutex. `Session::set_remote_host_id()` mutates the `host_id` in-place through `Arc<Session>`.
This separation means `SessionInfo` never carries mutable state, and the `Session`'s `session_type` is the single source of truth that evolves as the remote server connection lifecycle progresses.
**`Sessions` subscription**: `Sessions::new()` subscribes to `RemoteServerManager` events. On `SessionConnected`, it calls `session.set_remote_host_id(Some(host_id))`. On `SessionDisconnected`, it clears it. A race-condition guard in `initialize_bootstrapped_session` also checks `RemoteServerManager` when a session is first inserted, covering the case where the handshake completes before the session is stored.
**`get_supported_tools`** (`api/impl.rs`): gates on `host_id` presence (set only after a successful handshake):
```rust
match session_context.session_type() {
None | Some(SessionType::Local) => {
supported_tools.extend(&[
api::ToolType::ReadFiles,
api::ToolType::ApplyFileDiffs,
api::ToolType::SearchCodebase,
]);
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
supported_tools.push(api::ToolType::ApplyFileDiffs);
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {
// Feature flag off or not yet connected — no remote tools.
}
}
```
`ReadFiles` and `SearchCodebase` remain disabled for remote sessions (follow-up).
## End-to-End Flow
```mermaid
sequenceDiagram
participant LLM as LLM Server
participant Executor as RequestFileEditsExecutor
participant Model as ApplyDiffModel
participant Client as RemoteServerClient
participant Server as Remote Server
participant View as CodeDiffView
participant FileModel as FileModel (Remote)
LLM->>Executor: RequestFileEdits action (file edits)
Executor->>Model: apply_diffs(edits, session_context)
Note over Model: session_context.host_id is Some → remote path
loop For each file in edits
Model->>Client: read_file(absolute_path)
Client->>Server: ReadFile { path }
Server-->>Client: ReadFileResponse { ReadFileSuccess { content, exists } }
Client-->>Model: Ok(ReadFileResponse)
end
Note over Model: fuzzy_match_diffs / fuzzy_match_v4a_diffs on fetched content
Model-->>Executor: Ok(Vec<AIRequestedCodeDiff>)
Executor->>View: set_diff_session_type(Remote(host_id))
Executor->>View: set_candidate_diffs(diffs)
Note over View: registers files via register_remote_file
Note over View: User accepts diff
View->>FileModel: save(file_id, content, version)
FileModel->>Client: write_file(path, content)
Client->>Server: WriteFile { path, content }
Server-->>Client: WriteFileResponse
FileModel-->>View: FileSaved event
Note over Executor: Build ReadFileContextResult from editor buffers
Executor-->>LLM: RequestFileEditsResult::Success { updated_files, ... }
```
## Risks and Mitigations
**Network latency during diff application**: Each file requires a `ReadFile` round-trip. For diffs touching many files, this could be slow. Mitigation: `apply_edits_remote` can be extended to batch reads or use concurrent futures in a follow-up. The model boundary makes this change contained.
**Disconnected remote server**: If the `RemoteServerClient` disconnects between tool gating and diff application, `read_file` will fail. Mitigation: `ClientError::Disconnected` propagates as a `DiffApplicationError`, which the executor reports to the LLM so it can retry or inform the user.
**Large file reads over the wire**: `ReadFileResponse` returns entire file content as a string. For very large files this could be slow or memory-intensive. Mitigation: this matches the local path (`std::fs::read_to_string`) which also loads full files. Existing per-file size limits apply equally.
**Editor buffer staleness for post-accept context**: We return buffer content that was just saved rather than re-reading. If `FileModel::save` silently fails, the buffer might not match disk. Mitigation: `FailedToSave` events already propagate through `CodeDiffView` and are reported as errors, so the buffer-read code path would not be reached on failure.
## Testing and Validation
- **Proto round-trip test**: Add a test in `protocol_tests.rs` for `ReadFile` / `ReadFileResponse` encode/decode.
- **`handle_read_file` server test**: Verify the handler reads existing files, returns `exists: false` for missing files, and returns an error for unreadable files.
- **Unit tests for remote path**: Since local and remote share a single codepath, existing `diff_application_tests.rs` covers the core logic. Additional tests can pass a mock `read_file` closure that simulates remote behavior (e.g. returning `ReadError` for connectivity failures) without needing to mock `RemoteServerClient` directly.
- **Regression**: Run existing `diff_application_tests.rs` and `cargo nextest run -p warp_files` to verify local path is unchanged.
- **Integration**: Manually test agent mode in an SSH session — verify `ApplyFileDiffs` appears in supported tools, diff preview renders correctly, accept/save writes to the remote host, and the LLM receives updated file context after acceptance.
## Follow-ups
- **`ReadFiles` tool for remote sessions**: Use the same `ReadFile` proto to implement remote file reading for the `ReadFiles` agent tool.
- **`SearchCodebase` for remote sessions**: Requires remote codebase indexing infrastructure — separate project.
- **Batch/concurrent `ReadFile`**: The `read_file` closure could be extended to batch or pre-fetch reads for independent files before entering the application loop.
- **Post-accept context from buffer for local sessions**: The buffer-based approach could also eliminate the re-read for local sessions — a minor optimization.
+204
View File
@@ -0,0 +1,204 @@
# APP-3790: Remote ReadFiles Tool
## Problem
The `ReadFiles` agent tool is disabled for remote SSH sessions. `get_supported_tools` skips `ToolType::ReadFiles` when `SessionType::WarpifiedRemote`, because the underlying `read_local_file_context` reads files via `async_fs`, `FileModel::read_text_file`, and local image processing — all local-only APIs.
The remote server already runs on the host machine with full filesystem access and has access to the same dependencies (`warp_files`, `warp_util`, `mime_guess`). Rather than building a degraded client-side approximation, we push the file-reading logic to the server so the ReadFiles tool has full feature parity with local: line-range extraction, binary/image support, metadata, and size limits.
## Relevant Code
- `app/src/ai/blocklist/action_model/execute/read_files.rs``ReadFilesExecutor`; dispatches to `read_local_file_context`
- `app/src/ai/blocklist/action_model/execute.rs:941``read_local_file_context`; per-file reading logic (metadata, binary detection, text/binary read, image processing, byte limits)
- `crates/warp_files/src/lib.rs:528``FileModel::read_text_file`; line-range extraction and byte-limit truncation
- `crates/warp_util/src/file_type.rs:46``is_binary_file`; extension-based binary detection
- `app/src/util/image.rs:96``process_image_for_agent`; image processing for LLM context
- `crates/remote_server/proto/remote_server.proto:234-249` — current `ReadFile`/`ReadFileResponse`/`ReadFileSuccess` proto (too simple)
- `crates/remote_server/src/client.rs:266``RemoteServerClient::read_file`; current simple read
- `app/src/remote_server/server_model.rs:883``handle_read_file_context`; calls `read_local_file_context` and converts result to proto
- `app/src/ai/agent/api/impl.rs:146``get_supported_tools`; gates tools on session type
- `app/src/ai/agent/api/impl.rs:221``get_supported_cli_agent_tools`; same gating for CLI agent
- `app/src/ai/blocklist/action_model/execute/request_file_edits/apply_diff_model.rs``read_remote_file` adapter; uses old `ReadFile` proto
## Current State
**`ReadFilesExecutor::execute`** resolves cwd/shell from `ActiveSession`, then calls `read_local_file_context(locations, cwd, shell, None)` inside a `BoxFuture`.
**`read_local_file_context`** iterates over `FileLocations` and for each file:
1. Resolves the absolute path via `host_native_absolute_path`
2. Reads metadata via `async_fs::metadata``last_modified`, `file_size`
3. Computes effective byte budget (per-file `MAX_FILE_READ_BYTES` ∩ remaining batch budget)
4. Binary detection via `is_binary_file` (extension-based, `warp_util::file_type`)
5. Text path: `FileModel::read_text_file` — reads with line-range extraction and byte-limit truncation, returns segments
6. Binary path: reads raw bytes, checks MIME via `mime_guess`, runs `process_image_for_agent` for supported images, skips oversized files
7. Returns `ReadFileContextResult { file_contexts, missing_files }`
**Current `ReadFile` proto** (`remote_server.proto`): `ReadFile { path }``ReadFileSuccess { content, exists }`. The server handler just calls `tokio::fs::read_to_string` — no metadata, no line ranges, no size limits, no binary support.
**Tool gating**: `get_supported_tools` excludes `ReadFiles` for `WarpifiedRemote` sessions. `get_supported_cli_agent_tools` also excludes it.
## Proposed Changes
### 1. Replace `ReadFile` proto with `ReadFileContext` (richer batch request/response)
Replace the current `ReadFile`/`ReadFileResponse`/`ReadFileSuccess` messages in-place (no backward compat needed — both changes land in the same release). Reuse field slots 10/11 in `ClientMessage`/`ServerMessage`.
**`remote_server.proto`**:
```protobuf
// A single file to read, with optional line ranges.
message ReadFileContextFile {
string path = 1;
// 1-indexed line ranges (start..end). Empty = read entire file.
repeated LineRange line_ranges = 2;
}
message LineRange {
uint32 start = 1;
uint32 end = 2;
}
// Client → server: batch read multiple files with full context.
message ReadFileContextRequest {
repeated ReadFileContextFile files = 1;
// Per-file byte limit. Absent = use server default (MAX_FILE_READ_BYTES).
optional uint32 max_file_bytes = 2;
// Cumulative byte budget across all files. Absent = no batch limit.
optional uint32 max_batch_bytes = 3;
}
// Server → client: result of ReadFileContextRequest.
// Per-file failures are reported in `failed_files`, not as a top-level error.
// Catastrophic server errors (malformed request, etc.) use the generic ErrorResponse.
message ReadFileContextResponse {
repeated FileContextProto file_contexts = 1;
repeated FailedFileRead failed_files = 2;
}
message FailedFileRead {
string path = 1;
FileOperationError error = 2; // reuses the existing shared error type
}
message FileContextProto {
string file_name = 1;
oneof content {
string text_content = 2;
bytes binary_content = 3;
}
// Optional 1-indexed line range this segment covers.
optional uint32 line_range_start = 4;
optional uint32 line_range_end = 5;
optional uint64 last_modified_epoch_millis = 6;
uint32 line_count = 7;
}
```
This is a batch API — one round-trip reads all requested files, avoiding serial latency.
### 2. Share file-reading logic via `read_local_file_context`
Rather than extracting a new per-file helper, the server handler calls the existing `read_local_file_context` directly. This function already implements the full pipeline (metadata → binary detection → text line-range extraction via `FileModel::read_text_file` → image processing → byte-limit enforcement) and is accessible as `pub(crate)` from `crate::ai::blocklist::read_local_file_context`.
The server handler converts its proto request into `Vec<FileLocations>` (paths are already absolute, so `cwd: None` and `shell: None` are passed, causing `host_native_absolute_path` to act as an identity on absolute paths). The `ReadFileContextResult` is then converted to proto `ReadFileContextResponse` via a `file_context_result_to_proto` helper that maps `FileContext``FileContextProto` and `missing_files``FailedFileRead`.
### 3. Implement `handle_read_file_context` on the server
New handler in `ServerModel`:
- Deserialize `ReadFileContextRequest` → convert `ReadFileContextFile` list into `Vec<FileLocations>`
- Call `read_local_file_context(file_locations, None, None, max_batch_bytes)` to read all files
- Convert the `ReadFileContextResult` to proto via `file_context_result_to_proto`
- If `read_local_file_context` returns an `Err`, convert to a single `FailedFileRead` in the response
- Return `ReadFileContextResponse`
The handler uses `spawn_request_handler` (like `handle_run_command`) so it runs on the background executor and is cancellable via `Abort`.
### 4. Add `RemoteServerClient::read_file_context`
New method on the client:
```rust
pub async fn read_file_context(
&self,
files: Vec<ReadFileContextFile>,
max_file_bytes: Option<u32>,
max_batch_bytes: Option<u32>,
) -> Result<ReadFileContextResponse, ClientError>
```
Follows the same pattern as `write_file` / `delete_file` — sends request, awaits correlated response, maps error variant.
### 5. Update `ReadFilesExecutor::execute` to dispatch on session type
In the `execute` method, after resolving cwd/shell, check `active_session.session_type(ctx)`:
- **Local / None**: call `read_local_file_context` as today (unchanged).
- **WarpifiedRemote with host_id**: resolve `RemoteServerClient` via `RemoteServerManager::client_for_host`, call `client.read_file_context(...)`, convert `ReadFileContextResponse``ReadFileContextResult` (mapping proto `FileContextProto``FileContext`, `FailedFileRead``missing_files`).
- **WarpifiedRemote without host_id**: fall through to the local `read_local_file_context` path.
The remote client lookup uses a unified code path with no `cfg` gating — `RemoteServerManager` and `RemoteServerClient` compile on all targets including WASM. On WASM, `client_for_host` returns `None` (since `connect_session` is a no-op), so the local path is used automatically.
### 6. Update `apply_diff_model.rs` to use new proto
The `read_remote_file` adapter currently uses the old `ReadFile`/`ReadFileSuccess` proto. Update it to send a `ReadFileContextRequest` with a single file (no line ranges, no byte limits) and map the response back to `FileReadResult`.
### 7. Enable `ReadFiles` in `get_supported_tools` for remote sessions
In `get_supported_tools` (impl.rs:179), add `api::ToolType::ReadFiles` alongside `ApplyFileDiffs` for the `WarpifiedRemote { host_id: Some(_) }` arm.
Also in `get_supported_cli_agent_tools` (impl.rs:234), enable `ReadFiles` for remote sessions with a connected host.
## End-to-End Flow
```mermaid
sequenceDiagram
participant LLM as LLM Server
participant Executor as ReadFilesExecutor
participant Client as RemoteServerClient
participant Server as Remote Server (ServerModel)
participant Shared as read_single_file_context
LLM->>Executor: ReadFiles action (file locations)
Note over Executor: session_type is WarpifiedRemote with host_id
Executor->>Client: read_file_context(files, max_bytes)
Client->>Server: ReadFileContextRequest { files, max_file_bytes, max_batch_bytes }
loop For each file
Server->>Shared: read_single_file_context(path, line_ranges, max_bytes)
Note over Shared: metadata → binary detect → text/binary read → image process
Shared-->>Server: SingleFileReadResult
end
Server-->>Client: ReadFileContextResponse { file_contexts, failed_files }
Client-->>Executor: Ok(ReadFileContextResponse)
Note over Executor: Convert proto → ReadFileContextResult
Executor-->>LLM: ReadFilesResult::Success { files }
```
## Risks and Mitigations
**Network latency for large batches**: The batch API sends all files in one round-trip, but the server reads them sequentially. For requests with many files, this could be slow. Mitigation: follow-up to add concurrent reads on the server via `futures::join_all`.
**Large binary files over the wire**: Image files after processing can still be significant. Mitigation: the same `MAX_FILE_READ_BYTES` limit applies server-side, and `process_image_for_agent` already has its own size guard.
**Server crash on malformed request**: A malformed `ReadFileContextRequest` could panic. Mitigation: validate inputs before processing; the `spawn_request_handler` pattern already handles errors gracefully.
**`apply_diff_model.rs` migration**: Replacing the proto in-place means the apply-diff path must be updated atomically in the same PR. Mitigation: the change to `apply_diff_model.rs` is small — send a single-file `ReadFileContextRequest` and map the response.
## Testing and Validation
- **Existing `read_local_file_context` tests**: Cover the shared per-file reading logic (text files with/without line ranges, missing files, binary/image files, oversized files, byte-limit enforcement) that the server handler now reuses.
- **Server handler test**: Test `handle_read_file_context` end-to-end — verify it reads existing files, returns `failed_files` for non-existent/unreadable files, and respects byte limits.
- **Proto round-trip test**: Encode/decode `ReadFileContextRequest` / `ReadFileContextResponse`.
- **Client integration**: Unit test `read_file_context` with a mock server response.
- **Regression**: Existing `read_local_file_context` tests and `diff_application_tests` remain unchanged (local path untouched, apply-diff adapter updated).
- **Manual**: Connect to a remote SSH session, invoke agent mode, verify the LLM can read text and image files on the remote host with correct line ranges.
## Follow-ups
- **Concurrent reads on server**: The server handler reads files sequentially via `read_local_file_context`. Concurrency could be added either inside that function or by splitting files across multiple calls.
- **Backport `FailedFileRead` to local path**: Update `ReadFileContextResult::missing_files` from `Vec<String>` to include failure reasons, matching the richer remote proto.
+510
View File
@@ -0,0 +1,510 @@
# Unified FileModel for Local and Remote File Persistence
## Problem
`LocalFileModel` only supports local filesystem operations. When the agent generates file edits for a remote SSH session, the diff view needs to write files to the remote host via `RemoteServerClient`, not to the local disk. Today this is impossible because `LocalFileModel::save()` and `delete()` always call `async_fs::write` / `async_fs::remove_file` on local paths.
The goal is to extend `LocalFileModel` into a unified `FileModel` that dispatches to local or remote backends based on how the file was registered, so all existing consumers (`InlineDiffView`, `LocalCodeEditorView`, `GlobalBufferModel`, `ServerModel`) continue using the same `FileId` + `FileModelEvent` event pattern without any changes to their subscription logic.
## Relevant code
- `app/src/code/inline_diff.rs``InlineDiffView` struct and all `LocalFileModel` usage
- `app/src/code/diff_viewer.rs (113-171)``DiffViewer` trait with `accept_and_save_diff`, `restore_diff_base` defaults
- `app/src/ai/blocklist/inline_action/code_diff_view.rs (954-1005)``set_candidate_diffs()` which constructs `InlineDiffView` and calls `register_file()`
- `crates/warp_files/src/lib.rs (282-315, 548-706)``LocalFileModel::register_file_path()`, `save()`, `delete()`
- `crates/warp_util/src/standardized_path.rs``StandardizedPath` for platform-aware remote paths
- `app/src/remote_server/manager.rs``RemoteServerManager` and `RemoteServerClient` (future remote implementation)
## Current state
`InlineDiffView` manages file persistence through a `backing_file_id: Option<FileId>` field:
- **Construction**: `InlineDiffView::new()` creates the view with `backing_file_id: None` (read-only).
- **Registration**: `register_file()` (non-WASM only) calls `LocalFileModel::register_file_path()`, stores the `FileId`, subscribes to `FileModelEvent::FileSaved` / `FailedToSave`, and re-emits them as `InlineDiffViewEvent`.
- **Save**: `save_content()` calls `LocalFileModel::save(file_id, content, version)`.
- **Delete**: `restore_diff_base()` calls `LocalFileModel::delete(file_id, version)` for new files or `LocalFileModel::save(file_id, base_content, version)` for existing files.
- **Editability gate**: `backing_file_id.is_some()` determines whether the editor is editable and whether accept/save/revert are allowed.
The coupling to `LocalFileModel` is spread across:
1. `register_file()` — 6 `LocalFileModel` calls + event subscription (lines 100-136)
2. `save_content()` — 1 `LocalFileModel::save()` call (lines 166-181)
3. `restore_diff_base()` — 2 `LocalFileModel` calls (save or delete) (lines 238-282)
All three are gated behind `#[cfg(not(target_family = "wasm"))]`. On WASM, `backing_file_id` stays `None` and all operations are no-ops.
## Proposed changes
### Unified `FileModel` singleton (replaces per-view trait)
Instead of a `BackingFileStore` trait with callbacks, extend the existing `LocalFileModel` singleton into a unified `FileModel` that handles both local and remote file entries. All consumers (`InlineDiffView`, `LocalCodeEditorView`, `GlobalBufferModel`, `ServerModel`) continue to use the same `FileId` + `FileModelEvent` pattern they already use today.
#### Why not a trait with callbacks
A trait-based approach (`BackingFileStore`) was considered but rejected because:
- Callbacks can't carry mutable `ViewContext` / `ModelContext`, making it impossible to emit events or access singletons from within the completion handler.
- All four consumers already subscribe to `FileModelEvent` via `ctx.subscribe_to_model(&file_model, ...)` and filter by `FileId`. This pattern works with any context type.
- Introducing a new abstraction layer adds complexity when the existing singleton event pattern already solves the problem.
#### Design: extend `LocalFileModel` to handle remote files
Rename `LocalFileModel` to `FileModel`. Internally, each `FileId` is backed by either a local file entry (existing `LocalFile` struct) or a remote file entry:
```rust
/// Per-file backing store. Local files use std::fs via async_fs.
/// Remote files use RemoteServerClient RPCs.
enum FileBackend {
Local(LocalFile),
Remote {
/// Identifies the remote host. The actual client is looked up from
/// RemoteServerManager at call time, which naturally handles
/// disconnect (lookup returns None) without holding the Arc alive.
host_id: HostId,
/// Platform-aware path on the remote host.
path: StandardizedPath,
},
}
```
The `FileModel` (née `LocalFileModel`) stores `HashMap<FileId, FileBackend>` instead of `HashMap<FileId, LocalFile>`. The public API stays the same:
```rust
impl FileModel {
/// Register a local file path. Existing behavior, unchanged.
pub fn register_file_path(
&mut self, path: &Path, subscribe_to_updates: bool, ctx: ...
) -> FileId;
/// Register a remote file path. New.
pub fn register_remote_file(
&mut self, host_id: HostId, path: StandardizedPath,
) -> FileId;
/// Save content. Dispatches to local async_fs::write or remote WriteFile RPC
/// based on the FileId's backend. Emits FileModelEvent::FileSaved / FailedToSave.
pub fn save(
&mut self, file_id: FileId, content: String, version: ContentVersion, ctx: ...
) -> Result<(), FileSaveError>;
/// Delete a file. Dispatches to local async_fs::remove_file or remote DeleteFile RPC.
pub fn delete(
&mut self, file_id: FileId, version: ContentVersion, ctx: ...
) -> Result<(), FileSaveError>;
}
```
`save()` and `delete()` check the `FileBackend` variant and dispatch accordingly:
- **`FileBackend::Local`**: existing code path (async_fs::write, ensure_parent_directories)
- **`FileBackend::Remote`**: look up `Arc<RemoteServerClient>` from `RemoteServerManager` via `host_id` at call time. If the client is connected, spawn async task calling `client.write_file(path, content).await` or `client.delete_file(path).await`, emit `FileModelEvent::FileSaved` / `FailedToSave` on completion. If disconnected, emit `FailedToSave` immediately with a descriptive error.
Both paths emit the same `FileModelEvent`, so all downstream subscribers work unchanged.
#### What changes for each consumer
| Consumer | Change |
|---|---|
| `InlineDiffView` | Calls `FileModel::register_file_path()` for local or `FileModel::register_remote_file()` for remote. Stores `FileId`. All existing event subscriptions unchanged. |
| `LocalCodeEditorView` | `LocalFileModel::handle(ctx)``FileModel::handle(ctx)`. No other changes. |
| `GlobalBufferModel` | Same rename. All `FileModelEvent` subscriptions unchanged. |
| `ServerModel` | Uses `FileModel::register_file_path()` + `save()` / `delete()` with `pending_file_ops` dispatch map (unchanged from previous spec section). |
| `code_diff_view.rs` | `set_candidate_diffs()` calls `register_file_path()` for local sessions, `register_remote_file()` for remote sessions. |
#### Migration strategy
1. **Rename**: `LocalFileModel``FileModel` (type alias `LocalFileModel = FileModel` for backward compat during migration).
2. **Add `FileBackend` enum**: wrap existing `LocalFile` in `FileBackend::Local`. All existing code paths unchanged.
3. **Add `register_remote_file()`**: creates a `FileBackend::Remote` entry.
4. **Extend `save()` / `delete()`**: match on `FileBackend` variant, dispatch to local or remote code path.
5. **Update `InlineDiffView`**: replace `backing_file_id: Option<FileId>` (unchanged type) but call `register_remote_file()` for remote sessions.
Steps 1-2 are pure refactors with no behavior change. Steps 3-4 add the remote capability. Step 5 wires it up.
#### `backing_file_id` semantics in `InlineDiffView` (unchanged)
The `backing_file_id: Option<FileId>` field retains its current semantics:
- `Some(file_id)` → editable, save/revert write through `FileModel`
- `None` → selection-only, save/revert are no-ops
The `FileId` is opaque — `InlineDiffView` doesn't know or care whether it's backed by local or remote. It calls `FileModel::save(file_id, content, version)` and subscribes to `FileModelEvent` filtered by `file_id`, exactly as today.
#### Proto definition
Extend `remote_server.proto` with two new request/response pairs. These follow the existing pattern: the `ClientMessage` oneof gets new variants, and corresponding response variants are added to `ServerMessage`.
```protobuf
// ── In ClientMessage oneof ────────────────────────────────────────
// WriteFile write_file = 5;
// DeleteFile delete_file = 6;
// ── File write/delete operations ──────────────────────────────────
// Client → server: write content to a file, creating parent dirs if needed.
message WriteFile {
string path = 1;
string content = 2;
}
// Server → client: file was written successfully.
message WriteFileResponse {}
// Client → server: delete a file.
message DeleteFile {
string path = 1;
}
// Server → client: file was deleted successfully.
message DeleteFileResponse {}
// ── In ServerMessage oneof ────────────────────────────────────────
// WriteFileResponse write_file_response = 7;
// DeleteFileResponse delete_file_response = 8;
```
Errors are returned using the existing `ErrorResponse` variant (field 3 in `ServerMessage`), with `ErrorCode::INTERNAL` for I/O failures and a human-readable message. No new error codes are needed.
#### Client-side methods on `RemoteServerClient`
Follows the existing `initialize()` / `navigate_to_directory()` pattern:
```rust
impl RemoteServerClient {
/// Writes content to a file on the remote host.
/// Creates parent directories if they don't exist.
pub async fn write_file(
&self,
path: String,
content: String,
) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::WriteFile(
WriteFile { path, content },
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::WriteFileResponse(_)) => Ok(()),
_ => Err(ClientError::UnexpectedResponse),
}
}
/// Deletes a file on the remote host.
pub async fn delete_file(&self, path: String) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::DeleteFile(
DeleteFile { path },
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::DeleteFileResponse(_)) => Ok(()),
_ => Err(ClientError::UnexpectedResponse),
}
}
}
```
Both use the existing `send_request()` which handles timeout, abort, and error unwrapping.
#### Server-side handler in `ServerModel`
Reuses `LocalFileModel` on the server to avoid reimplementing async file I/O, parent directory creation, and error handling. `LocalFileModel` is already a singleton model with `save()` and `delete()` that run on background threads via `ctx.spawn(async { async_fs::write(...) })`. The remote server can register it as a singleton in `run()` and the handlers can use `LocalFileModel::handle(ctx)` just like the client does.
##### Setup: register `LocalFileModel` in the remote server
In `app/src/remote_server/mod.rs`, add `LocalFileModel` to the headless app's singleton models:
```rust
pub fn run() -> anyhow::Result<()> {
// ...
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None).run(|ctx| {
ctx.add_singleton_model(DirectoryWatcher::new);
ctx.add_singleton_model(|_ctx| DetectedRepositories::default());
ctx.add_singleton_model(RepoMetadataModel::new_with_incremental_updates);
ctx.add_singleton_model(LocalFileModel::new); // NEW
ctx.add_singleton_model(ServerModel::new);
})?;
Ok(())
}
```
`LocalFileModel::new()` creates a `BulkFilesystemWatcher` internally, but since we never call `register_file_path(path, true /* subscribe */)`, the watcher stays idle with no overhead.
##### Event dispatch: subscribe once, dispatch via `file_id` map
Instead of subscribing to `LocalFileModel` per-request (which leaks subscriptions), `ServerModel` subscribes once at startup and uses a `HashMap<FileId, PendingFileOp>` to correlate `FileModelEvent`s back to their originating request.
```rust
/// Tracks an in-flight file write or delete so the async completion
/// event can be correlated back to the originating client request.
enum FileOpKind {
Write,
Delete,
}
struct PendingFileOp {
request_id: RequestId,
kind: FileOpKind,
}
```
Add to `ServerModel`:
```rust
pub struct ServerModel {
response_tx: async_channel::Sender<ServerMessage>,
in_progress: HashMap<RequestId, tokio::sync::oneshot::Sender<()>>,
host_id: String,
/// Maps FileId → pending file operation for write/delete correlation.
pending_file_ops: HashMap<FileId, PendingFileOp>,
}
```
In `ServerModel::new()`, subscribe to `LocalFileModel` once:
```rust
// Subscribe to LocalFileModel events for write/delete completion.
{
let file_model = LocalFileModel::handle(ctx);
ctx.subscribe_to_model(&file_model, |me, event, _ctx| {
let file_id = event.file_id();
let Some(pending) = me.pending_file_ops.remove(&file_id) else {
return; // Not a file op we're tracking.
};
let response_message = match (event, &pending.kind) {
(FileModelEvent::FileSaved { .. }, FileOpKind::Write) => {
server_message::Message::WriteFileResponse(WriteFileResponse {})
}
(FileModelEvent::FileSaved { .. }, FileOpKind::Delete) => {
server_message::Message::DeleteFileResponse(DeleteFileResponse {})
}
(FileModelEvent::FailedToSave { error, .. }, _) => {
server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("File operation failed: {error}"),
})
}
_ => return,
};
let _ = me.response_tx.try_send(ServerMessage {
request_id: pending.request_id.into(),
message: Some(response_message),
});
});
}
```
##### Handlers: register file, insert pending op, trigger I/O
The handlers are now simple: register the path, record the pending op, trigger the async I/O, return `None`.
In `ServerModel::handle_message()`, add two new arms:
```rust
Some(client_message::Message::WriteFile(msg)) => {
self.handle_write_file(msg, &request_id, ctx)
}
Some(client_message::Message::DeleteFile(msg)) => {
self.handle_delete_file(msg, &request_id, ctx)
}
```
Handler implementations:
```rust
fn handle_write_file(
&mut self,
msg: WriteFile,
request_id: &RequestId,
ctx: &mut ModelContext<Self>,
) -> Option<server_message::Message> {
log::info!("Handling WriteFile path={} (request_id={request_id})", msg.path);
let path = std::path::Path::new(&msg.path);
let file_model = LocalFileModel::handle(ctx);
let file_id = file_model.update(ctx, |m, ctx| m.register_file_path(path, false, ctx));
let version = ContentVersion::new();
file_model.update(ctx, |m, _| m.set_version(file_id, version));
// Track this op so the event subscription can correlate the result.
self.pending_file_ops.insert(file_id, PendingFileOp {
request_id: request_id.clone(),
kind: FileOpKind::Write,
});
if let Err(err) = file_model.update(ctx, |m, ctx| m.save(file_id, msg.content, version, ctx)) {
self.pending_file_ops.remove(&file_id);
return Some(server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("Failed to initiate write: {err}"),
}));
}
None // Response sent asynchronously via the event subscription.
}
fn handle_delete_file(
&mut self,
msg: DeleteFile,
request_id: &RequestId,
ctx: &mut ModelContext<Self>,
) -> Option<server_message::Message> {
log::info!("Handling DeleteFile path={} (request_id={request_id})", msg.path);
let path = std::path::Path::new(&msg.path);
let file_model = LocalFileModel::handle(ctx);
let file_id = file_model.update(ctx, |m, ctx| m.register_file_path(path, false, ctx));
let version = ContentVersion::new();
file_model.update(ctx, |m, _| m.set_version(file_id, version));
self.pending_file_ops.insert(file_id, PendingFileOp {
request_id: request_id.clone(),
kind: FileOpKind::Delete,
});
if let Err(err) = file_model.update(ctx, |m, ctx| m.delete(file_id, version, ctx)) {
self.pending_file_ops.remove(&file_id);
return Some(server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("Failed to initiate delete: {err}"),
}));
}
None
}
```
Key design decisions for the server handlers:
- **Subscribe once, dispatch via map**: `ServerModel` subscribes to `LocalFileModel` once at startup. Each write/delete handler inserts a `PendingFileOp` keyed by `FileId`, and the subscription callback removes it on completion to send the correlated response. No per-request subscription leaks.
- **Reuse `LocalFileModel`**: avoids reimplementing `async_fs::write`, `ensure_parent_directories`, error wrapping, and the async completion callback pattern. `LocalFileModel::save()` already handles all of this.
- **Async via `LocalFileModel`**: file I/O runs on the background thread through `LocalFileModel`'s internal `ctx.spawn()`. The handler returns `None` and the response is sent asynchronously when `FileModelEvent::FileSaved` / `FailedToSave` fires.
- **No watcher overhead**: `register_file_path(path, false)` skips watcher subscription, so the `BulkFilesystemWatcher` stays idle.
- **Cleanup on sync failure**: if `save()` / `delete()` returns an immediate error (e.g. `NoFilePath`), the pending op is removed and a sync error response is returned.
### Changes to `InlineDiffView`
No structural change — `backing_file_id: Option<FileId>` stays as-is. The `FileId` is opaque and works for both local and remote files.
Unify `register_file()` to accept a `DiffSessionType` and dispatch internally:
```rust
pub fn register_file(
&mut self,
session_type: &DiffSessionType,
ctx: &mut ViewContext<Self>,
) {
let file_model = FileModel::handle(ctx);
let file_id = match session_type {
DiffSessionType::Local => file_model.update(ctx, |m, ctx| {
m.register_file_path(file_path, false, ctx)
}),
DiffSessionType::Remote(host_id) => {
let remote_path = StandardizedPath::try_new(...);
file_model.update(ctx, |m, _| m.register_remote_file(host_id, remote_path))
}
};
self.finish_file_registration(file_id, ctx);
}
```
`save_content()`, `restore_diff_base()`, and all `is_some()`/`is_none()` checks remain identical — they call `FileModel::save(file_id, ...)` / `FileModel::delete(file_id, ...)` which dispatches internally.
### Changes to `set_candidate_diffs()` in `code_diff_view.rs`
The caller passes the session type; the local vs. remote dispatch is an internal detail of `register_file()`:
```rust
#[cfg(not(target_family = "wasm"))]
diff_viewer.update(ctx, |view, ctx| {
view.register_file(&self.diff_session_type, ctx);
});
```
### File placement
- `crates/warp_files/src/lib.rs``FileModel` (renamed from `LocalFileModel`), `FileBackend` enum, `register_remote_file()`
- No new files needed for the trait — the abstraction lives inside the singleton.
## End-to-end flow
### Accept + save (local session)
```mermaid
sequenceDiagram
participant User
participant CodeDiffView
participant InlineDiffView
participant FileModel
participant Disk
User->>CodeDiffView: Accept diff
CodeDiffView->>InlineDiffView: accept_and_save_diff()
InlineDiffView->>InlineDiffView: retrieve_unified_diff() [async]
InlineDiffView->>FileModel: save(file_id, content, version)
Note over FileModel: FileBackend::Local → async_fs::write()
FileModel->>Disk: async_fs::write()
Disk-->>FileModel: Ok
FileModel-->>InlineDiffView: FileModelEvent::FileSaved
InlineDiffView-->>CodeDiffView: InlineDiffViewEvent::FileSaved
CodeDiffView->>CodeDiffView: mark_diff_saved()
```
### Accept + save (remote session)
```mermaid
sequenceDiagram
participant User
participant CodeDiffView
participant InlineDiffView
participant FileModel
participant RemoteServerClient
participant RemoteHost
User->>CodeDiffView: Accept diff
CodeDiffView->>InlineDiffView: accept_and_save_diff()
InlineDiffView->>InlineDiffView: retrieve_unified_diff() [async]
InlineDiffView->>FileModel: save(file_id, content, version)
Note over FileModel: FileBackend::Remote → RPC
FileModel->>RemoteServerClient: write_file(path, content) [async]
RemoteServerClient->>RemoteHost: WriteFile proto message
RemoteHost-->>RemoteServerClient: WriteFileResponse
RemoteServerClient-->>FileModel: Ok
FileModel-->>InlineDiffView: FileModelEvent::FileSaved
InlineDiffView-->>CodeDiffView: InlineDiffViewEvent::FileSaved
CodeDiffView->>CodeDiffView: mark_diff_saved()
```
## Risks and mitigations
**Risk: Remote `save()` / `delete()` latency.**
Remote RPCs are slower than local `async_fs::write`. The `SavingDiffs` state machine in `CodeDiffView` already handles async completion, so latency is tolerated. But UI responsiveness may degrade on high-latency SSH connections. Mitigation: the accept flow already shows a loading state via `CodeDiffState::Accepted(Some(SavingDiffs))`.
**Risk: `FileModel` becomes a larger singleton.**
Adding `FileBackend::Remote` entries increases the surface area of `FileModel`. Mitigation: remote entries are simple (no watcher, no repo subscription) and the `save()`/`delete()` dispatch is a single match arm. The rename from `LocalFileModel``FileModel` is the biggest churn.
**Risk: WASM cfg gates.**
On WASM, `FileModel` is not available. `InlineDiffView` continues to use `backing_file_id: None` (selection-only, no save). The cfg gates stay at the registration site in `set_candidate_diffs()`, same as today.
## Testing and validation
1. **Existing diff application tests** (`diff_application_tests.rs`): verify that `apply_edits()` still produces correct `AIRequestedCodeDiff` with `original_content`. These don't touch `BackingFileStore` directly.
2. **Manual testing**: accept, reject, and revert agent-generated diffs in local sessions. Verify files are written/deleted correctly.
3. **WASM build**: `cargo clippy --target wasm32-unknown-unknown --profile release-wasm-debug_assertions --no-deps` — verify no regressions.
4. **Unit test for `FileModel` remote backend** (optional): construct with a mock `RemoteServerClient`, call `save()`, verify `FileSaved` event is emitted. Requires `App::test()` harness.
## Follow-ups
- **`ApplyEditModel`**: dispatch diff matching to local `apply_edits()` vs remote `ApplyEdits` RPC based on session type.
- **Tool gating for remote sessions**: enable `ApplyFileDiffs` tool in `get_supported_tools()` when `RemoteServerClient` is connected.
- **Memory optimization**: clear `DiffBase.content` after editor initialization to avoid holding two copies (one in `DiffBase`, one in `DiffModel.base`).
- **Disconnect handling**: when `RemoteServerManager` emits `SessionDisconnected`, in-flight `FileModel` remote RPCs should fail with `FailedToSave` rather than hanging. `FileModel` could subscribe to `RemoteServerManagerEvent::SessionDisconnected` and fail all pending remote ops for that session.
- **Migrate `LocalCodeEditorView`**: replace direct `LocalFileModel::handle(ctx).update(...)` calls with `FileModel::handle(ctx).update(...)`. Pure rename, no behavior change.
- **Migrate `GlobalBufferModel`**: same rename. The `FileModelEvent` subscriptions are already generic over `FileId`.
+238
View File
@@ -0,0 +1,238 @@
# Remote Command Execution for SSH Completions via RemoteServerManager
Linear: [APP-3791](https://linear.app/warpdotdev/issue/APP-3791/code-feature-support-completions)
## 1. Problem
When a user SSHes into a remote host, Warp's completions pipeline (autosuggestions, syntax highlighting, tab completions) needs to run generator commands on the remote machine. Today this is done by opening a new SSH session per command, which is constrained by the host's `MaxSessions` limit and cannot run commands in parallel.
This spec covers building the completions path through the persistent `remote_server` process:
1. **Proto messages**`RunCommandRequest` / `RunCommandResponse` for executing shell commands on the remote host.
2. **Server-side handling**`ServerModel` dispatches `RunCommand` to `$SHELL -c`, returns output.
3. **Client-side API**`RemoteServerClient.run_command()` sends the request and correlates the response.
4. **`RemoteServerCommandExecutor`** — a `CommandExecutor` that uses a `RemoteServerClient` to execute commands.
5. **Wiring** — how the executor gets its client from the manager via event subscription.
### Scope
Triggering `RemoteServerManager.connect_session()` is handled in a separate flow. The manager's connection flow sends an `Initialize` handshake (for version/host negotiation) followed by a `SessionBootstrapped` notification that carries `session_id`, `shell_type`, and `shell_path` so the server creates a per-session `LocalCommandExecutor` matching the bootstrapped shell. This spec assumes the server is already running and the manager is in `Connected` state. Error conditions are logged but not surfaced to the user.
## 2. Relevant Code
### Protocol
- `app/proto/remote_server.proto``ClientMessage`/`ServerMessage` envelopes, `RunCommandRequest` (field 7 on `ClientMessage`), `RunCommandResponse` (field 8 on `ServerMessage`)
- `app/src/remote_server/protocol.rs` — length-delimited protobuf read/write helpers, `RequestId` newtype
### Server-side
- `app/src/remote_server/server_model.rs``ServerModel` dispatches `handle_message` on the main thread; `RunCommand` arm delegates to `LocalCommandExecutor` via `ctx.spawn_abortable`, sends `RunCommandResponse` back through `response_tx`
### Client-side
- `app/src/remote_server/client.rs``RemoteServerClient` with `run_command()`, `initialize()`, background reader/writer tasks, `ClientError` enum, `ClientEvent::Disconnected`
### Remote server manager
- `app/src/remote_server/manager.rs``RemoteServerManager` singleton with per-session state (`RemoteSessionState` enum: `Connecting``Initializing``Connected``Disconnected`), `connect_session`, `client_for_session`, `deregister_session`, session-scoped events (`SessionConnected`, `SessionDisconnected`) and host-scoped events (`HostConnected`, `HostDisconnected`)
- `app/src/lib.rs:1202` — singleton registration at app startup
### Command executor framework
- `app/src/terminal/model/session/command_executor.rs``CommandExecutor` trait (`execute_command`, `supports_parallel_command_execution`), `new_command_executor_for_session` dispatch
- `app/src/terminal/model/session.rs (199-309)``Sessions::initialize_bootstrapped_session()` creates the command executor for each session
### Existing SSH executor (being replaced)
- `app/src/terminal/model/session/command_executor/remote_command_executor.rs``RemoteCommandExecutor` opens a one-off SSH session per command via `ControlMaster`/`ControlPath`. Limited by `MaxSessions`, does not support parallel execution.
## 3. Current State
SSH completions currently use `RemoteCommandExecutor`, which forks a new `ssh` process for every generator command (e.g. `compgen -c`, `ls`). Each invocation opens a new channel on the ControlMaster SSH connection. This has two problems:
- **MaxSessions limit**: Many SSH servers default `MaxSessions` to 10. When multiple generators fire in parallel, they can exceed this limit and get `channel: open failed` errors. To avoid this, `RemoteCommandExecutor` returns `false` from `supports_parallel_command_execution()`, serializing all generator commands and making completions slow.
- **Per-command overhead**: Each command requires SSH channel setup/teardown. Even with multiplexing, the per-command latency adds up across the dozens of generators that fire during a typical completion cycle.
The remote server architecture (proto, `ServerModel`, `RemoteServerClient`, `RemoteServerManager`) already exists. The `remote_server` binary runs on the remote host as a long-lived process, communicating with the client over a single SSH connection via length-delimited protobuf. The manager tracks per-session state (`Connecting``Initializing``Connected``Disconnected`) and emits lifecycle events. What's missing is the `RunCommand` flow (proto messages, server dispatch, client API) and the `CommandExecutor` implementation that plugs into the completions pipeline.
## 4. Proposed Changes
### 4.1. Proto changes
`RunCommandRequest` (field 7 on `ClientMessage.oneof`):
- `string command` — the shell command to execute.
- `optional string working_directory` — cwd for the command. If absent, uses the server's default.
- `map<string, string> environment_variables` — env vars applied natively via `cmd.envs(...)`, not baked into the command string.
- `uint64 session_id` — routes the command to the correct per-session executor.
`RunCommandResponse` (field 8 on `ServerMessage.oneof`):
- `bytes stdout` / `bytes stderr` — raw output. `bytes` to avoid UTF-8 validity assumptions.
- `optional int32 exit_code` — absent when the process is killed by a signal (Unix).
### 4.2. Server-side: per-session executors in `ServerModel`
`ServerModel` maintains a `HashMap<SessionId, Arc<LocalCommandExecutor>>` (`executors`) - every session must be registered via `SessionBootstrapped` before it can run commands.
**SessionBootstrapped handler**: When the client sends `SessionBootstrapped` with `session_id`, `shell_type`, and optionally `shell_path`, the server parses the shell type and creates a `LocalCommandExecutor` with the provided `shell_path` (or falls back to the bare shell name if absent). The executor is inserted into `executors` keyed by the session ID. If the shell type is unknown, the handler logs an error and returns early (this is a notification — no response is sent).
**Repeated SessionBootstrapped**: If `SessionBootstrapped` is sent again for the same `session_id`, the new executor overwrites the old one (last-writer-wins). In-flight commands on the old executor complete or fail naturally since they hold their own `Arc<LocalCommandExecutor>`. A warning is logged when this happens.
**RunCommand handler**: Looks up the executor by `session_id` from the request. If the session is unregistered, returns `ErrorResponse` with `INVALID_REQUEST` — this is a bug (every session goes through `SessionBootstrapped` before sending commands).
- **Future**: delegates to `LocalCommandExecutor::execute_local_command()`.
- **`on_resolve`** (main thread): removes the entry from `in_progress`. If `Ok(output)`, wraps in `RunCommandResponse` and sends via `response_tx`. If `Err(e)`, sends `ErrorResponse { code: INTERNAL, message }`.
- **`on_abort`** (main thread): removes the entry from `in_progress` and logs the cancellation. No response is sent — `Abort` is fire-and-forget.
- **Abort handling**: When the client sends `Abort`, `handle_message` removes the `SpawnedFutureHandle` from `in_progress` and calls `handle.abort()`. The framework aborts the background future (dropping it kills the child process via `kill_on_drop`) and invokes the `on_abort` callback.
This same `spawn_abortable` + `in_progress` pattern is used for all async request handlers (e.g. `NavigatedToDirectory`), so the `Abort` handler works generically for any in-progress request.
**Why `LocalCommandExecutor`:** Delegating to it gives us shell config flags (`--norc` for bash, `-f` for zsh, `--no-config` for fish) that suppress sourcing `.bashrc`/`.zshrc`, which is correct for generator commands. It also gives us process-group tracking and `kill_on_drop` via the `command` crate's `Command` wrapper.
### 4.3. Client-side: `RemoteServerClient.run_command()`
```rust path=null start=null
pub async fn run_command(
&self,
session_id: SessionId,
command: String,
working_directory: Option<String>,
environment_variables: HashMap<String, String>,
) -> Result<RunCommandResponse, ClientError>
```
`run_command()` accepts `SessionId` to route to the correct per-session executor. It uses the same request/response correlation pattern as `initialize()`: generate a `RequestId`, construct a `ClientMessage`, call `send_request`, match the response variant. On `ErrorResponse`, returns `ClientError::ServerError`. On timeout, sends `Abort` and returns `ClientError::Timeout`. Session registration is handled separately by `notify_session_bootstrapped()`, which sends a fire-and-forget `SessionBootstrapped` notification (no response expected).
### 4.4. `RemoteServerCommandExecutor`
A `CommandExecutor` implementation in `app/src/terminal/model/session/command_executor/remote_server_executor.rs`.
**Structure:**
```rust path=null start=null
#[derive(Debug)]
pub struct RemoteServerCommandExecutor {
session_id: SessionId,
client: RwLock<Option<Arc<RemoteServerClient>>>,
}
```
The executor holds its `SessionId` and a `parking_lot::RwLock<Option<Arc<RemoteServerClient>>>` that starts as `None` and is updated from the main thread when the connection state changes.
**Why `RwLock<Option<>>`:** The manager stores each session's client as `Arc<RemoteServerClient>` inside `RemoteSessionState`. `client_for_session()` returns `Option<&Arc<RemoteServerClient>>`, so the main thread can clone the `Arc` out. Cloning gives a second handle to the same underlying channels (`outbound_tx`, `pending_requests`), fully functional from any thread. `RwLock<Option<>>` lets the main thread write the `Arc` on connect and clear it on disconnect, while background threads read it without needing `AppContext`.
Unlike `OnceLock`, `RwLock<Option<>>` supports clearing the client on disconnect and replacing it on reconnect. The read lock is uncontended in practice — the writer (main thread, on connect/disconnect) and readers (background threads, on `execute_command`) almost never overlap, and the read-side operation is just an `Arc::clone`.
**`set_client(client: Arc<RemoteServerClient>)`** — writes `Some(client)` into the `RwLock`. Called from the main thread on connect.
**`clear_client()`** — writes `None` into the `RwLock`. Called from the main thread on disconnect.
**`CommandExecutor` impl:**
- `execute_command(command, shell, cwd, env_vars, options)`: reads `self.client.read().clone()`. If `None` (server not connected yet or disconnected), returns empty `CommandOutput` with `Failure` status and logs a warning. Otherwise, calls `client.run_command(self.session_id, command, cwd, env_vars)`. Timeout and abort are handled by `send_request` internally using the shared `REQUEST_TIMEOUT`. Translates `RunCommandResponse``CommandOutput`.
- `supports_parallel_command_execution()``true`. The remote server multiplexes commands over a single SSH connection (unlike `RemoteCommandExecutor` which opens a new SSH session per command and is limited by `MaxSessions`).
### 4.5. Wiring the executor to `RemoteServerManager`
The executor does **not** spawn or manage the server. It gets its `RemoteServerClient` from the `RemoteServerManager` via two mechanisms set up during executor creation in `new_command_executor_for_local_tty_session`.
The manager is **per-session**: each SSH session gets its own `RemoteServerClient` and SSH connection. Events are session-scoped (`SessionConnected { session_id, host_id }`, `SessionDisconnected { session_id, host_id }`). The host-level tracking (`host_to_sessions`) exists only to deduplicate host-scoped models (e.g. `RepoMetadataModel`), not connections.
**A. Eager check at creation time**: If this session's server is already connected (the executor is created after the manager has already completed the handshake), set the client immediately:
```rust path=null start=null
let executor = Arc::new(RemoteServerCommandExecutor::new(session_id));
let remote_server_manager = RemoteServerManager::handle(ctx);
let executor_clone = executor.clone();
remote_server_manager.read(ctx, |manager, _ctx| {
if let Some(client) = manager.client_for_session(session_id) {
executor_clone.set_client(Arc::clone(client));
}
});
```
**B. Event subscription for connection changes**: Subscribe to `RemoteServerManagerEvent` so the executor tracks the connection lifecycle. The subscription receives events for *all* sessions, so it filters on `session_id`:
```rust path=null start=null
let executor_clone = executor.clone();
let remote_server_manager_clone = remote_server_manager.clone();
ctx.subscribe_to_model(&remote_server_manager, move |_sessions, event, ctx| {
match event {
RemoteServerManagerEvent::SessionConnected { session_id: sid, .. } if *sid == session_id => {
remote_server_manager_clone.read(ctx, |manager, _ctx| {
if let Some(client) = manager.client_for_session(session_id) {
executor_clone.set_client(Arc::clone(client));
}
});
}
RemoteServerManagerEvent::SessionDisconnected { session_id: sid, .. } if *sid == session_id => {
executor_clone.clear_client();
}
_ => {}
}
});
```
Both paths are needed: (A) handles the case where the server is already connected for this session, (B) handles connection and disconnection events after creation. On disconnect, the client is cleared so `execute_command` returns clean "not connected" results. On reconnect, the subscription fires `SessionConnected` again and sets the new client.
### 4.6. Dispatch ordering in `new_command_executor_for_local_tty_session`
The `SshRemoteServer` branch is added as the **first** check in `new_command_executor_for_local_tty_session`, before all other SSH executor paths:
```text path=null start=null
1. SshRemoteServer + IsLegacySSHSession::Yes → RemoteServerCommandExecutor [NEW]
2. SSHTmuxWrapper + tmux_control_mode → TmuxCommandExecutor
3. SessionType::Local (various) → LocalCommandExecutor / MSYS2 / WSL
4. WarpifiedRemote + legacy SSH + !InBandForSSH → RemoteCommandExecutor
5. default → InBandCommandExecutor / NoOp
```
**Why first:** When the remote server is available, it is strictly better than every other SSH command execution method:
- **vs `RemoteCommandExecutor`** (branch 4): opens a new SSH session per command, limited by the host's `MaxSessions` sshd setting. The remote server multiplexes all commands over a single persistent connection.
- **vs `InBandCommandExecutor`** (branch 5): injects commands into the user's visible terminal session. Slow, fragile, and pollutes terminal output.
- **vs `TmuxCommandExecutor`** (branch 2): wraps the session in tmux for generator access. The remote server provides the same capability without the tmux dependency.
By checking `SshRemoteServer` first, we ensure that when the flag is on, the persistent multiplexed connection is always preferred. When the flag is off, the existing executor dispatch is unchanged.
The full executor creation (including the eager check and subscription from §4.5) happens in this branch.
## 5. End-to-End Flow
### Precondition
`RemoteServerManager.connect_session()` was called by a separate flow (out of scope for this spec). The manager has completed: server startup → `Arc<RemoteServerClient>` creation (state: `Initializing`) → initialize handshake → `mark_session_connected` (state: `Connected`). The session's `RemoteSessionState` is `Connected { client: Arc<RemoteServerClient>, host_id }`.
### Executor creation
1. SSH session bootstraps. `Sessions::initialize_bootstrapped_session()` creates a `RemoteServerCommandExecutor` with `session_id`.
2. Eager check: reads `manager.client_for_session(session_id)` — if this session is `Connected`, clones the `Arc<RemoteServerClient>` and calls `executor.set_client(client)`.
3. Subscribes to `RemoteServerManagerEvent` for future `SessionConnected`/`SessionDisconnected` events, filtering on matching `session_id`.
### RunCommand flow
Detailed steps:
1. Generator calls `executor.execute_command("compgen -c", shell, cwd, env_vars, opts)`.
2. `RemoteServerCommandExecutor` reads `self.client.read().clone()`. If `Some`, uses the `RemoteServerClient`.
3. Calls `client.run_command(self.session_id, command, cwd, env_vars)`.
4. `run_command` calls `send_request(request_id, msg)`, which registers a oneshot in `pending_requests`, sends the `ClientMessage` via `outbound_tx`, and awaits the response with the standard `REQUEST_TIMEOUT`. If the timeout fires, `send_request` removes the `pending_requests` entry and sends `Abort` to the server.
5. **Client writer task** pulls message from channel, calls `write_client_message``[4-byte LE length][protobuf bytes]` over SSH stdin.
6. **Server stdin reader task** decodes `ClientMessage`, dispatches to `ServerModel::handle_message` via `ModelSpawner`.
7. `handle_message` matches `RunCommand`: delegates to `LocalCommandExecutor::execute_local_command()` via `ctx.spawn_abortable`. The returned `SpawnedFutureHandle` is stored in `in_progress` so the client can cancel it via `Abort`.
8. `on_resolve` callback receives `Output`, removes the entry from `in_progress`, constructs `RunCommandResponse { stdout, stderr, exit_code }`, sends via `response_tx.try_send(response)`.
9. **Server stdout writer task** encodes `ServerMessage``[4-byte LE length][protobuf bytes]` over SSH stdout.
10. **Client reader task** decodes `ServerMessage`, looks up `request_id` in `pending_requests`, resolves the oneshot.
11. `run_command()` receives `RunCommandResponse`, returns to executor.
12. Executor translates: `exit_code == Some(0)``CommandExitStatus::Success`, else `Failure`. Wraps in `CommandOutput { stdout, stderr, status, exit_code }`.
### Server connects after executor creation
1. Executor is created, but `self.client.read()` returns `None` because the manager hasn't connected this session yet.
2. Completions calls to `execute_command` return empty `Failure` results (logged).
3. Manager completes connection for this session, emits `SessionConnected { session_id, host_id }`.
4. Subscription handler fires on main thread (matches on `session_id`) → clones `Arc<RemoteServerClient>` from manager → `executor.set_client(client)`.
5. Subsequent `execute_command` calls read the client from the `RwLock`.
### Disconnection
1. SSH dies or server crashes for this session → `RemoteServerClient` reader task hits EOF → clears `pending_requests` (in-flight calls get `ResponseChannelClosed`).
2. Manager's subscription on the client fires `ClientEvent::Disconnected``mark_session_disconnected(session_id)` → emits `SessionDisconnected { session_id, host_id }`.
3. Subscription handler fires on the executor (matches on `session_id`) → `executor.clear_client()` → writes `None` into the `RwLock`.
4. Subsequent `execute_command` calls read `None` → return clean empty `Failure` result (logged).
### Reconnection
1. Manager reconnects this session (trigger out of scope for this spec) — creates a new `RemoteServerClient` for this session.
2. Manager emits `SessionConnected { session_id, .. }` → subscription handler fires → clones new `Arc<RemoteServerClient>` from manager → `executor.set_client(new_client)`.
3. Subsequent `execute_command` calls read the new client from the `RwLock`. Completions resume.
+78
View File
@@ -0,0 +1,78 @@
# Remote Server Authentication
Linear: [APP-3801](https://linear.app/warpdotdev/issue/APP-3801)
## Summary
The remote server gains a daemon-wide authentication layer so that handlers running on a remote host can make authenticated upstream calls to Warp services on behalf of the Warp user driving the daemon. The initial credential rides on the existing `Initialize` handshake as a new `auth_token` field (no extra round-trip on connection setup); mid-session rotation uses a dedicated new `Authenticate` message. The credential lives in daemon memory for the daemon's lifetime and is cleared only on process exit — there is no explicit protocol message to clear it mid-life.
## Problem
Today the remote server has no notion of user identity. Any handler that needs to call Warp services (`app.warp.dev` APIs, upstream LLM routing, telemetry attribution, Drive-backed features) from the remote host has no credential to present. At the same time, APP-4068 makes the server a long-running daemon shared across multiple client connections from the same user's tabs, with a 10-minute grace period after the last disconnect. Any credential model for this system has to work within that architecture: the daemon is started by whichever proxy got there first, serves multiple concurrent connections, and may outlive any single SSH session.
## Goals
- Give the daemon a way to receive the current Warp credential for the user that owns its socket path.
- Let handlers running on the daemon use that credential for upstream calls.
- Support mid-session credential rotation so short-lived tokens (Firebase ID tokens) can refresh without tearing down the connection.
- Keep the credential in daemon memory only — never on disk, never in process arguments or environment.
- Minimize protocol surface: one new `auth_token` field on `Initialize` for the initial credential; one new `Authenticate` message for mid-session rotation; no new error codes in this PR, no explicit clear message.
## Non-goals
- Multi-user authentication on a shared daemon. All connections on a given daemon belong to the same Warp user by construction — socket-path partitioning by identity key in APP-4068 enforces this at the file-system level.
- Validating credentials locally on the remote host. Validity is determined by the upstream service that receives them.
- Persisting credentials across server restarts or across the daemon's grace-period expiry.
- Securing the server against adversarial co-located Unix users on the remote host. SSH is assumed to be the trust boundary.
- Authenticating the `Initialize`/`InitializeResponse` handshake itself. The handshake remains anonymous.
- Remote MCP spawn or server-side MCP credential handling. MCP is assumed client-side.
## Behavior
### Proxy and daemon topology
The remote server runs as two distinct process roles on the remote host:
**Daemon** (`remote-server-daemon`): a long-lived process scoped to a single user identity. The first proxy to connect for a given identity spawns the daemon; subsequent proxies join the already-running daemon. The daemon listens on a Unix socket whose path is partitioned by an identity key — the Warp canonical user UUID for logged-in users, or a per-install persistent UUID for anonymous users. All authentication state lives exclusively in the daemon, as a single credential shared by all of its connections.
**Proxy** (`remote-server-proxy`): a short-lived process scoped to a single SSH session. Each Warp tab that connects to a remote host spawns its own proxy. The proxy byte-bridges SSH stdin/stdout to the daemon's Unix socket and is auth-unaware — it does not inspect, hold, or forward credentials. One user can have multiple proxies connected to the same daemon simultaneously (one per open tab).
The lifecycle of auth state maps to process events as follows:
- **Proxy connects**: a new connection is registered with the daemon. The client calls `initialize(auth_token)` on that connection; the daemon stores (or overwrites) its singleton credential as part of handshake processing. No follow-up message is needed.
- **Proxy exits** (SSH drop, tab close, user logout): the daemon deregisters that connection. The singleton credential is retained. Handlers on any remaining connections continue to see it.
- **Last proxy exits**: the daemon enters its up-to-10-minute grace period. The credential is still held in memory but has no active consumer. A new proxy arriving during this window joins the existing daemon and sees the credential already populated; it nonetheless carries the current token on its own `Initialize` to keep the protocol path uniform.
- **Daemon exits** (SIGTERM, panic, or grace-period expiry): all in-memory state, including the credential, is lost. The next proxy to connect starts a fresh daemon with no credential.
The daemon never mutates its credential in response to connection events. The singleton is only written by `Initialize` (carrying `auth_token`) or `Authenticate`, and only cleared by process exit.
### Connection authentication
1. The client carries its current bearer token on the `Initialize` handshake itself (as the new `auth_token` field). The daemon stores it as its single credential as part of handshake processing. No follow-up message is required to complete initial authentication.
2. The credential is daemon-wide, not per-connection. All connections on a given daemon share one credential because the socket path guarantees they all belong to the same Warp user.
3. For mid-session rotation, the client sends a fire-and-forget `Authenticate` message. It carries only a new bearer token; no acknowledgement is sent. `Initialize` retains its existing request-response structure and is only used for new connections.
4. Any connection may carry a refresh `Authenticate`; the client picks one arbitrarily and does not fan out. Initial authentication and refresh are distinct on the wire (the former rides on `Initialize`), though both write to the same daemon singleton.
### Server-side credential usage
5. When a handler needs an upstream credential, it reads the daemon's single credential. The originating connection's identity is irrelevant.
6. If the daemon has no credential stored, handler behavior is defined by the PR that introduces the first such handler (see TECH.md §7). It is out of scope for this PR; no handler in this PR reads the credential.
7. Local-only handlers (filesystem operations, local command execution, repo metadata indexing) behave identically whether or not the daemon has a credential.
### Lifecycle
8. The credential exists only in daemon memory. It is never written to disk, environment variables, process arguments, or any on-disk artifact of any `oz remote-server*` binary.
9. Deregistering a connection does not clear the credential. The daemon deliberately retains it across connection teardown so that other connections (and any future reconnects to the same daemon) continue to work without re-auth machinery.
10. The credential is cleared only on daemon process exit — SIGTERM, panic, or APP-4068's grace-period expiry after the last proxy disconnects. There is no intermediate cleanup path.
11. A reconnected client (after SSH drop or explicit teardown) rejoins the existing daemon if it is still alive and sees the same credential. If it arrives after a daemon restart, it observes a fresh daemon with no credential and must authenticate.
### Client-side responsibilities
12. `RemoteServerClient` exposes two methods: `initialize(auth_token)` carries the initial credential during handshake, and `authenticate(token)` refreshes the credential mid-session. The client is responsible for calling `initialize` with the current bearer token when establishing a new connection, and `authenticate` only when the local token rotates.
13. On rotation, the manager picks one arbitrary `Connected` session and sends `authenticate` on it. No fan-out; the daemon's singleton propagates the new value to every handler.
14. On logout, the client tears down its remote connections. The daemon's credential is cleared only when the daemon process exits at grace-period expiry. Mid-life clearing is not part of the protocol.
### Security invariants
15. The credential is transmitted only over the already-encrypted client-to-server byte stream (SSH stdin/stdout for the per-SSH topology; the local Unix socket for the APP-4068 daemon topology, whose file-system permissions are owned by APP-4068 and scoped to the owning user). It never appears in process arguments, environment variables, or on-disk artifacts of any `oz remote-server*` binary.
16. The credential is never written to logs. Server-side log statements redact the credential field whenever `Initialize` or `Authenticate` messages are traced.
+354
View File
@@ -0,0 +1,354 @@
# TECH.md — Remote Server Authentication (APP-3801)
Linear: [APP-3801](https://linear.app/warpdotdev/issue/APP-3801)
Behavior is specified in `specs/APP-3801/PRODUCT.md`. This document plans the implementation and documents the alternatives that were rejected because of how APP-4068 reshapes the server topology.
## 1. Context
### Terminology note
The originating Linear ticket is titled "remote server initialization with authentication runtime flags", which reads like CLI flags on `oz remote-server*`. The design does **not** introduce such flags. Once APP-4068's daemon topology is in scope, every startup-time credential transport (argv, env, fd inheritance, file handoff) fails for the same set of reasons — §4 walks through each. The "runtime" in this design means runtime protocol fields — an `auth_token` carried on `Initialize` for the initial credential, and a dedicated `Authenticate` message for mid-session rotation — exchanged over the already-encrypted client↔server byte stream, not CLI args parsed at process start. Reviewers who come in expecting `--auth-token` should start at §4.
### Remote server today
- `crates/remote_server/proto/remote_server.proto``ClientMessage`/`ServerMessage` envelopes, `Initialize`/`InitializeResponse`, shared `ErrorResponse` with `ErrorCode { UNSPECIFIED, INVALID_REQUEST, INTERNAL }`. No auth fields anywhere.
- `crates/remote_server/src/client.rs (117-199)``RemoteServerClient::initialize()`, per-request correlation via `pending_requests`, fire-and-forget helpers (`send_notification`, `notify_session_bootstrapped`).
- `crates/remote_server/src/manager.rs (168-283)``RemoteServerManager::connect_session` drives Setup → Launch → `Initialize` handshake → `Connected`.
- `app/src/remote_server/mod.rs:21``run()` configures stderr-only logging and boots the headless warpui app; reads no CLI args or env.
- `app/src/remote_server/server_model.rs (118-480)``ServerModel` state, `handle_message` dispatch, `handle_initialize` returns `server_version` + `host_id`.
- `crates/warp_cli/src/lib.rs:430-433``WorkerCommand::RemoteServer` is a unit variant; `app/src/lib.rs:548-551` dispatches to `remote_server::run()` with no args.
### Credential sources on the client
- `app/src/auth/credentials.rs``Credentials::{Firebase, ApiKey, SessionCookie, Test}`, `AuthToken::{Firebase(String), ApiKey(String), NoAuth}`, `AuthToken::bearer_token()`.
- `app/src/server/server_api/auth.rs (243-280)``ServerApi::get_or_refresh_access_token()` returns a fresh `AuthToken`, transparently refreshing Firebase tokens 5 minutes before expiry. Emits `ServerApiEvent::NeedsReauth` on refresh failure.
### APP-4068 daemon topology (the operative constraint)
APP-4068 splits the binary into `remote-server-proxy` (byte-bridging SSH stdio to a Unix socket via `std::io::copy`) and `remote-server-daemon` (long-lived, spawned by the first proxy via `setsid` with null stdio, serves multiple concurrent proxy connections, 10-minute grace after last disconnect). `ServerModel` already tracks connections in `HashMap<ConnectionId, Sender<ServerMessage>>` and exposes `register_connection(id, sender)` / `deregister_connection(id)`. Routing of responses is per-`ConnectionId` — there is no broadcast path except explicit fan-out over the map.
The critical invariants this imposes on auth design:
- Which tab spawned the daemon is an accident of timing — any credential baked into the daemon at spawn belongs statically to whichever proxy won the `flock` race, with no path to update it later.
- The proxy is deliberately protocol-agnostic; anything the proxy has to understand about auth is new coupling.
- The daemon outlives any single SSH session, so credential rotation has to work at any point during the daemon's life.
### Identity key and socket-path partitioning (APP-4068 foundational assumption)
APP-4068 partitions daemon sockets by user identity at the path level: `~/.warp[-channel]/remote-server/{identity_key}/server.sock`. The `{identity_key}` is:
- **Logged-in users**: the Warp canonical user UUID.
- **Anonymous users**: a per-install persistent UUID loaded from user preferences under key `"ExperimentId"`, generated and saved if absent. This ensures all tabs for the same anonymous install share a single daemon and socket, matching the logged-in behavior.
APP-3801 treats this partitioning as a given. Because the socket is identity-scoped, all connections to a given daemon belong to the same user by construction, and the daemon can store a single credential for its whole lifetime rather than one per connection. APP-3801 does not add any cross-user authentication logic. For anonymous users with no bearer token, `Initialize` carries an empty `auth_token` (no credential provided); handler behavior in the unauthenticated case is out of scope and ships with the first handler that actually needs an upstream credential (§7).
## 2. Proposed changes
### 2.1 Protocol (`crates/remote_server/proto/remote_server.proto`)
```protobuf
message ClientMessage {
string request_id = 1;
oneof message {
Initialize initialize = 2;
// ... existing variants ...
Authenticate authenticate = 11; // new, rotation only
}
}
// Initialize gains an optional auth_token field that carries the daemon's
// initial credential as part of the handshake. Empty string means "no
// credential provided" (anonymous users); the daemon leaves its existing
// auth_token unchanged in that case rather than clearing it.
message Initialize {
// ... existing fields ...
string auth_token = N; // new; empty = no credential provided
}
// Client → server: refresh the daemon's credential mid-session.
// Fire-and-forget. Sending replaces the previously stored credential
// (last-writer-wins; the daemon stores one token for its whole lifetime, §4.7).
message Authenticate {
string auth_token = 1;
}
```
No other protocol additions. `ErrorCode` stays at the existing `{ UNSPECIFIED, INVALID_REQUEST, INTERNAL }`; `ErrorResponse` is unchanged. Auth-specific error codes (`MISSING_CREDENTIAL`, `CREDENTIAL_REJECTED`, `PERMISSION_DENIED`, and any sub-reason enum) are deferred to the follow-up PR that introduces the first handler actually needing them (§7) — defining them here with no caller would ship dead protocol surface.
No `ClearCredentials` message either. Mid-life clearing is not part of the protocol; the daemon holds the credential until process exit (§6).
Two paths exist for writing the daemon's singleton: `Initialize.auth_token` carries it during handshake (covering every new connection in a single round-trip), and `Authenticate` updates it mid-session (covering rotation). The split saves one fire-and-forget message per new connection over the SSH-bridged topology; see §4.5 for why the original unified `Authenticate`-only design was rejected in favor of this bundling.
### 2.2 Client (`crates/remote_server/src/client.rs`)
```rust
impl RemoteServerClient {
/// Perform the Initialize handshake, optionally carrying the daemon's credential.
/// If `auth_token` is `Some`, the server stores it as the daemon-wide singleton
/// as part of handshake processing. If `None`, no credential is set or cleared.
pub async fn initialize(
&self,
auth_token: Option<&str>,
) -> Result<InitializeResponse, ClientError> {
// ... existing initialize logic, with auth_token wired into the Initialize message ...
}
/// Refresh the daemon's credential mid-session. Fire-and-forget.
/// Used on token rotation only; initial auth rides on `initialize`.
pub fn authenticate(&self, auth_token: &str) {
let msg = ClientMessage {
request_id: String::new(),
message: Some(client_message::Message::Authenticate(
Authenticate { auth_token: auth_token.to_owned() },
)),
};
self.send_notification(msg);
}
}
```
No `ClientError` additions in this PR. Auth-specific error-code handling lands with the first handler that produces those codes (§7).
### 2.3 Manager (`crates/remote_server/src/manager.rs`)
**Pre-initialize auth step:**
Phase 4 of `connect_session` fetches the credential *before* `initialize` and passes it through as part of the handshake, rather than as a follow-up fire-and-forget message.
1. Before calling `client.initialize(...)`, hop to the main thread and obtain a fresh `AuthToken` via `ServerApi::get_or_refresh_access_token()` (reached through a new `AuthProvider` handle passed into `connect_session`, kept abstract so `remote_server` stays independent of `app/src/server`).
2. Call `client.initialize(auth_token.bearer_token())`. If the `Option` is `Some`, the daemon stores the token as part of handshake processing. If `None` (anonymous user, or an `AuthToken::NoAuth`), the daemon leaves its singleton untouched — in the anonymous case there is nothing to set; in the pathological case of a logged-in user arriving on an already-populated daemon, the existing credential is still valid.
3. Transition to `Connected`.
No separate follow-up `authenticate` call on first connection. See §3's mermaid for the single-round-trip flow and §4.5 for why the original split-message design was rejected.
**Token rotation (pick-one, no fan-out):**
Subscribe `RemoteServerManager` to the client-side auth state. On token rotation (the existing Firebase refresh path in `ServerApi`), pick one arbitrary `Connected` session and call `client.authenticate(new_token)` on it. The daemon stores a single credential shared across all its connections, so one send is sufficient to update every handler's view of the current token. This pattern matches how the manager already sends other server-bound notifications. If no sessions are `Connected` at the moment of rotation, the rotation is a no-op; the next new session's `initialize` carries the current token.
**Logout path:**
On logout, the client tears down its remote connections. No explicit server-side clear is sent — the daemon retains the stale token in memory until its grace period expires and the process exits. See §6 for the trade accepted.
No change to `start_remote_server` or SSH args. No credential enters argv or env.
### 2.4 Server (`app/src/remote_server/server_model.rs`)
```rust
pub struct ServerModel {
// ... existing fields ...
/// Daemon-wide credential, populated by handle_initialize (when the
/// Initialize message carries a non-empty auth_token) or by
/// handle_authenticate (mid-session rotation). Stored as a plain String;
/// the server forwards tokens opaquely without categorising or validating
/// them locally. Never cleared except by daemon process exit — see §4.7
/// for why per-connection state was rejected given APP-4068's identity-
/// scoped daemon.
auth_token: Option<String>,
}
fn handle_initialize(&mut self, msg: Initialize) -> InitializeResponse {
if !msg.auth_token.is_empty() {
log::info!("Initialize carries credential (token=<redacted>)");
self.auth_token = Some(msg.auth_token);
}
// If auth_token is empty: do nothing. Never clear an existing singleton
// from Initialize — a reconnect with no token must not invalidate the
// daemon's current credential.
//
// ... existing response construction (server_version, host_id, ...) ...
}
fn handle_authenticate(&mut self, msg: Authenticate) {
log::info!("Handling Authenticate (token=<redacted>)");
self.auth_token = Some(msg.auth_token);
}
pub fn auth_token(&self) -> Option<&str> {
self.auth_token.as_deref()
}
// `deregister_connection` (added by APP-4068) is unchanged by this PR.
// `auth_token` is deliberately retained across connection teardown;
// the only cleanup event is daemon process exit (grace-period expiry,
// SIGTERM, panic). See §6.
```
**Why daemon-wide rather than per-connection?** APP-4068 already partitions daemon socket paths by user identity (§1), so every connection on a given daemon belongs to the same Warp user by construction and will present the same bearer token. A `HashMap<ConnectionId, String>` would hold N copies of the same string with no behavioral difference. §4.7 walks through the per-connection alternative in full; the short version is that `Option<String>` is simpler, smaller, and avoids cleanup machinery that has no work to do.
## 3. End-to-end flow
### Overview
Three flows — fresh connection, proactive refresh, and server-side teardown — route through two protocol touchpoints: the initial credential rides on the existing `Initialize` handshake (as a new `auth_token` field), and mid-session refreshes use the new fire-and-forget `Authenticate` message. The server is a passive recipient in both cases: it writes into its singleton `auth_token` slot when either carries a value, and does nothing else auth-related. The client-side `RemoteServerManager` owns every decision about *when* to send. Handler behavior when a credential is missing, rejected, or insufficient is out of scope here (§7).
### User identity scoping
User identity is established before any APP-3801 message flows. The client selects the daemon socket path by identity key — the Warp canonical user UUID for logged-in users, or a per-install persistent UUID for anonymous users (see §1 "Identity key and socket-path partitioning"). Because each daemon is bound to exactly one socket path, every connection it accepts already belongs to a single user by construction, and the daemon stores a single credential for all of them. APP-3801 carries no additional user-identifier field on the wire; the bearer token carried on `Initialize` (or `Authenticate` at refresh time) is the only user-identifying information the daemon sees.
**How identity flows in practice.** Using `abc123` as a placeholder for the user's Warp UUID:
1. **Client computes the path.** Warp (running on the local machine) already has the user's identity in memory. It builds the socket path: `~/.warp/remote-server/abc123/server.sock`. For anonymous users, `abc123` is replaced with the per-install `ExperimentId` UUID loaded from preferences.
2. **Client launches the proxy with that path.** When the user opens a remote session, the client invokes something like `oz remote-server-proxy --socket-path ~/.warp/remote-server/abc123/server.sock` on the remote host over SSH. The proxy receives the path as an opaque argv string — it does not parse, validate, or interpret the UUID segment; it just knows where the socket lives.
3. **Proxy finds or spawns the daemon.** The proxy attempts to `connect()` at the given path. If a daemon is already listening, it joins. If not, it spawns one (via `setsid`) whose first action is to `bind()` the socket at that path. APP-4068 owns this whole dance; APP-3801 rides on top of it.
4. **Daemon has no UUID awareness.** The daemon never sees a UUID in its own code. It does not parse an identifier out of any message, does not validate users, does not know what `abc123` means. It listens on the socket it was told to listen on and accepts whatever connections arrive there.
**Why this scopes identity without a protocol field:**
- A second tab from the same Warp user → same UUID → same path → same daemon (joins it).
- A tab from a different Warp user → different UUID → different path → different daemon (separate process).
- A process running as a different OS user on the remote host → cannot reach the socket at all, because the parent directory `abc123/` is `mode 0700` owned by the one OS user. The kernel's permission check enforces it.
The daemon never needs to trust a user identifier on the wire because the identifier isn't on the wire. It is in the file-system path, and the OS's permission check is what turns "a process requested connection to this path" into "that process is running as the owning user."
```mermaid
sequenceDiagram
participant API as ServerApi (client)
participant Mgr as RemoteServerManager
participant C1 as Client (tab 1)
participant C2 as Client (tab 2)
participant Server as ServerModel (daemon)
Note over Mgr,Server: 1. Initial authentication (first connection, single round-trip)
Mgr->>API: get_or_refresh_access_token()
API-->>Mgr: AuthToken
Mgr->>C1: initialize(Some(token))
C1->>Server: ClientMessage(Initialize{auth_token})
Note right of Server: handle_initialize sets auth_token = Some(token)
Server-->>C1: ServerMessage(InitializeResponse)
Note over Mgr,Server: 2. Additional connection on same daemon (same single RTT)
Mgr->>C2: initialize(Some(token))
C2->>Server: ClientMessage(Initialize{auth_token})
Note right of Server: auth_token overwritten with same value
Server-->>C2: ServerMessage(InitializeResponse)
Note over API,Server: 3. Proactive refresh (~5 min before expiry, pick-one)
API-->>Mgr: token-rotated event
Mgr->>C1: authenticate(new_token)
C1->>Server: ClientMessage(Authenticate)
Note right of Server: auth_token = Some(new_token)
Note over C2: no message sent — singleton already updated
Note over Server: 4. Server-side teardown (SSH drop / crash / logout)
C1--xServer: connection closes
Note right of Server: deregister_connection runs; auth_token retained
C2--xServer: connection closes
Note right of Server: grace timer starts; auth_token still retained
Note right of Server: grace expiry → process exit → auth_token gone
```
### Fresh connection
1. Client calls `manager.connect_session(session_id, socket_path)`.
2. Manager runs Setup / Launch / creates `RemoteServerClient`.
3. Manager calls `get_or_refresh_access_token()`, then `client.initialize(auth_token.bearer_token())`. If `bearer_token()` is `Some`, the initial credential rides on the `Initialize` message; if `NoAuth`, `None` is passed and the daemon leaves its singleton untouched.
4. Server: `handle_initialize` runs the existing handshake logic and, if the carried `auth_token` is non-empty, sets `auth_token = Some(token)`. Responds with `InitializeResponse`. If this is not the first connection on this daemon, the value overwrites the existing singleton with the same token (same user, same `ServerApi`).
5. Session transitions to `Connected`.
### Token rotation
1. Client-side `ServerApi` refreshes its Firebase ID token (existing auto-refresh).
2. `RemoteServerManager` observes the rotation event and picks one arbitrary `Connected` session, calling `client.authenticate(new_token)` on it. This pattern matches how the manager already handles other server-bound notifications.
3. Server: `handle_authenticate` replaces the singleton.
4. In-flight requests using the previous token continue — warp-server accepts a refresh overlap window of both old and new tokens.
5. If no sessions are `Connected` at the moment of rotation, the rotation is a no-op; the next new session's `initialize` carries the current token.
### SSH drop / daemon grace-period expiry
1. Proxy sees SSH EOF → exits → daemon's accept loop observes connection close → `deregister_connection(connection_id)`.
2. `auth_token` is retained. There is no per-connection auth state to clean up.
3. Daemon continues serving other connections (if any) with the same singleton credential.
4. When the last connection leaves, APP-4068's grace timer starts. The daemon still holds `auth_token` during the grace window.
5. On grace-period expiry (or SIGTERM, panic) the daemon process exits. All in-memory state, including `auth_token`, dies with the process.
## 4. Alternatives considered
Each was considered and rejected. Each failure mode is stated with respect to APP-4068's daemon topology.
### 4.1 CLI flag on the server binary (`--auth-token`)
Shape: make `WorkerCommand::RemoteServer` a struct variant carrying `--auth-token`; the client includes the token in the SSH launch command.
Why rejected:
- **Leaks via `ps`.** On any shared-user remote host, argv is world-readable. Disqualifying on security grounds before even considering the daemon.
- **No refresh channel; stale at spawn.** Firebase ID tokens expire in ~1 hour. A startup-only credential cannot be updated once baked into argv: even if a later tab on the same daemon has a fresher token, there is no protocol path for it to land on the already-spawned daemon. The runtime `Authenticate` message this spec introduces is the only way to ship a rotation-capable credential.
### 4.2 Environment variable (`WARP_REMOTE_AUTH_TOKEN`)
Why rejected:
- Readable via `/proc/$pid/environ` on shared hosts.
- SSH strips env by default; `SendEnv`/`AcceptEnv` cooperation not guaranteed on arbitrary remote hosts.
- Same "no refresh channel, stale at spawn" problem as §4.1.
### 4.3 File-based handoff
Why rejected:
- Brief disk artifact; a crash between write and unlink leaves a credential on disk.
- Ambiguous ownership in daemon mode (multiple tabs drop distinct files; daemon must pick one).
- One-shot; doesn't model rotation.
### 4.4 File-descriptor handoff (`--auth-fd 3`)
Why rejected:
- No fd inheritance path to the daemon. APP-4068 spawns the daemon via `Command::pre_exec(setsid)` with null stdio; inherited fds are dropped.
- Even if the proxy held such an fd, the daemon-spawn step drops it.
- One-shot; doesn't model rotation.
### 4.5 Separate `Authenticate` for initial auth (in addition to refresh)
Shape: `Initialize` carries no credential. The client sends a fire-and-forget `Authenticate` message immediately after `Initialize` completes, and again on any later rotation. Both initial and refresh paths use the same message.
This is how the spec was structured before the bundling pivot — a unified "one message for setting credentials" surface.
Why rejected:
- **Extra message per new connection.** Every new connection on a daemon would send `Initialize` + one follow-up `Authenticate`, rather than a single `Initialize` carrying the token. On the SSH-bridged topology (Warp → SSH → remote proxy → Unix socket → daemon), each message adds bytes on the wire and a processing round on both ends. `Authenticate` is fire-and-forget so it does not block an RTT, but it adds one message per new connection for no semantic gain — the token was available to the client *before* `Initialize` was sent.
- **Transient unauthenticated state.** With split messages, the session briefly exists in an "Initialize complete, Authenticate in-flight" window. No one observes this state today (no handlers use `auth_token` yet, §7), but the moment the first such handler lands, either the client or the handler has to reason about what happens if a request arrives during that window. Bundling collapses this: once `InitializeResponse` is received, the daemon's credential is already set.
- **No unified-surface benefit.** The ostensible win of "one path to set the credential" is surface-level; `Authenticate` stays in place for refresh regardless, so both paths exist either way. The question is whether the split buys anything, and it doesn't.
### 4.6 Handshake-time upstream validation
Why rejected:
- Doubles handshake latency.
- Adds a failure mode orthogonal to the credential itself (transient network issue on the remote host blocks authentication even when the token is fine).
- Store-and-forward produces the same end-to-end behavior via the reactive-refresh path at lower cost.
### 4.7 Per-connection credential map
Shape: `ServerModel` holds `auth_tokens: HashMap<ConnectionId, String>` instead of `auth_token: Option<String>`. Each `Authenticate` is keyed by the connection that sent it; each `deregister_connection` clears that connection's entry; each handler reads the entry for the connection its request arrived on. A dedicated `ClearCredentials` message pairs with the map to give the client a proactive clear path on logout.
This is the closest alternative to the chosen design — it was the initial shape of APP-3801 before the design collapsed to a singleton.
Why rejected:
- **Storage doesn't reflect the data model.** APP-4068 partitions daemon socket paths by user identity (§1), so every connection on a given daemon belongs to the same Warp user and presents the same bearer token. A per-connection map holds N copies of the same string with no behavioral difference; the storage structure implies a flexibility the system does not provide.
- **Fan-out with no purpose.** Token rotation in the per-connection model requires the manager to iterate every `Connected` session and call `authenticate` on each — N messages to write the same value N times. The singleton collapses this to one `authenticate` on any arbitrary connection, matching the pick-one pattern already used for other manager-to-server notifications.
- **Cleanup machinery with nothing to clean.** Per-connection storage motivates a `ClearCredentials` message, a `handle_clear_credentials` branch, and an explicit `auth_tokens.remove` inside `deregister_connection` — each guarding narrow logout or teardown windows. With a singleton, none of these are needed: the daemon process itself is the trust boundary (Unix socket, user-partitioned path, OS-level file permissions owned by APP-4068), and the credential dies with the process on grace-period expiry. The per-connection design pays a fixed protocol-surface cost to close a window that, at realistic durations, is already bounded by the grace period the singleton accepts (§6).
**What the singleton gives up:** foreclosing per-connection policy if it is ever needed (different bearer tokens per tab, for example). No such use case exists today or is on the roadmap; adding per-connection state back later is a straight refactor if the need arises.
## 5. Testing and validation
- **Daemon-wide authentication:** unit tests on `ServerModel` using in-memory transports. Send `Initialize` with a non-empty `auth_token`; assert `auth_token()` returns the value. Send a second `Authenticate` (rotation); assert last-writer-wins. Send `Initialize` with an empty `auth_token` on a daemon whose singleton is already populated; assert the existing value is preserved (empty on Initialize never clears). Register a second connection and send on either one; assert the singleton reflects the last write regardless of source connection.
- **Lifecycle:** initialize with a token, deregister; assert `auth_token()` is still `Some` after `deregister_connection` (deliberate retention — no per-connection cleanup). Drop the `ServerModel`; assert a freshly-constructed model has no `auth_token`. Assert no file artifacts in `~/.warp*` contain the token at any point during the above.
- **Client and manager:** `RemoteServerClient` unit tests for `initialize(Some(token))` producing an `Initialize` message with `auth_token` set, and `initialize(None)` producing one with the field empty. Unit test for `authenticate(token)` producing the expected `Authenticate` `ClientMessage`. `RemoteServerManager` integration test: new-session flow fetches the token via `ServerApi` *before* calling `initialize` and passes it through. Rotation test: rotation event sends `authenticate` to exactly one `Connected` session (pick-one, no fan-out) and none of the `Disconnected` ones.
- **Security:** CI grep check that no `{:?}`/`Debug` formatting is applied to `ClientMessage`/`Initialize`/`Authenticate` inside server-side log sites. Unit test of `describe_client_message` on an `Initialize` input with a non-empty `auth_token` and on an `Authenticate` input asserts the token field is replaced with `<redacted>`. Audit test that `start_remote_server` argv and env contain no credential-shaped strings.
## 6. Risks and mitigations
- **Token-in-log regression.** A future contributor adds `log::info!("{:?}", msg)` in the server path. Mitigation: CI grep check on `{:?}`-formatting of `ClientMessage`/`Authenticate` inside `app/src/remote_server/` and `crates/remote_server/`, plus a redaction helper; all existing sites converted in this PR.
- **Credential persists during daemon grace period (accepted).** After the last connection disconnects, APP-4068's daemon keeps running for up to 10 minutes before exiting. During that window the daemon process still holds `auth_token` in memory with no active consumer. Mitigation: the daemon's Unix socket is owned by APP-4068 with user-scoped file-system permissions — the same security boundary that protects live credentials on disk. Reaching the socket requires OS-level access as the owning user, at which point far more sensitive material is already accessible. Treating the grace-period window as a real exposure would require either a second timer (artificial cleanup) or teardown-on-last-disconnect (collapsing the daemon model). Neither is justified by the actual threat.
- **Stale token after logout.** If the user logs out while one or more remote sessions are still alive, the daemon holds the (now-invalidated) token until its process exits at grace-period expiry. Mitigation: the client tears down the remote connections on logout, which starts the grace timer; warp-server treats the logged-out token as revoked on any upstream call, so the window is a latency issue, not an authorization-bypass issue.
- **Credential type heterogeneity.** `AuthToken` can be `Firebase(String)`, `ApiKey(String)`, or `NoAuth`. The server stores the opaque bearer string. `NoAuth` users skip `authenticate` and leave `auth_token` unset; handler behavior for that state ships with the first handler that needs upstream auth (§7). No server-side changes are needed when a new credential variant is added as long as it produces a bearer string.
## 7. Follow-ups
- **First handler that actually calls upstream.** This spec establishes only the plumbing (`ServerModel::auth_token`, `handle_initialize`'s auth-setting branch, `handle_authenticate`, `RemoteServerClient::initialize(auth_token)`, `RemoteServerClient::authenticate`, manager-side pre-initialize credential fetch and rotation wiring). No handler in this PR calls `auth_token()`. The follow-up PR introduces the first such handler and is where the `MISSING_CREDENTIAL`, `CREDENTIAL_REJECTED`, `PERMISSION_DENIED`, and any `ErrorSubReason` additions land — defined together with their first caller rather than as dead protocol surface now.
- **Client-side automatic reconnect.** If APP-4068's reconnect follow-up lands, the new session's post-initialize auth step re-authenticates naturally — no changes needed here.
- **Daemon-scoped handlers without a `ConnectionId` context.** If Warp ever needs a handler that runs outside any connection (periodic background sync, cross-tab broadcast), the singleton `auth_token` is directly usable. The current design already assumes every upstream call reads from daemon state rather than per-connection state.
+256
View File
@@ -0,0 +1,256 @@
# TECH.md — Remote Server: Headless App + Message Transport Foundation
Linear: [APP-3721](https://linear.app/warpdotdev/issue/APP-3721)
## 1. Problem
The `remote_server` crate needs to become a standalone binary that communicates with the Warp client over remote connections with length-delimited protobuf messages. In order to support future coding features like the file tree and code review pane, the remote server needs the warpui App to store and handle `Entity`/`SingletonEntity` models like `RepositoryMetadataModel`.
This spec covers the foundation: a shared protocol layer, a minimal request/response client, the headless warpui server runtime, and `Initialize` end-to-end validation.
## 2. Relevant Code
### remote_server crate (current state)
- `remote_server/Cargo.toml` — current deps: `prost`, `tokio`, `prost-build`
- `remote_server/src/lib.rs` — library target re-exporting generated prost types
- `remote_server/proto/remote_server.proto``ClientMessage`/`ServerMessage` envelopes with `Initialize`/`InitializeResponse`
- `remote_server/build.rs` — prost codegen for the proto
### Headless warpui App infrastructure
- `crates/warpui/src/platform/app.rs:68-80``AppBuilder::new_headless(callbacks, assets, test_driver)` constructor
- `crates/warpui/src/platform/app.rs:107-155``AppBuilder::run(init_fn)` wraps init_fn and enters the event loop
- `crates/warpui/src/platform/headless/app.rs``App::run()` creates mpsc channel, marks main thread, enters `event_loop::run()`
- `crates/warpui/src/platform/headless/event_loop.rs` — blocking `for event in receiver.iter()` loop processing `RunTask`, `RunCallback`, `Terminate`; includes Ctrl-C handler via `ctrlc::set_handler`
### Entity/Model system
- `ui/src/core/entity.rs:39-54``Entity` trait (has `type Event`) and `SingletonEntity` trait (provides `handle()` and `as_ref()`)
- `ui/src/core/app.rs:2060-2077``AppContext::add_singleton_model(build_model)` registers a singleton
- `ui/src/core/app.rs:845-847``AppContext::background_executor()` returns `&Arc<Background>`
### ModelSpawner
- `ui/src/core/model/context.rs:442-466``ModelContext::spawner()` creates a `ModelSpawner<T>` (Send + Clone)
- `ui/src/core/model/context.rs:592-624``ModelSpawner<T>` definition; `spawn(work).await` dispatches `work` to main thread and returns the result
- `app/src/ai/agent_sdk/driver.rs:890-1027``AgentDriver::run_internal`: long async workflow using `ModelSpawner` to step into the model at specific points
- `app/src/workspace/view/global_search/model.rs:77-178``GlobalSearch`: background ripgrep task pushing result batches via `ModelSpawner`
### No-op asset provider
- `ui/src/assets/mod.rs:5-11``impl AssetProvider for ()` returns errors for all lookups
### App termination
- `ui/src/core/app.rs:3998-4012``AppContext::terminate_app(mode, result)` delegates to platform
- `ui/src/platform/mod.rs:282-292``TerminationMode` enum: `Cancellable`, `ForceTerminate`, `ContentTransferred`
## 3. Current State
The current `remote_server` crate has:
- Proto definition for `ClientMessage`/`ServerMessage` with `Initialize`/`InitializeResponse`
- `lib.rs` re-exporting generated prost types via `include!(concat!(env!("OUT_DIR"), "/remote_server.rs"))`
- No `main.rs`, no binary entry point, no I/O code, no warpui dependency
## 4. Proposed Changes
### 4.1. Shared `protocol.rs` in the `remote_server` library
Create `remote_server/src/protocol.rs` and re-export from `lib.rs`.
**Contents:**
- `ProtocolError` enum — covers I/O errors, decode failures, unexpected EOF, and message-too-large
- `read_message<M: prost::Message + Default>(reader) -> Result<M, ProtocolError>` — reads `[4-byte LE length][protobuf bytes]`, decodes into `M`
- `write_message<M: prost::Message>(writer, msg) -> Result<(), ProtocolError>` — encodes `M`, writes `[4-byte LE length][protobuf bytes]`
- Convenience wrappers: `read_client_message`, `write_client_message`, `read_server_message`, `write_server_message` that specialize the generic helpers for `ClientMessage` and `ServerMessage`
**Message size limit:** `read_message` rejects payloads exceeding `MAX_MESSAGE_SIZE` (64 MB) with `ProtocolError::MessageTooLarge` after decoding the `u32` length prefix but before allocating the payload buffer. This prevents OOM from a corrupted or adversarial length prefix. Since both `read_client_message` and `read_server_message` delegate to the generic `read_message`, the size check applies in both directions — protecting the server from oversized client requests and the client from oversized server responses.
The generic `read_message`/`write_message` take `tokio::io::AsyncRead + Unpin` / `tokio::io::AsyncWrite + Unpin` so both the server (stdin/stdout) and client (child process or SSH streams) can use them.
### 4.2. Minimal `RemoteServerClient` in the library
Create `remote_server/src/client.rs` and export from `lib.rs`.
**Structure:**
- `RemoteServerClient` struct owns:
- `outbound_tx: async_channel::Sender<ClientMessage>` — feeds the background writer task
- `pending_requests: Arc<DashMap<RequestId, oneshot::Sender<ServerMessage>>>` — maps `request_id` to response sender (shared with the reader task).
**`RequestId` newtype:** Introduce a `RequestId(String)` newtype in the `remote_server` library (e.g. in `protocol.rs`) wrapping the proto `string request_id` field. This provides type safety over raw strings and centralizes ID generation (`RequestId::new()``Uuid::new_v4().to_string()`). The proto field stays `string`; conversion happens at the serialization boundary. Use `RequestId` consistently in `RemoteServerClient`, `ServerModel`, and `pending_requests`.
- Constructor takes generic `reader: impl AsyncRead + Unpin + Send + 'static` and `writer: impl AsyncWrite + Unpin + Send + 'static`, plus a handle to the background executor (or accepts a `tokio::runtime::Handle`)
- Spawns two background tasks:
- **Writer task**: a dedicated background task spawned at construction time. The write half of the connection (`impl AsyncWrite`) is moved into this task — no other code retains a reference. It pulls `ClientMessage`s from `outbound_rx` and writes each one via `protocol::write_client_message`.
Callers never write to the stream directly. They only hold clones of `outbound_tx`, which enqueue messages into the channel. The channel acts as a FIFO queue: concurrent `send_request` calls are serialized into arrival order, and the writer task drains them one at a time.
- **Reader task**: reads `ServerMessage`s via `protocol::read_server_message` in a loop, looks up `request_id` in `pending_requests`, sends response through the corresponding `oneshot::Sender`
**Public API:**
- `async fn initialize(&self) -> Result<InitializeResponse, ClientError>` — generates a `request_id`, sends `ClientMessage { initialize }`, awaits the correlated response
- Private `async fn send_request(&self, msg: ClientMessage) -> Result<ServerMessage, ClientError>` — generic request/response correlation
- `ClientError` enum covering disconnection, protocol errors, server-reported errors (`ClientError::ServerError`), and response timeout.
### 4.3. `main.rs` — headless App entry point
Create `remote_server/src/main.rs`. Add `[[bin]]` target in `Cargo.toml` and add `warpui` as a dependency.
```rust
fn main() -> anyhow::Result<()> {
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None)
.run(|ctx| { /* init_fn */ })?;
Ok(())
}
```
**Key details:**
- `AppCallbacks::default()` — all fields `None`, no custom callbacks needed
- `Box::new(())` — uses `impl AssetProvider for ()` (no-op, returns errors for all lookups)
- The headless `App::run()` creates the mpsc event channel, marks the current thread as main, and enters the blocking event loop. The `Background` executor inside the App IS the tokio runtime — there is exactly one runtime in the process.
- The headless warpui `App` infrastructure is proven in production (the Oz CLI uses it via `AppBuilder::new_headless` + `add_singleton_model` + `ModelSpawner`). It provides the full entity/model runtime with zero rendering overhead.
**Logging:**
Stdout is the wire transport — any stray output to stdout will corrupt the protocol and cause decode failures on the client. Logging must be configured to write exclusively to stderr before the App starts.
At the top of `main()`, before `AppBuilder::new_headless`:
```rust
env_logger::Builder::from_default_env()
.target(env_logger::Target::Stderr)
.init();
```
This ensures all `log::info!`, `log::error!`, etc. macros route to stderr.
**Client-side stderr streaming:** The client reads the server's stderr in a background task to surface server logs locally. It spawns a task that calls `read_line` on the child's stderr in a loop, forwarding each line to the client's own logging. Stderr streaming is the always-on fallback — it requires no protocol changes and continues to flow even when the protocol itself is broken, which is critical for debugging transport-level issues.
**Inside `init_fn`:**
1. Create a typed response channel: `async_channel::unbounded::<ServerMessage>()`
2. Register `ServerModel` as a singleton and obtain a `ModelSpawner` in the same step.
3. Spawn a **background stdin reader task** on `ctx.background_executor()`:
- Wraps `tokio::io::stdin()` in a `BufReader`
- Loops: `read_client_message(&mut reader).await``spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await`
- Handles errors:
- `Err(ModelDropped)` from `spawner.spawn()` by breaking out of the loop (this means the `ServerModel` was dropped during shutdown — no further messages should be processed)
- **Recoverable errors** (log a warning and continue to the next message): errors where the stream is still correctly positioned at the next message boundary. Example: `ProtocolError::Decode` — the payload bytes were already consumed, so the next read starts at a valid length prefix.
- **Fatal errors** (break and begin shutdown): errors where the stream is dead or misaligned. Examples: `ProtocolError::UnexpectedEof` (client disconnected), `ProtocolError::Io` (broken pipe, connection reset), `ProtocolError::MessageTooLarge` (payload not consumed, stream position is invalid).
- On fatal error: dispatches `spawner.spawn(|_, ctx| ctx.terminate_app(TerminationMode::ForceTerminate, None))`. This is best-effort (`let _ =`) since the model may already be gone.
4. Spawn a **background stdout writer task** on `ctx.background_executor()`:
- Wraps `tokio::io::stdout()` in a `BufWriter`
- Receives `ServerMessage`s from the `async_channel::Receiver`
- Calls `protocol::write_server_message(&mut writer, msg).await` for each
- Exits naturally when the response channel closes (all senders dropped)
`app/src/lib.rs` stays thin: boot the headless app and register `ServerModel`.
It is called from the `WorkerCommand::RemoteServer` dispatch in `app/src/lib.rs`, which
returns early before the full app initialization path — identical to how `TerminalServer`
and other worker commands are structured.
### 4.4. `ServerModel` — remote-side main-thread orchestrator
Create `remote_server/src/server_model.rs`.
```rust
pub struct ServerModel {
response_tx: async_channel::Sender<ServerMessage>,
}
impl Entity for ServerModel {
type Event = ();
}
impl SingletonEntity for ServerModel {}
```
**Responsibilities:**
- Holds the typed response sender
- Exposes `handle_message(&mut self, msg: ClientMessage, ctx: &mut ModelContext<Self>)` — called by the background stdin reader via `ModelSpawner`
- Dispatches on `msg.message` (the `oneof` variant):
- `Initialize` → constructs `InitializeResponse { server_version }` from `ChannelState::app_version()` (falling back to `env!("CARGO_PKG_VERSION")` in dev builds where `GIT_RELEASE_TAG` is unset), wraps in `ServerMessage { request_id: msg.request_id, message: Some(...) }`, sends via `self.response_tx`
- `None` (missing variant) → sends `ErrorResponse { code: INVALID_REQUEST, message }` back to the client
- Future message types will be added as new `oneof` variants in the proto and new match arms here
**Error responses:** The proto defines a shared `ErrorResponse` message (with an `ErrorCode` enum and a human-readable `message` string) as a variant in `ServerMessage.oneof`. This follows the JSON-RPC pattern: one error shape shared across all request types, with a machine-readable code for programmatic handling. The initial codes are `INVALID_REQUEST` and `INTERNAL`; domain-specific codes (e.g. `FILE_NOT_FOUND`) can be added as the protocol grows. On the client side, `ErrorResponse` maps to `ClientError::ServerError { code, message }`.
- Dispatches to future child models via `ctx.update_model(...)`, subscriptions, and emitted events — never ad-hoc cross-thread calls
**Design boundary:** Transport loops and protobuf byte encoding stay outside this model. `ServerModel` receives and sends typed Rust structs, not raw bytes.
### 4.5. Design Decision: `ModelSpawner` vs `spawn_stream_local`
Two warpui primitives could bridge background I/O to main-thread model context:
**`ModelSpawner` (chosen):** The background stdin reader task holds a `ModelSpawner<ServerModel>` and calls `spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await` for each decoded message. The transport loop is explicit code we own — it controls pacing, handles EOF, and manages shutdown. The model is a passive handler that doesn't know where messages come from.
- Precedent: `AgentDriver::run_internal` (`app/src/ai/agent_sdk/driver.rs:890`) uses `ModelSpawner` for a long async workflow. `GlobalSearch` (`app/src/workspace/view/global_search/model.rs:77`) uses it for a background producer pushing results.
- Advantage: transport-level concerns (reconnect, backpressure, batching, error recovery) stay in the transport loop, not in model callbacks. EOF handling is a simple `break` + terminate dispatch.
**`spawn_stream_local` (considered, not chosen):** The model would call `ctx.spawn_stream_local(request_rx, on_item, on_done)` during construction. Each item is delivered to an `on_item` callback; `on_done` fires on channel close.
- Precedent: `BulkFilesystemWatcher` (`watcher/src/lib.rs:163`) uses this for OS file events.
- Tradeoff: simpler setup for pure event-consumption, but the model owns the ingestion lifecycle. Transport-level logic (reconnect, rate-limiting) would need to live inside model callbacks.
We chose `ModelSpawner` because the remote server's transport layer will likely grow (protocol versioning, multiplexed streams) and keeping that logic in an explicit background loop is easier to extend.
### 4.6. Cargo.toml changes
Add to `remote_server/Cargo.toml`:
- `warpui` dependency (workspace) — for headless App, Entity, ModelContext, ModelSpawner
- `anyhow` (workspace) — error handling in main
- `tokio` features: add `io-std` for stdin/stdout access
- `async-channel` (workspace) — for all async channels (outbound client channel, server response channel). Avoid `tokio::sync::mpsc` for warpui-layer code.
- `log` (workspace) — structured logging
- `env_logger` (workspace) — stderr-only log output
- `dashmap` (workspace) — for lock-free concurrent request tracking in `RemoteServerClient`
- `thiserror` (workspace) — for `ProtocolError` and `ClientError` derive
## 5. End-to-End Flow
### Initialize handshake (client → server → client)
1. **Client** calls `RemoteServerClient::initialize()`:
- Generates a UUID `request_id`
- Constructs `ClientMessage { request_id, message: Initialize {} }`
- Registers a `oneshot::Sender` in `pending_requests` keyed by `request_id`
- Sends the message through `outbound_tx` to the writer task
2. **Client writer task** receives the `ClientMessage`, calls `protocol::write_client_message(stdout, msg)`:
- Encodes via `prost::Message::encode`
- Writes `[4-byte LE length][protobuf bytes]` to the stream
3. **Server stdin reader task** calls `protocol::read_client_message(stdin)`:
- Reads 4 bytes → interprets as LE u32 length
- Reads `length` bytes → decodes via `prost::Message::decode` into `ClientMessage`
- Dispatches to main thread: `spawner.spawn(|model, ctx| model.handle_message(msg, ctx)).await`
4. **ServerModel::handle_message** (main thread):
- Matches `Initialize` variant
- Constructs `ServerMessage { request_id, message: InitializeResponse { server_version } }`
- Sends via `self.response_tx.send(response)`
5. **Server stdout writer task** receives the `ServerMessage`, calls `protocol::write_server_message(stdout, msg)`:
- Encodes and writes `[4-byte LE length][protobuf bytes]` to stdout
6. **Client reader task** calls `protocol::read_server_message(stdin)`:
- Decodes `ServerMessage`, looks up `request_id` in `pending_requests`
- Sends the response through the `oneshot::Sender`
7. **Client** `initialize()` awaits the oneshot, receives `InitializeResponse { server_version }`
### Shutdown (stdin EOF)
1. Server stdin reader task's `read_client_message` returns an error (EOF or broken pipe)
2. Reader loop breaks
3. Reader dispatches `spawner.spawn(|_, ctx| ctx.terminate_app(TerminationMode::ForceTerminate, None))`
4. The headless event loop receives `AppEvent::Terminate(ForceTerminate)` and breaks
5. Dropping all response senders closes the response channel; stdout writer task exits
6. On the client side, the reader task sees EOF on its stream, notifies pending requests of disconnection, and the client tears down
## 6. Risks and Mitigations
- **Client request/response matching**: Responses can arrive out of order once the server handles multiple message types concurrently. Mitigation: track in-flight requests by `request_id` with a `DashMap<RequestId, oneshot::Sender>`
- **warpui compile footprint**: Pulling in `warpui` brings transitive deps (fonts, rendering stubs). These are dead code in the headless binary — same tradeoff as the Oz CLI. No runtime cost, only compile time.
- **Main thread serialization**: All typed request handling runs on the main thread via the event loop. Handlers should be fast (in-memory dispatch and model coordination). Heavy work (filesystem I/O, tree building) must be offloaded to background tasks via `ctx.spawn()` or `ModelSpawner`.
## 7. Testing and Validation
- **Unit tests for `protocol.rs`**: Round-trip encode/decode for `ClientMessage` and `ServerMessage`. Test edge cases: zero-length messages, maximum length, malformed length prefix, truncated payload.
- **Unit tests for `RemoteServerClient`**: Use in-memory `tokio::io::duplex` streams to simulate a server. Verify `initialize()` returns the expected `InitializeResponse`. Verify correct `request_id` correlation. Verify `ClientError::Disconnected` on stream close.
- **Integration test for `Initialize` round trip**: Spawn `warp remote-server` as a child process, create a `RemoteServerClient` over the child's stdin/stdout, call `initialize()`, assert `server_version` is non-empty. The test lives in `app/tests/remote_server_tests.rs` — because the `warp` binary is a `[[bin]]` target in the `app` crate, cargo automatically builds it before running these tests.
- **Shutdown test**: Send an `Initialize`, then close the client's write end. Assert the server process exits cleanly (exit code 0).
- **Build validation**: `cargo build -p warp` produces a binary with the `remote-server` subcommand. `cargo clippy` and `cargo fmt` pass.
## 8. Follow-ups
- Feature-specific message types (file tree listing, filesystem watch events, code review context) as new `oneof` variants in the proto and new `ServerModel` match arms.
- SSH integration: the app wrapper on the local side that spawns the remote server binary over SSH and wraps a `RemoteServerClient` around the SSH channel.
- **Server lifecycle management**: V0 terminates the server immediately on fatal stream errors. Future versions should better handle transient errors, client disconnects, and retries.
- **Protocol-based log streaming**: In addition to stderr, add structured log delivery over the protocol. The server would install a custom `log` layer that, alongside stderr, sends each log event through the response channel as a `ServerMessage`.
+152
View File
@@ -0,0 +1,152 @@
# APP-3825: Vertical Tabs — Drag Panes Between Tabs
## Summary
Add the vertical-tabs equivalent of Warp's existing horizontal-tab pane-drag flow. When vertical tabs are enabled, a user should be able to drag a pane header over a different tab in the vertical tabs panel, have that tab become the active drag destination, and then drop the pane into the target tab using the same in-tab drop-target and relayout UX that already exists for horizontal tabs.
The feature should also preserve the existing "promote to a new tab" behavior: dragging between tabs should create a new tab at that position instead of targeting an existing tab.
## Problem
In horizontal tabs mode, users can move a pane from one tab to another by dragging the pane header to the top tab strip, hovering the destination tab so it becomes active, and then dropping the pane into a new location inside that tab. That workflow is currently missing in vertical tabs mode, even though the same pane-drag and in-tab relayout concepts already exist elsewhere in the product.
This creates an inconsistent drag-and-drop model between horizontal and vertical tabs. Users who opt into vertical tabs lose an existing pane-management workflow and must fall back to indirect alternatives like creating a new tab first or rearranging panes after the fact.
## Goals
- Restore parity with the existing horizontal-tab pane-drag workflow when vertical tabs are enabled.
- Let users target an existing tab from the vertical tabs panel and then place the pane within that tab using the existing pane drop overlays and relayout rules.
- Let users create a new tab by dropping between vertical tab groups, matching the current horizontal-tab "drop between tabs" behavior.
- Make the targeting behavior work regardless of whether the vertical tabs panel is in compact or expanded mode.
## Non-goals
- Auto-opening the vertical tabs panel if it is currently closed.
- Redesigning the in-tab pane drop overlays or changing how pane placement inside a tab works.
- Changing any of the existing special-case pane-drop rules inside the target tab (for example, code-pane merge behavior when tabbed editor view is preferred).
- Adding new keyboard interactions for this drag flow.
- Extending this ticket to editor file-tab dragging; this spec is only for dragging pane headers.
## Figma / design references
Figma: none provided
Design intent should match the current horizontal-tab pane-drag behavior as closely as possible.
## User experience
### Availability
This behavior applies when:
- vertical tabs are enabled, and
- the vertical tabs panel is visible, and
- the user is dragging a pane by its pane header.
If the vertical tabs panel is closed, this ticket does not introduce a new auto-open or alternate targeting path.
### Valid tab targets in the vertical tabs panel
Each visible vertical tab group represents one workspace tab and must be targetable during a pane drag.
In practice:
- In **expanded** mode, hovering any visible part of a tab group counts as hovering that tab, including its pane rows and its optional custom-title header.
- In **compact** mode, the same rule applies to the compact rendering of that tab group.
- The user does not need a custom tab title/header in order to target a tab. Tabs that render only pane rows must still be targetable.
Hover-only panel chrome must not interfere with drag targeting:
- Drag-target feedback takes precedence over hover-only action affordances like the kebab/close button belt.
- Those controls must not prevent the user from targeting the underlying tab group while a pane drag is in progress.
### Hovering an existing tab
When the user drags a pane header over a different tab group in the vertical tabs panel, Warp should treat that tab group the same way the horizontal tab strip treats a hovered destination tab today.
That means:
- The hovered tab group is shown as the active drag target.
- The workspace switches to that tab as the drag destination.
- Once that tab is active, the user can continue moving the cursor into the workspace content area and see the existing pane relayout drop targets for that tab.
This should feel like a direct vertical-tabs analogue of the current horizontal behavior, not like a separate drag mode.
### Dropping into the target tab
After the destination tab becomes active, dropping the pane inside the workspace should reuse the existing in-tab pane drop behavior unchanged.
Specifically:
- The same drop-target affordances that already appear when rearranging panes within a tab should appear for the dragged pane in the destination tab.
- The same placement outcomes should apply as they do today in horizontal tabs mode.
- Any existing special-case behavior in the destination tab remains unchanged. For example, if a destination tab's current rules would merge a dragged code pane into an existing code-pane/editor setup instead of allowing arbitrary free placement, vertical tabs should preserve that same result rather than inventing a new one.
### Dragging between tabs to create a new tab
The vertical tabs panel must also support the vertical analogue of "drop between tabs."
When the dragged pane is positioned between two visible tab groups, Warp should show an insertion indicator between those groups. Dropping there creates a new workspace tab containing the dragged pane at that position.
The same applies after the last visible tab group:
- Hovering below the final tab group shows an insertion indicator at the end of the list.
- Dropping there creates a new tab at the end.
This should match the semantics of the current horizontal-tab strip:
- **Over a tab** targets that existing tab.
- **Between tabs** creates a new tab at that position.
### Visual feedback
During a pane drag in vertical tabs mode, the panel should provide clear, mutually exclusive feedback:
- **Over an existing tab**: that tab group is highlighted as the current destination tab.
- **Between tab groups**: show an insertion indicator between groups.
- **No valid tab target**: clear any tab-target highlight or insertion indicator.
At no point should both an existing-tab highlight and a between-tabs insertion indicator be shown at the same time.
### Cancellation and reversibility
The cross-tab drag flow must remain reversible until the drop is committed.
- Moving off a target tab group removes that target state.
- Moving from one tab group to another updates the target accordingly.
- Aborting the drag or dropping outside any valid destination leaves the tab/pane layout unchanged.
### No new behavior outside the intended scope
This ticket does not change how users reorder vertical tabs by dragging the tab groups themselves. It only adds parity for dragging a pane header from one tab into another tab or into a new tab position.
## Success criteria
1. In vertical tabs mode with the panel open, dragging a pane header over a different visible tab group makes that tab group the active drag destination.
2. When a non-active tab group becomes the drag destination, Warp switches the workspace to that tab so the user can place the pane inside it.
3. After switching to the destination tab, the existing pane relayout/drop-target UX appears in the workspace and can be used to place the pane.
4. The placement outcomes inside the destination tab match the existing horizontal-tabs flow; no new placement rules are introduced.
5. Dragging between two visible tab groups shows an insertion indicator and dropping there creates a new tab containing the pane at that position.
6. Dragging below the last visible tab group shows an end-of-list insertion indicator and dropping there creates a new tab at the end.
7. The behavior works in both compact and expanded vertical tabs panel modes.
8. Tabs without a custom title/header are still targetable via their rendered tab-group body.
9. Hover-only controls in the vertical tabs panel do not block or replace drag-target feedback.
10. Cancelling the drag, or dropping outside a valid destination, leaves the tab/pane layout unchanged and clears any temporary targeting UI.
11. Existing vertical-tab reordering behavior is unchanged.
12. Existing within-tab special cases, including code-pane merge behavior where applicable, remain unchanged.
## Validation
- **Existing-tab transfer**: In vertical tabs mode, create two tabs with multiple panes. Drag a pane header from tab A over tab B in the vertical tabs panel. Verify tab B becomes active and the pane can be dropped into a new split location using the normal in-tab drop overlays.
- **Compact mode**: Repeat the same flow with the vertical tabs panel in compact mode.
- **Expanded mode**: Repeat the same flow with the panel in expanded mode.
- **No custom header**: Verify the drag works for a destination tab that has no custom tab title and therefore renders without a separate custom header row.
- **New-tab insertion**: Drag a pane between two tab groups and verify an insertion indicator appears. Drop and confirm a new tab is created at that exact position containing the dragged pane.
- **End insertion**: Drag below the last tab group and verify dropping creates a new final tab.
- **Cancel path**: Start a cross-tab pane drag, hover a target so it highlights, then cancel or drop outside any valid target. Verify no pane move is committed and temporary highlighting clears.
- **Special-case regression check**: Use a scenario where the existing horizontal-tabs flow has a special outcome inside the target tab (for example, a code-pane/editor merge case). Verify vertical tabs preserves the same behavior.
- **Tab reordering regression**: Verify that dragging vertical tab groups themselves still reorders tabs exactly as before.
## Open questions
None.
+276
View File
@@ -0,0 +1,276 @@
# APP-3825: Tech Spec — Vertical Tabs Pane Drag Parity
## Problem
The pane-drag pipeline already supports moving a pane onto the horizontal tab strip and then reusing the existing in-tab relayout flow inside the destination tab. Vertical tabs do not participate in that pipeline today, even though the downstream workspace and pane-group logic is already generic once it receives a `TabBarHoverIndex`.
The missing piece is the vertical tabs panel itself:
- it renders tab groups as draggable items for tab reordering,
- but it does not expose any drop targets for pane-header drags,
- it does not render insertion indicators from `Workspace.hovered_tab_index`,
- and its hover-only action button belt can overlap the tab-group surface during a drag.
As a result, pane-header drags over vertical tabs fall through to `PaneDragDropLocation::Other` instead of producing the existing `OverTab` / `BeforeTab` flow.
## Relevant code
- `app/src/workspace/view/vertical_tabs.rs (710-929)` — vertical tabs panel rendering; `render_vertical_tabs_panel`, `render_groups`, `render_tab_group`
- `app/src/workspace/view/vertical_tabs.rs (968-1083)` — each vertical tab group is currently only a `Draggable` + `SavePosition` for reordering
- `app/src/workspace/view.rs (15291-15328)` — horizontal tab bar wrapper adds a workspace-level `DropTarget` with `TabBarDropTargetData { AfterTabIndex(..) }`
- `app/src/tab.rs (1366-1382)` — each horizontal tab is wrapped in `DropTarget::new(..., TabBarDropTargetData { TabIndex(..) })`
- `app/src/pane_group/pane/view/header/mod.rs (321-352)` — pane-header drag hover classification currently derives `TabBarHoverIndex` from `TabBarLocation` + geometry
- `app/src/pane_group/pane/view/header/mod.rs (939-1088)``render_pane_header_draggable`; pane drags only accept `PaneDropTargetData` and `TabBarDropTargetData`
- `app/src/pane_group/mod.rs (544-561)` — workspace-facing events: `DroppedOnTabBar`, `SwitchTabFocusAndMovePane`, `UpdateHoveredTabIndex`, `ClearHoveredTabIndex`
- `app/src/pane_group/mod.rs (1098-1153)``handle_pane_view_event`; `OverTab` hides/moves panes into the target tab, `BeforeTab` preserves “new tab” behavior
- `app/src/workspace/view.rs (11630-11889)` — workspace handling for cross-tab drag/drop and existing special cases like code-pane merge behavior
- `app/src/workspace/mod.rs (1447-1461)``TabBarDropTargetData` and `TabBarLocation`
- `app/src/workspace/view.rs (14883-14891)` — existing horizontal insertion indicator renderer
## Current state
### Horizontal tabs already provide the full drag channel
The horizontal tab strip exposes `TabBarDropTargetData` in two places:
- each concrete tab via `TabBarLocation::TabIndex(i)`, and
- the strip container via `TabBarLocation::AfterTabIndex(tab_count)`.
Pane-header drags and editor-tab drags both recognize that data. In the pane-header path, `PaneHeader::calculate_tab_focus_hover_index` interprets the dragged rect relative to the target tabs bounds and converts it into:
- `TabBarHoverIndex::OverTab(i)` when the user is targeting an existing tab
- `TabBarHoverIndex::BeforeTab(i)` when the user is inserting between tabs
From there, the rest of the flow is already shared:
- `PaneGroup::handle_pane_view_event` emits `SwitchTabFocusAndMovePane` for `OverTab` and hides the dragged pane for `BeforeTab`
- `Workspace` stores `hovered_tab_index`, switches active tabs when needed, and on drop either
- adds a new tab from the moved pane for `BeforeTab`, or
- reuses the target tabs existing placement rules for `OverTab`
### Vertical tabs only support tab reordering today
`render_tab_group` in `vertical_tabs.rs` currently builds each tab group as:
- a hoverable group surface,
- an optional overlay belt with kebab/close actions,
- a `Draggable` used for vertical tab reordering,
- and a `SavePosition(tab_position_id(tab_index))` used by the reorder math in `Workspace::calculate_updated_tab_index_vertical`.
There is no `DropTarget` around the group and no dedicated insertion targets between groups or after the final group.
Because of that, dragging a pane header over the vertical tabs panel never produces a workspace-tab target. The drag is not accepted by the panel, so the pane-header code falls back to `PaneDragDropLocation::Other`.
### `hovered_tab_index` is only rendered in the horizontal tab strip
`Workspace.hovered_tab_index` already drives two pieces of feedback in the horizontal strip:
- highlighted tab styling for `OverTab`
- insertion bars for `BeforeTab`
The vertical tabs panel does not currently read or render that state at all.
### Overlay controls can interfere with tab-group hit-testing
The vertical tabs group action belt is shown from hover state and rendered as a positioned overlay on top of the group. During a pane drag, that overlay can visually and spatially compete with the tab-group surface unless we explicitly suppress it or ensure the drop target sits above it in hit-testing.
## Proposed changes
### 1. Add explicit vertical-tabs pane drop targets
Introduce a new workspace-level drop target data type for pane-header drags in vertical tabs. Keep it separate from `TabBarDropTargetData` so this ticket stays scoped to pane headers and does not implicitly expand to editor file-tab dragging.
Proposed shape in `app/src/workspace/mod.rs`:
```rust
#[derive(PartialEq, Copy, Clone, Debug)]
pub struct VerticalTabsPaneDropTargetData {
pub tab_bar_location: TabBarLocation,
pub tab_hover_index: TabBarHoverIndex,
}
```
This reuses the existing `TabBarLocation` / `TabBarHoverIndex` semantics instead of inventing a parallel enum.
### 2. Teach pane-header draggables to accept the new vertical target data
Update `render_pane_header_draggable` in `app/src/pane_group/pane/view/header/mod.rs` to accept three drop-target kinds:
- `PaneDropTargetData`
- `TabBarDropTargetData` (existing horizontal strip path)
- `VerticalTabsPaneDropTargetData` (new vertical tabs path)
To keep the existing horizontal logic intact, extend the pane-header drag action so it can optionally carry a precomputed `TabBarHoverIndex`:
```rust
PaneHeaderDragged {
origin: ActionOrigin,
drag_location: PaneDragDropLocation,
drag_position: RectF,
explicit_tab_hover_index: Option<TabBarHoverIndex>,
}
```
Behavior:
- horizontal tab-strip drags keep sending `None` and continue to use `calculate_tab_focus_hover_index`
- vertical tabs send `Some(...)` and bypass geometry inference
That lets vertical tabs use explicit “over tab” vs “between tabs” zones instead of trying to reinterpret a large tab-group card with the horizontal strips x-axis heuristics.
### 3. Wrap vertical tab groups in `OverTab` drop targets
In `render_groups` / `render_tab_group`:
- keep the current `Draggable + SavePosition` structure for tab reordering
- wrap each rendered tab-group element in `DropTarget::new(..., VerticalTabsPaneDropTargetData { ... OverTab(tab_index) ... })`
The `SavePosition(tab_position_id(tab_index))` should remain attached to the group element used for tab reordering, not to any insertion spacer. That preserves the current reorder math in `calculate_updated_tab_index_vertical`.
This gives the entire visible tab-group surface an explicit “existing tab” meaning, which matches the product spec for both compact and expanded modes.
### 4. Add explicit insertion targets between groups and after the final group
Render small, dedicated insertion targets in the vertical list rather than deriving `BeforeTab` from pointer position inside a tab-group card.
Concretely, `render_groups` should render:
- a leading insertion target for `BeforeTab(0)` for full parity with the horizontal strip
- one insertion target before each subsequent tab group
- one trailing insertion target after the final group for `BeforeTab(tab_count)`
Each insertion target is a thin block wrapped in `DropTarget::new(..., VerticalTabsPaneDropTargetData { tab_hover_index: BeforeTab(i), ... })`.
This has two advantages:
- it matches the product behavior more closely than splitting a tab-group card into vertical thirds
- it avoids rewriting the shared pane-group/workspace drag logic, since that logic already understands `BeforeTab`
The trailing target should have enough height to remain practically hittable even when the list fills the panel.
### 5. Render vertical drag feedback from `Workspace.hovered_tab_index`
Add vertical-tabs equivalents of the horizontal tab-strip feedback in `vertical_tabs.rs`:
- when `hovered_tab_index == OverTab(i)`, the corresponding tab group renders as the active drag target
- when `hovered_tab_index == BeforeTab(i)`, render an insertion indicator before that group
- when `hovered_tab_index == BeforeTab(tab_count)`, render the insertion indicator at the end of the list
Implementation-wise:
- thread `workspace.hovered_tab_index` into `render_groups` / `render_tab_group`
- add a helper like `render_vertical_tab_hover_indicator`
- include drag-target highlighting in the group background logic, separate from normal hover and active-tab styling
The current horizontal `render_tab_hover_indicator` is a narrow vertical bar. Vertical tabs should use a horizontal divider-style accent that visually reads as “insert here” in the list layout.
### 6. Suppress the vertical tab-group action belt during pane drags
While any pane is being dragged, do not render the floating kebab/close action belt in `render_tab_group`.
This is the simplest way to avoid overlay interference with drag targeting and keeps the panel visually focused on drop feedback instead of hover controls.
Use the existing `PaneGroup::any_pane_being_dragged(app)` signal from the active pane group, similar to how the workspace already uses it for tab-bar visibility and keymap context.
### 7. Leave pane-group and workspace drop behavior unchanged
Do not redesign the downstream move/drop handlers. The current event chain is already the right abstraction boundary:
- `PaneGroup::handle_pane_view_event`
- `Workspace`s `SwitchTabFocusAndMovePane`
- `Workspace`s `DroppedOnTabBar`
That code already preserves:
- hidden-pane staging while hovering `OverTab`
- “promote to new tab” behavior for `BeforeTab`
- existing target-tab placement rules
- special cases like code-pane merge behavior under tabbed editor view
This ticket should only change how vertical tabs produce `TabBarHoverIndex`, not what happens after that.
## End-to-end flow
1. The user starts dragging a pane header.
2. `render_pane_header_draggable` enters drag mode and accepts either pane drop targets, horizontal tab-strip targets, or the new vertical-tabs pane targets.
3. The user moves over the vertical tabs panel:
- over a tab group → the drop target provides `OverTab(i)`
- over an insertion zone → the drop target provides `BeforeTab(i)`
4. The pane-header view emits `DraggedOverTabBar` with that `TabBarHoverIndex`.
5. `PaneGroup::handle_pane_view_event` reuses the existing behavior:
- `OverTab(i)` → stage the pane in the destination tab via `SwitchTabFocusAndMovePane`
- `BeforeTab(i)` → hide the pane for move and preserve “create a new tab on drop”
6. `Workspace` updates `hovered_tab_index`, which now drives vertical-panel drag feedback.
7. If the target is `OverTab(i)` and a tab switch is needed, `Workspace` activates the target tab and adds the pane as hidden in that tabs pane group.
8. The user moves into the workspace content area; the existing pane relayout drop targets appear and the pane can be placed using the current within-tab logic.
9. On drop:
- `BeforeTab(i)` → existing `DroppedOnTabBar` logic creates a new tab at `i`
- `OverTab(i)` → existing target-tab placement logic runs unchanged
10. Cancelling or leaving valid targets clears `hovered_tab_index` and reverts hidden-pane staging, as it does today.
## Risks and mitigations
### Risk: accidental scope expansion to editor file-tab dragging
If we reused `TabBarDropTargetData` directly in vertical tabs, editor file tabs would likely start targeting vertical tabs too, because `code/view.rs` already recognizes that type.
Mitigation:
- use a separate `VerticalTabsPaneDropTargetData`
- only add it to the pane-header draggable acceptance path
### Risk: overlay hit-testing blocks the drop target
The action button belt is rendered as an overlay on the group and can interfere with drag targeting.
Mitigation:
- suppress the overlay while any pane drag is active
### Risk: breaking vertical tab reordering
Adding wrappers around the group could change the bounds used by `calculate_updated_tab_index_vertical`.
Mitigation:
- keep `SavePosition(tab_position_id(..))` attached to the group element itself
- do not include insertion-target spacers in that saved position
### Risk: feedback mismatch between target state and actual drop behavior
If the vertical panel renders `OverTab` / `BeforeTab` differently from what the workspace eventually does, the interaction will feel inconsistent.
Mitigation:
- reuse `TabBarHoverIndex` end to end
- keep workspace and pane-group drop handlers unchanged
## Testing and validation
### Manual validation
Use the scenarios in `specs/APP-3825/PRODUCT.md`:
- drag a pane from one tab to another in expanded mode
- repeat in compact mode
- verify tabs without custom headers are targetable
- verify insertion between groups and after the last group creates a new tab
- verify cancel / drop-outside clears temporary state
- verify code-pane merge behavior still matches the horizontal flow
- verify vertical tab reordering still works
### Automated validation
This feature is mostly WarpUI drag/drop hit-testing, so manual validation is the primary check. Add lightweight automated coverage where it is cheap and reliable:
- if a pure helper is introduced for vertical drag-target rendering decisions, cover `OverTab` / `BeforeTab` cases in `app/src/workspace/view/vertical_tabs_tests.rs`
- add or update workspace tests only if the new helper can be exercised without full UI drag simulation
No new persistence, networking, or model migration coverage is needed.
## Follow-ups
- If we later want vertical tabs to support editor file-tab dragging as well, we can either:
- teach `code/view.rs` to accept `VerticalTabsPaneDropTargetData`, or
- unify the horizontal and vertical tab-target metadata behind a shared explicit-hover-index type
- If the vertical insertion indicator and the horizontal tab-strip indicator should share styling, we can extract a small shared helper after this behavior is stable
+197
View File
@@ -0,0 +1,197 @@
# APP-3828: Vertical Tabs v2 — View as Panes / Tabs
## Summary
Add a new `View as` control to the vertical tabs display options popup with two modes:
- **Panes**: the current behavior, where each pane is rendered as its own item under its tab.
- **Tabs**: a new overview mode where each tab renders exactly one item, using that tabs active pane as the representative row.
This first iteration only ships the `View as` toggle and the `Focused session` behavior implicitly. It does not yet add a separate Tabs-only naming control such as `Summary`.
## Problem
The current vertical tabs panel is pane-centric. That works well when a user wants fine-grained visibility into every split, but it becomes noisy when tabs contain multiple panes and the user is trying to scan the workspace at the tab level.
Users need a higher-level overview mode that reduces each tab to a single representative item without introducing a brand-new visual language. The new mode should preserve the current tab structure and reuse the existing row UI so the first iteration is easy to understand and low-risk to ship.
## Goals
- Add a new `View as` setting in the vertical tabs popup with `Panes` and `Tabs` options.
- Preserve the current behavior as the default via `View as = Panes`.
- Introduce `View as = Tabs`, where each tab renders one representative row derived from that tabs active pane.
- Reuse the existing compact and expanded pane row UI for the representative row rather than inventing a new tab row design.
- Keep the existing tab group/header structure and interactions intact in Tabs mode.
- Persist the `View as` preference across sessions as a synced setting.
## Non-goals
- Shipping the future Tabs-only `Default name` section from the exploratory mock.
- Shipping a `Summary` naming mode or any other alternative tab naming strategy.
- Flattening the panel into a headerless list of tabs.
- Redesigning tab group headers, close affordances, rename behavior, or drag-and-drop behavior.
- Changing the current `Density`, `Pane title as`, `Additional metadata`, or `Show` controls beyond ensuring they continue to work with the reused active-pane row.
## Figma / design references
- Popup exploration: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7628-119835&t=LSuxL7FNk3EXOfvJ-0
- Tabs-selected popup state: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7628-123042&t=LSuxL7FNk3EXOfvJ-0
### Intentional deviation from the exploratory mock
The exploratory Tabs-selected popup includes a Tabs-only `Default name` section with `Focused session` and `Summary`.
This iteration intentionally does **not** ship that extra section. `Focused session` is the only supported Tabs behavior and is implicit when `View as = Tabs`.
## User experience
### Setting
The vertical tabs popup gains a new top-level setting:
- **View as: Panes**
- **View as: Tabs**
`Panes` is the default.
The control is rendered as a two-segment toggle at the top of the popup, above the existing display controls.
### Popup behavior
- Clicking `Panes` or `Tabs` updates the panel immediately.
- The popup remains open after switching modes so the user can see the change in context.
- The existing `Density` and other pane-row display controls remain present in this iteration.
- Those pane-row controls remain accessible in both `Panes` and `Tabs` modes, because the `Tabs` row is still a pane-style row derived from the active pane.
- Switching between `Panes` and `Tabs` does not reset the current density or any existing pane-row display preferences.
### Panes mode
When `View as = Panes`, the panel behaves exactly as it does today:
- each visible pane is rendered as its own item
- items are grouped under their tab header
- compact vs expanded density works as it does today
- existing pane-row display preferences continue to apply as they do today
No visual or behavioral change should be introduced in Panes mode beyond the existence of the new `View as` control in the popup.
### Tabs mode
When `View as = Tabs`, the panel stays tab-grouped, but each tab group renders exactly one representative row instead of one row per visible pane.
#### Representative row source
The representative row is always derived from the tabs **active pane**.
In this iteration, `View as = Tabs` therefore means the tab item is effectively named and styled using the tabs **focused session**.
#### Representative row appearance
The representative row reuses the existing pane-row UI for the active pane:
- in **compact density**, it uses the same compact row renderer the active pane would use in Panes mode
- in **expanded density**, it uses the same expanded row renderer the active pane would use in Panes mode
This includes the same icon rules, title rules, subtitle/metadata rules, badges, truncation behavior, and selection styling that already apply to the active panes row in Panes mode.
Because the representative row is still a pane-style row, the existing pane-row display controls continue to apply in `Tabs` mode as well. In this iteration that means:
- `Pane title as` still changes how a terminal representative row is labeled
- `Additional metadata` still affects compact terminal representative rows
- `Show` still affects expanded terminal representative rows
These controls are not hidden when `View as = Tabs`.
#### Representative row updates
The representative row updates immediately whenever the active pane for that tab changes. Examples:
- the user changes focus between split panes inside the tab
- the active pane is closed and a different pane becomes active
- the active panes displayed metadata changes (for example, terminal title, branch, badges, or unsaved state)
Tabs mode should always reflect the tabs current active pane, not the pane that happened to be active when the user first switched into Tabs mode.
#### Relationship to tab headers
Tabs mode does **not** remove or redesign the existing tab group header.
The current tab group header behavior remains intact, including:
- tab title display
- pane count display
- rename behavior
- close behavior
- drag-and-drop behavior
- existing header context menu behavior
The change in Tabs mode is only the number of rows rendered beneath each header: one representative row per tab instead of one row per pane.
#### Single-pane tabs
For tabs that only contain one visible pane, Tabs mode and Panes mode look effectively the same below the header, because the active pane is also the only pane.
#### Multi-pane tabs
For tabs that contain multiple visible panes:
- **Panes mode** renders one item per visible pane
- **Tabs mode** renders one item total for that tab, based on the active pane only
Non-active panes in the tab do not get their own rows in Tabs mode.
### Interaction behavior in Tabs mode
The representative row remains actionable in the same spirit as the active pane row it reuses:
- clicking the row activates that tab and focuses its active pane
- selection/highlight state continues to represent the active tab / focused pane as it does today
This iteration should not introduce new row-specific interactions unique to Tabs mode.
### Search behavior
Search/filtering operates on the items currently rendered in the chosen mode.
That means:
- in **Panes mode**, matching remains pane-based as it is today
- in **Tabs mode**, matching is based on each tabs representative row only
In this first iteration, a non-active pane that is hidden by Tabs mode does not create its own separate match result.
## Success criteria
1. The display options popup shows a new top-level `View as` segmented control with `Panes` and `Tabs`.
2. `Panes` is selected by default, so existing users see no change in the panel until they opt into `Tabs`.
3. Switching to `Tabs` updates the panel immediately without requiring the popup to close.
4. In `Tabs` mode, each tab group renders exactly one row beneath its header.
5. The row shown for a tab in `Tabs` mode is derived from that tabs current active pane.
6. If the active pane changes within a tab, the representative row updates immediately to reflect the newly active pane.
7. A tab with only one visible pane looks the same in `Panes` and `Tabs` modes below the header.
8. A tab with multiple visible panes shows multiple rows in `Panes` mode and exactly one row in `Tabs` mode.
9. The representative row in `Tabs` mode reuses the same compact or expanded row UI, icons, metadata, badges, and truncation rules the active pane already uses in `Panes` mode.
10. Existing tab header behavior remains unchanged in `Tabs` mode, including pane count, close, rename, and drag behavior.
11. Existing `Density` and pane-row display preferences continue to apply after switching between `Panes` and `Tabs`.
12. `Pane title as`, `Additional metadata`, and `Show` remain visible and usable in `Tabs` mode, and they continue to affect the representative row.
13. The `View as` preference persists across app relaunches as a synced setting.
14. Tabs mode does not surface `Summary` or any other alternate naming mode in this iteration.
15. In search/filter mode, Tabs mode returns matches for representative rows only, not hidden non-active panes.
## Validation
- **Popup toggle**: Open the display options popup and verify `View as` appears above the existing controls. Toggle between `Panes` and `Tabs` and verify the panel updates immediately while the popup remains open.
- **Default behavior**: With the default setting, verify the panel still renders one row per visible pane exactly as before.
- **Single-pane tab**: Open a tab with one pane, switch between `Panes` and `Tabs`, and verify there is no meaningful change below the header.
- **Multi-pane tab**: Create a tab with multiple split panes. Verify `Panes` shows all pane rows and `Tabs` shows exactly one row for that tab.
- **Active pane switching**: In a multi-pane tab, switch focus between panes and verify the representative row in `Tabs` mode updates to match the newly active pane.
- **Density coverage**: Verify the representative row works in both compact and expanded density modes.
- **Row parity**: For a given active pane, compare its appearance in `Panes` mode vs `Tabs` mode and verify the row content matches.
- **Pane-row controls in Tabs mode**: With `View as = Tabs`, change `Pane title as`, `Additional metadata`, and `Show`, and verify they still affect the representative row rather than disappearing.
- **Header regression**: In `Tabs` mode, verify header rename, close, pane count, drag behavior, and context menu behavior still work.
- **Persistence**: Select `Tabs`, relaunch Warp, and verify the panel reopens in `Tabs` mode.
- **Search**: In a multi-pane tab, ensure only the active panes representative row is matched and rendered in `Tabs` mode.
## Open questions
None for this iteration.
+328
View File
@@ -0,0 +1,328 @@
# 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:
- `Panes` preserves the current pane-centric rendering
- `Tabs` renders one representative row per tab
- the representative row is the existing row UI for that tabs 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 feature
- `app/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 actions
- `app/src/workspace/action.rs (740-741)``should_save_app_state_on_action` coverage for the current vertical-tabs setting actions
- `app/src/workspace/view.rs (17761-17810)` — workspace-side action handlers that update `TabSettings`
- `app/src/workspace/view/vertical_tabs.rs (246-344)``VerticalTabsPanelState` and popup mouse-state ownership
- `app/src/workspace/view/vertical_tabs.rs (458-483)``matching_tab_indices`, which currently assumes every visible pane can make a tab searchable
- `app/src/workspace/view/vertical_tabs.rs (630-799)``render_groups`, including current search filtering behavior
- `app/src/workspace/view/vertical_tabs.rs (800-1034)``render_tab_group`, which currently renders one row per visible pane
- `app/src/workspace/view/vertical_tabs.rs (1569-1664)``PaneProps::new` and query matching helpers used by render/search
- `app/src/workspace/view/vertical_tabs.rs (2380-2664)``render_settings_popup`, including the current top-of-popup density segmented control and the existing secondary controls
- `app/src/workspace/view/vertical_tabs.rs (2845-3033)``render_compact_pane_row`; compact density reuses `PaneProps`
- `app/src/workspace/view/vertical_tabs.rs (1160-1250)``render_pane_row`; expanded density reuses `PaneProps`
- `app/src/pane_group/mod.rs:1981``PaneGroup::focused_pane_id`, the current source of truth for the pane last focused within a tab
- `app/src/pane_group/mod.rs (4566-4571)``PaneGroup::display_title`, which already derives tab-level display state from the focused pane
- `app/src/workspace/view/vertical_tabs_tests.rs (1-196)` — current unit-test home for vertical-tabs pure logic
- `app/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_action` writes the new value into `TabSettings`
- `should_save_app_state_on_action` returns `false`, because persistence is handled by the settings framework rather than workspace snapshotting
This pattern currently exists for:
- `VerticalTabsViewMode` = compact vs expanded density
- `VerticalTabsPrimaryInfo`
- `VerticalTabsCompactSubtitle`
### 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_row`
- `render_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_indices` decides which tabs are included in keyboard navigation / search result bookkeeping
- `render_groups` computes `matching_ids` to 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`:
```rust path=null start=null
#[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::ALL`
- `SyncToCloud::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 `MouseStateHandle`s to `VerticalTabsPanelState` for the new control:
- one for the `Panes` segment
- one for the `Tabs` segment
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:
1. `View as` header
2. text segmented control for `Panes` / `Tabs`
3. divider
4. `Density` header
5. existing compact / expanded icon segmented control
6. 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 `Panes` or `Tabs`, matching the existing “update in place” behavior of the other controls
- do not hide `Pane title as`, `Additional metadata`, or `Show` when `Tabs` is 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 metadata` only in compact, `Show` only 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:
```rust path=null start=null
fn pane_ids_for_display_granularity(
visible_pane_ids: &[PaneId],
focused_pane_id: PaneId,
granularity: VerticalTabsDisplayGranularity,
) -> Vec<PaneId>
```
Behavior:
- `Panes` returns all visible pane ids in existing order
- `Tabs` returns exactly one pane id:
- `focused_pane_id` if it is present in `visible_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_row`
- `render_compact_pane_row`
- `render_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
1. The user opens the vertical-tabs popup from the settings icon.
2. `render_settings_popup` reads `vertical_tabs_display_granularity` and shows `Panes` selected by default.
3. The user clicks `Tabs`.
4. `WorkspaceAction::SetVerticalTabsDisplayGranularity(Tabs)` is dispatched.
5. `Workspace::handle_action` writes the synced setting through `TabSettings` and calls `ctx.notify()`.
6. On re-render, `render_groups` and `render_tab_group` both read the new setting.
7. 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`)
8. That representative pane is passed through `PaneProps::new` and then through the existing compact / expanded row renderer.
9. Clicking the representative row still dispatches `FocusPane`, which activates the tab and focuses that pane.
10. If the user changes focus within a split tab, `PaneGroup::focus_pane` updates `focused_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 `VerticalTabsViewMode` to mean density, while the product copy now uses `View as` to mean pane-vs-tab granularity
Mitigation:
- introduce a separately named enum (`VerticalTabsDisplayGranularity`) instead of overloading `VerticalTabsViewMode`
- 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`, and `render_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 as` control only
- do not add a Tabs-only `Default name` or `Summary` section in this implementation
- do not hide the existing pane-row controls when `Tabs` is 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(...)`
- `Panes` returns all visible panes in order
- `Tabs` returns the focused pane when present
- `Tabs` falls 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 as` first and `Density` above the existing compact / expanded toggle
- verify `Panes` remains default and current behavior is unchanged
- verify a multi-pane tab shows one representative row in `Tabs` mode
- verify switching focus within a split tab changes the representative row
- verify compact and expanded densities both work in `Tabs` mode
- verify existing `Pane title as`, `Additional metadata`, and `Show` preferences remain visible in the popup in `Tabs` mode and still affect the representative row as expected
- verify search in `Tabs` mode only matches the representative row, not hidden non-active panes
- verify the setting persists across relaunch
## Follow-ups
- Rename `VerticalTabsViewMode` to 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 session` vs `Summary`) 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 for `Tabs`.
+216
View File
@@ -0,0 +1,216 @@
# APP-3832: Vertical Tabs v2 — Hover Detail Sidecar
## Summary
Add a hover-activated detail sidecar to the vertical tabs panel that shows full, un-elided information for the currently hovered item without changing focus.
- When `View as = Panes`, hovering an eligible pane row shows a single pane-scoped sidecar for that pane.
- When `View as = Tabs`, hovering an eligible tab representative row shows a tab-scoped sidecar composed of one pane-scoped section per visible pane in that tab.
In this first iteration, the sidecar supports terminal / agent terminal panes, code panes, and supported Warp Drive object panes. It is hover-only; keyboard focus and selection do not open it.
## Problem
The vertical tabs panel intentionally compresses information so it stays scannable. That compression creates two gaps:
- even in `View as = Panes`, important metadata is clipped or omitted in the row itself
- in `View as = Tabs`, non-active panes disappear from the panel entirely, so the user cannot quickly inspect the rest of a split tab without focusing it
Users need a way to inspect full pane detail from the panel itself without changing focus, opening the tab, or losing the higher-level overview that `View as = Tabs` provides.
## Goals
- Show full, un-elided detail for the currently hovered vertical-tabs item.
- Preserve the users current focus; hover detail must not activate tabs or panes just by appearing.
- Make `View as = Tabs` inspectable by exposing all visible panes in the hovered tab.
- Reuse a single pane-scoped detail pattern in both modes so the UI feels consistent.
- Keep the vertical tabs panel layout stable; the detail view should not resize the panel or the workspace.
## Non-goals
- Opening the sidecar from keyboard focus, keyboard navigation, or selection state.
- Adding support for the remaining pane types that still do not render a sidecar in this iteration.
- Changing the existing `View as`, `Density`, `Pane title as`, `Additional metadata`, or `Show` settings.
- Adding new tab naming or tab summarization behavior.
- Turning the sidecar into a general-purpose inspector with editable controls.
## Figma / design references
- Tabs-scoped sidecar mock: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7633-130645&t=LSuxL7FNk3EXOfvJ-0
- Pane-scoped sidecar mock: https://www.figma.com/design/n5d1rK2dMGKqf97XDBN1BV/Agents-mgmt?node-id=7029-53964&m=dev
There is no separate code-pane mock. In this iteration, code panes reuse the same pane-scoped sidecar shell and follow the code-specific content rules defined below.
## User experience
### General behavior
- The detail sidecar is a floating overlay anchored to the right side of the vertical tabs panel.
- It does not resize the vertical tabs panel or the main workspace content.
- Only one detail sidecar is shown at a time.
- The sidecar opens only from pointer hover over an eligible item.
- Keyboard focus, selected state, and programmatic pane activation do not open the sidecar on their own.
- Hovering a different eligible item updates the sidecar to the newly hovered item.
- Moving the pointer from the hovered item into the sidecar keeps the sidecar open; it must not flicker closed during that cursor transition.
- The sidecar closes when the pointer is no longer over either the source item or the sidecar.
### Eligibility
In this first iteration, the sidecar is supported for:
- plain terminal panes
- Oz agent terminal panes
- CLI agent terminal panes
- code panes
- notebooks and plans
- workflows
- environment variable collections
- rules
- MCP servers
The sidecar is not shown for the remaining pane types in this iteration.
### Panes mode
When `View as = Panes`, hovering an eligible pane row opens a pane-scoped sidecar for that exact pane.
- The sidecar contains a single pane-scoped section.
- There is no extra tab-level wrapper, heading, or pane-count summary.
- Hovering an unsupported pane type shows no sidecar.
### Tabs mode
When `View as = Tabs`, hovering an eligible tab representative row opens a tab-scoped sidecar for that tab.
- The sidecar contains one pane-scoped section per visible pane in the hovered tab.
- Sections appear in the same top-to-bottom pane order the tab uses internally; the sidecar must not reorder panes just because one is active.
- Each section uses the same pane-scoped layout as the single-pane sidecar from `View as = Panes`.
- The tab-scoped sidecar is purely a stacked collection of pane sections; it does not introduce a separate tab summary header in this iteration.
Because this first iteration only supports the pane types listed above, a tab-scoped sidecar is shown only when every visible pane in the hovered tab is one of those supported types. Tabs containing any other visible pane show no sidecar in this iteration.
### Sidecar shell and sizing
- The sidecar uses a fixed-width card-like container matching the Figma mocks.
- The sidecar has a bounded maximum height based on the available window height; it must not grow off-screen just because a hovered tab contains many panes.
- When the sidecar content exceeds that bounded height, the sidecar content area scrolls vertically within the card.
- The vertical tabs panel itself does not scroll as a side effect of interacting with the sidecar; overflow is handled inside the sidecar.
- Text inside the sidecar wraps as needed; it is not ellipsized just because the corresponding row in the panel is clipped.
- In tabs mode, pane sections are separated by dividers.
- In panes mode, the single pane section uses the same shell but without internal section dividers.
### Relationship to existing display settings
The sidecar is a fixed detail view, not a larger version of the row.
- `Density` does not change the sidecar layout.
- `Pane title as` does not reorder the sidecar fields.
- `Additional metadata` does not hide or swap sidecar fields.
- `Show` does not remove sidecar badges that are part of the fixed detail layout.
The sidecars job is to expose the panes full supported detail, even when the row is configured to prioritize different information.
### Terminal and agent pane sections
Terminal and agent panes use the pane-scoped layout shown in the mocks.
#### Content order
From top to bottom, a terminal / agent pane section shows:
1. An optional agent-status pill when the pane is an agent session and a conversation status is available (for example `Working` or `Done`).
2. The full working directory, if available.
3. A git-branch row with branch icon and full branch name, if available.
4. The full command / conversation text as the primary descriptive body text.
5. A metadata row containing the pane kind badge on the left and any supported badges on the right.
#### Content rules
- Plain terminal panes omit the status pill.
- Agent panes show the status pill above the directory / branch block.
- The command / conversation text uses the same identity precedence as the rows terminal title logic, but without clipping:
- agent conversation title when present
- CLI agent title when present
- terminal title when present
- last completed command text when needed
- final fallback text such as `New session`
- The working directory and branch are shown as separate rows when available; missing values are omitted rather than replaced with placeholder text.
- The metadata row always includes the pane kind badge (`Terminal`, `Oz`, `Claude Code`, etc.).
- If diff stats are available, show the diff-stats badge in the metadata row.
- If a pull request link is available, show the PR badge in the metadata row.
#### Interaction rules
- The diff-stats badge keeps its current behavior: clicking it opens the code review flow for that pane.
- The PR badge keeps its current behavior: clicking it opens the PR link.
- Clicking empty space inside the pane section does not focus the pane or tab.
### Code pane sections
Code panes use the same pane-scoped shell, but with code-specific content.
#### Content order
From top to bottom, a code pane section shows:
1. The full active file name as the primary line.
2. The full parent directory / path for that file.
3. An optional additional line when the pane has multiple open files, using the same underlying multi-file summary concept as the vertical-tabs row (for example `and N more` when relevant).
4. A metadata row containing the `Code` kind badge and any supported dirty-state indication.
#### Content rules
- Code pane text is never intentionally ellipsized inside the sidecar; wrap instead.
- If the pane has unsaved changes, surface that dirty state in the section using the existing unsaved indicator treatment.
- Do not show clean-state filler text such as `No unsaved changes`.
- Terminal-specific content such as status pills, git branch, diff stats, and PR badges is not shown for code panes in this iteration.
### Selection and focus behavior
- Hovering an item opens the sidecar without changing which pane or tab is focused.
- Clicking the original row keeps its existing behavior; the presence of the sidecar does not change row activation semantics.
- In tabs mode, the sidecar may show sections for non-focused panes in the hovered tab, but it does not change which pane is active.
### Empty and unsupported states
- If a supported pane is missing some metadata, omit the missing row rather than showing placeholder copy.
- If the hovered item is unsupported, show no sidecar.
- If a hovered tab in `View as = Tabs` contains any unsupported visible pane, show no sidecar for that tab in this iteration.
## Success criteria
1. Hovering an eligible item in the vertical tabs panel shows a floating detail sidecar without changing focus.
2. The sidecar is hover-only; keyboard focus or selection alone does not open it.
3. In `View as = Panes`, hovering an eligible pane row shows exactly one pane-scoped section.
4. In `View as = Tabs`, hovering an eligible representative row shows one pane-scoped section per visible pane in that tab.
5. The tab-scoped sidecar preserves the tabs pane order rather than reordering sections around the active pane.
6. The sidecar stays open while the pointer moves from the source item into the sidecar and closes only after the pointer leaves both regions.
7. Terminal and agent pane sections show full, un-elided directory, branch, command / conversation text, and metadata badges when those fields exist.
8. Agent pane sections show a status pill when conversation status is available; plain terminal sections do not.
9. Code pane sections show full file and path information, plus dirty-state information when applicable, without terminal-specific metadata.
10. Supported Warp Drive object panes show their full title and pane-kind metadata in the sidecar.
11. `Density`, `Pane title as`, `Additional metadata`, and `Show` do not rearrange or remove the sidecars fixed detail layout.
12. Hovering an unsupported pane type shows no sidecar.
13. In `View as = Tabs`, a tab containing any unsupported visible pane shows no sidecar in this iteration.
14. Clicking the diff-stats or PR badge inside a terminal / agent sidecar section preserves the badges existing action.
15. The sidecar never resizes the vertical tabs panel or main workspace content.
16. When a tab-scoped sidecar is taller than the available window space, the sidecar remains bounded in height and becomes internally scrollable instead of extending off-screen.
## Validation
- **Panes mode / terminal**: Hover a plain terminal row and verify a single pane-scoped sidecar appears with full working directory, branch, command text, kind badge, and any available diff / PR badges.
- **Panes mode / agent**: Hover an Oz or CLI agent row and verify the sidecar shows the status pill, full conversation text, and terminal metadata without clipping.
- **Panes mode / code**: Hover a code row and verify the sidecar shows the full filename and path, plus dirty-state indication when applicable.
- **Panes mode / Warp Drive object**: Hover a supported notebook, plan, workflow, environment-variable collection, rule, or MCP server row and verify the sidecar shows the full title with the correct kind badge.
- **Tabs mode / multi-pane tab**: Hover a tab representative row for a tab with multiple supported panes and verify the sidecar shows one section per visible pane in the same pane order as the tab.
- **Large multi-pane tab**: Hover a representative row for a tab with enough supported panes to exceed the available vertical space and verify the sidecar stays bounded and scrolls internally.
- **Focus preservation**: Hover items and verify the currently focused pane does not change until the user explicitly clicks a row or an existing interactive badge.
- **Cursor transition**: Move the pointer diagonally from a row into the sidecar and verify the sidecar does not flicker closed.
- **Unsupported panes**: Hover an unsupported pane type in `View as = Panes` and verify no sidecar appears.
- **Mixed-type tab**: Hover a tab in `View as = Tabs` that contains at least one unsupported visible pane and verify no sidecar appears.
- **Settings independence**: Change `Density`, `Pane title as`, `Additional metadata`, and `Show`, then hover the same item and verify the sidecar layout and field order remain fixed.
- **Badge actions**: In a terminal / agent sidecar, click the diff-stats badge and PR badge and verify they perform the same actions they do from the row.
## Open questions
None for this iteration.
+410
View File
@@ -0,0 +1,410 @@
# 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 rows 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 rows pane id
- the rows 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 rows 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.
+205
View File
@@ -0,0 +1,205 @@
# APP-3864: Tech Spec — Vertical Tabs Detail Sidecar Stale Visibility Fix
## Problem
The vertical tabs detail sidecar can remain visible after the pointer has effectively left both the source row and the sidecar.
One reproducible path is:
1. Hover a pane row so the detail sidecar appears.
2. Move into the sidecar scrollbar gutter.
3. Move vertically within the gutter.
4. Move farther right so the pointer leaves the sidecar and vertical tabs panel.
Observed behavior:
- the sidecar stays visible
- visibility is not corrected until a later mouse event re-enters the vertical tabs area
Expected behavior:
- the sidecar should hide as soon as the pointer is no longer over the source row, over the sidecar, or in the safe-triangle transit path between them
## Relevant code
- `app/src/workspace/view/vertical_tabs.rs:377``render_pane_row_element`, where row hover updates `detail_overlay_state.active_target`
- `app/src/workspace/view/vertical_tabs.rs:624``VerticalTabsDetailHoverState` and panel-local detail-sidecar state
- `app/src/workspace/view/vertical_tabs.rs:4189``render_detail_sidecar`, which renders the overlay from `active_target`
- `app/src/workspace/view.rs:19774` — workspace-root rendering path that hosts the detail sidecar overlay
- `app/src/workspace/view.rs:20406` — workspace-wide `EventHandler` wrapper around the rendered workspace tree
- `app/src/safe_triangle.rs:1` — safe-triangle logic used to keep hover sidecars stable while the pointer moves toward them
- `crates/warpui_core/src/elements/hoverable.rs:405` — current per-element hover state transitions and coverage behavior
- `app/src/workspace/view/vertical_tabs_tests.rs:1` — helper test coverage for vertical-tabs sidecar logic
## Current state
The current implementation uses two local hover-driven mechanisms:
- row hover callbacks set and clear `detail_overlay_state.active_target`
- a sidecar-local `Hoverable` and `MouseStateHandle` are used to keep the sidecar open while hovered and to clear it on sidecar hover-out
This works for normal pointer movement from a row into the sidecar, especially when the safe triangle suppresses intermediate row hover changes.
It breaks down when pointer movement passes through a region that is visually part of the sidecar flow but does not produce the expected hover transitions on either the row or the sidecar root. The scrollbar gutter is one example. In these cases, `active_target` becomes stale and the overlay continues rendering even though the pointer is no longer in any region that should keep it visible.
The key problem is that visibility currently depends on element-local hover state instead of a direct geometry check against the current pointer position.
## Proposed changes
### 1. Add a pure visibility-reconciliation helper
Add a helper in `app/src/workspace/view/vertical_tabs.rs` that determines whether the sidecar should remain visible for a given pointer position.
Inputs:
- current mouse position
- source-row rect
- sidecar rect
- mutable `SafeTriangle`
Behavior:
- keep visible if the pointer is inside the source row
- keep visible if the pointer is inside the sidecar bounds
- keep visible if the pointer is moving through the safe triangle toward the sidecar
- otherwise report that the sidecar should be cleared
This helper should be pure with respect to vertical-tabs state other than the safe-triangle updates, so it can be tested directly in `vertical_tabs_tests.rs`.
### 2. Add a reconciliation method on detail hover state
Add a method on `VerticalTabsDetailHoverState` that:
- reads the current `active_target`
- resolves the saved source-row position id for that target
- reads the saved source-row rect and sidecar rect from the last frame
- calls the new helper
- clears `active_target` and the safe-triangle target rect when the helper says the sidecar is no longer valid
When clearing, also reset the sidecar mouse interaction state so stale hover state does not keep the overlay alive across subsequent renders.
### 3. Reconcile from the workspace root on mouse moves
Keep the existing row and sidecar hover callbacks, but add a workspace-root mouse-move reconciliation path in `app/src/workspace/view.rs`.
The root `EventHandler` that wraps the workspace tree is the right place for this because it can observe real mouse moves even when:
- the pointer is over a covered child region
- the pointer has left the vertical tabs panel but is still inside the workspace window
- the sidecar's own hover state is stale
Implementation shape:
- only enable the reconciliation path when vertical tabs are enabled and the panel is open
- attach a mouse-move callback at the workspace root with `fire_when_covered: true`
- invoke the new `VerticalTabsDetailHoverState` reconciliation method from that callback
- call `ctx.notify()` only when reconciliation actually clears stale state
This makes the workspace root the final source of truth for sidecar visibility without replacing the existing local hover behavior.
### 4. Preserve the current hover model and safe triangle
Do not replace the row/sidecar hover callbacks with action-driven state or a new overlay architecture.
The current design is still correct for:
- opening the sidecar from row hover
- maintaining the sidecar during normal row-to-sidecar movement
- tracking supported vs unsupported targets
The new reconciliation logic is a correctness backstop for stale-state paths, not a redesign of the feature.
### 5. Be conservative when the sidecar rect is unavailable
The sidecar rect comes from the previous frame via `SavePosition`, so there is a brief window during initial appearance where no sidecar rect exists yet.
The reconciliation logic should avoid aggressively clearing in that state. Until a valid sidecar rect exists, the system should continue to rely on the existing row-hover behavior rather than assume the sidecar has already been left.
## End-to-end flow
1. Hovering a supported vertical-tabs row sets `detail_overlay_state.active_target`.
2. The workspace renders the detail sidecar overlay anchored to that row.
3. On each real mouse move within the workspace, the workspace-root event handler invokes detail-sidecar reconciliation.
4. Reconciliation checks whether the pointer is still in the source row, inside the sidecar bounds, or in the safe-triangle transit path.
5. If one of those conditions is true, the sidecar remains visible.
6. If none of them is true, reconciliation clears `active_target`, clears the safe-triangle target rect, resets sidecar hover interaction state, and requests a redraw.
7. The next render no longer includes the sidecar overlay.
## Risks and mitigations
### Over-clearing during initial sidecar appearance
Risk:
- the sidecar rect is unavailable on the first frame, so geometry-based reconciliation could hide the sidecar too early
Mitigation:
- make reconciliation conservative until the sidecar has a valid saved rect
### Interfering with unrelated workspace hover behavior
Risk:
- adding mouse-move logic at the workspace root could create unintended coupling with other overlays
Mitigation:
- gate the logic narrowly to the vertical-tabs detail-sidecar state
- do nothing unless vertical tabs are enabled, the panel is open, and `active_target` is set
### Stale mouse state after clearing
Risk:
- even after `active_target` is cleared, cached sidecar hover state could persist and create inconsistent behavior on the next render
Mitigation:
- reset sidecar interaction state when clearing stale visibility
## Testing and validation
### Unit tests
Add tests in `app/src/workspace/view/vertical_tabs_tests.rs` for the new geometry helper:
- returns true when the pointer is inside the source row
- returns true when the pointer is inside the sidecar bounds
- returns true when the pointer is still inside the safe triangle
- returns false when the pointer is outside the row, outside the sidecar, and outside the safe triangle
These tests should be independent of the full workspace render path and should focus on the geometry and safe-triangle behavior directly.
### Build and targeted validation
- run targeted tests covering `vertical_tabs_tests`
- run a compile check for the affected crate
### Manual validation
- reproduce the scrollbar-gutter path and verify the sidecar hides immediately after the pointer leaves the valid hover region
- verify normal diagonal movement from a row into the sidecar still keeps the sidecar open
- verify the sidecar still hides normally when moving from the row to unrelated workspace content
- verify no regressions in tabs-mode vs panes-mode sidecar behavior
## Generalization
This fix is likely reusable for other hover-driven sidecar UI, but only at the interaction-primitive layer.
The generalizable pattern is:
- anchor an overlay to a source element via saved geometry
- keep it open while the pointer is over the source, over the overlay, or moving through a safe-triangle corridor between them
- reconcile visibility from a higher-level mouse-move observer instead of depending exclusively on element-local hover state
That pattern is a good fit for hover sidecars, submenu-adjacent overlays, and other floating UI that contains covered child regions such as scrollbars or nested interactive elements.
It is not a good fit for click-triggered popovers, focus-driven overlays, or panels whose lifetime is determined by selection rather than pointer geometry.
For now, this change should stay local to vertical tabs. If similar bugs appear in other sidecar-style UI, the first extraction should be a small geometry/state helper rather than a fully shared overlay component.
## Follow-ups
- if similar stale-visibility bugs appear in other hover sidecars, consider extracting a reusable geometry-based sidecar visibility helper
- if workspace-root mouse reconciliation becomes a recurring pattern, evaluate a shared overlay lifecycle utility instead of keeping this logic local to vertical tabs
+82
View File
@@ -0,0 +1,82 @@
# APP-3870: Vertical Tabs — Show Details on Hover toggle
## Summary
Add a new vertical-tabs display option, `Show details on hover`, that lets users enable or disable the hover detail sidecar.
The toggle lives in the vertical tabs display options menu and defaults to enabled so existing users keep the current behavior unless they explicitly turn it off.
## Problem
The hover detail sidecar is useful when users want richer metadata without changing focus, but always-on hover detail can also feel noisy or distracting for users who prefer a simpler vertical-tabs panel.
Once the sidecar exists, users need a straightforward way to opt out of hover-triggered detail without disabling vertical tabs or changing unrelated display settings.
## Goals
- Let users turn the vertical-tabs hover detail sidecar on or off from the existing display options menu.
- Preserve current behavior for existing users by defaulting the setting to enabled.
- Make the toggle apply immediately without requiring a restart or tab reload.
- Keep the toggle scoped specifically to hover-driven detail sidecar behavior.
- Ensure the setting is persisted like other vertical-tabs display preferences.
## Non-goals
- Changing the content or layout of the detail sidecar itself.
- Changing sidecar eligibility rules for supported and unsupported pane types.
- Adding keyboard-only ways to open the sidecar.
- Reworking other display options such as `View as`, `Density`, `Pane title as`, `Additional metadata`, `PR link`, or `Diff stats`.
- Adding per-workspace or per-tab overrides for the setting.
## Figma / design references
Figma: none provided
## User experience
### Menu placement and labeling
- The vertical tabs display options menu includes a new untitled section separated from the items above by a divider.
- That section contains a single toggle row labeled `Show details on hover`.
- The row uses the same selected / unselected visual treatment as the other menu toggles in this popup.
### Default behavior
- `Show details on hover` defaults to enabled.
- For users who have never changed the setting, hover behavior matches the current sidecar experience.
- The setting persists as part of the users vertical-tabs display preferences.
### Enabled state
- When the toggle is enabled, hovering an eligible vertical-tabs row can open the detail sidecar exactly as it does today.
- Existing hover behavior remains unchanged, including:
- supported-pane eligibility rules
- panes-vs-tabs behavior
- safe-triangle behavior while moving the cursor from the row into the sidecar
- existing sidecar interactions such as clickable PR and diff badges
### Disabled state
- When the toggle is disabled, hovering vertical-tabs rows does not open the detail sidecar.
- Disabling the toggle hides any currently visible hover detail sidecar immediately.
- While disabled, moving the pointer across eligible rows does not create, reopen, or update the sidecar.
- Disabling the toggle affects only the hover detail sidecar; it does not change row focus, click behavior, row rendering, or other popup menu settings.
### Interaction and state transitions
- Toggling the setting on takes effect immediately; the user does not need to close and reopen the panel.
- Toggling the setting off takes effect immediately, including dismissing any currently open sidecar.
- Re-enabling the setting restores normal hover behavior without requiring any additional setup.
### Relationship to other display settings
- The new toggle is independent from `PR link` and `Diff stats`; those settings still control row-level metadata visibility in expanded mode.
- The new toggle is independent from `View as`, `Density`, `Pane title as`, and `Additional metadata`.
- Turning off `Show details on hover` does not alter the content the sidecar would show if it were enabled; it only suppresses hover-based opening.
### Empty and error states
- There is no separate empty state for this feature.
- If the setting cannot be read yet during initial render, the user should see the default enabled behavior rather than a broken or inconsistent menu state.
## Success criteria
1. The vertical tabs display options menu shows a new toggle labeled `Show details on hover`.
2. The toggle appears in its own separated section with no section title.
3. The toggle defaults to enabled for users who have not changed the setting before.
4. When enabled, hover detail sidecar behavior matches the current behavior.
5. When disabled, hovering eligible rows never opens the sidecar.
6. Turning the toggle off immediately dismisses any currently open sidecar.
7. Turning the toggle back on immediately restores hover-open behavior.
8. The setting persists across app restarts and syncs like other vertical-tabs display preferences.
9. Changing this setting does not change row activation, row layout, or the behavior of unrelated vertical-tabs display settings.
## Validation
- Open the vertical tabs display options menu and verify there is a divider followed by an untitled row for `Show details on hover`.
- Verify the toggle is checked by default in a clean settings state.
- With the toggle enabled, hover an eligible pane or tab row and verify the detail sidecar opens with the existing behavior.
- While a sidecar is visible, disable the toggle and verify the sidecar dismisses immediately.
- With the toggle disabled, hover multiple eligible rows and verify no sidecar appears.
- Re-enable the toggle and verify the same rows can open the sidecar again on hover.
- Change unrelated display settings such as `View as`, `Density`, `PR link`, and `Diff stats` and verify the new toggle still only controls hover-sidecar visibility.
- Restart the app or reload settings state and verify the selected value persists.
## Open questions
None.
+83
View File
@@ -0,0 +1,83 @@
# Integration Test Recording Overlays
## Summary
Add an API to the integration test video recording framework that, when enabled, renders visual event metadata in an overlay layer within the recorded video. The goal is to make recorded test videos self-explanatory so a viewer can understand which mouse and keyboard actions were fired during the test without reading the test source.
## Problem
Current integration test recordings show the UI state changing over time, but they do not clearly communicate which input events caused those changes. This makes recordings harder to use for debugging, regression review, demos, and collaboration because a viewer has to infer whether a state transition came from a click, a drag, a keypress, or some combination of inputs.
## Goals
- Make integration test recordings easier to interpret without needing external narration or source-code context.
- Show the user inputs that occurred at the moment they occurred in the recording.
- Follow familiar conventions from other screen recording and presentation tools that use animated annotations for clicks, drags, and keyboard shortcuts.
- Keep the feature opt-in through an API on the video recording framework.
## Non-Goals
- Changing how tests dispatch events.
- Building a full analytics or event-inspection UI outside the recorded video.
- Capturing every possible event type in the first version beyond clicks, click-and-drag, and keyboard events.
## Primary Use Cases
- A developer watches a failed integration test video and can immediately see which click triggered the incorrect UI response.
- A reviewer watches a video attached to a change and can understand a drag gesture or keyboard shortcut without pausing to inspect test code.
- A developer records a repro video for a UI bug and wants the visible annotations to explain the interaction sequence clearly.
## Required Recorded Events
### Clicks
When a click occurs, the video should show a transient animated annotation at the pointer location. This should follow the common pattern used by screen recording apps, such as a pulse, ring, or highlight that makes the click easy to notice without obscuring the UI underneath.
### Click and Drag
When a click-and-drag gesture occurs, the video should show:
- The drag start location
- The drag path or motion between points
- The drag end state
The visualization should make it obvious that the pointer movement was part of a drag gesture rather than ordinary cursor motion.
### Keyboard Events
When keyboard input occurs, the video should show the keys being fired in a compact overlay. The annotation must include:
- Modifier keys, if any
- The primary key being fired
Examples include combinations such as `Cmd+K`, `Shift+Enter`, or `Ctrl+C`, as well as non-modified keys when they are relevant.
## Product Requirements
- The overlay feature must be enabled through an explicit API in the integration test video recording framework.
- When disabled, recording behavior should remain unchanged.
- Overlay rendering should be synchronized with the recorded event timeline so annotations appear at the correct moment in the video.
- Overlays should be visually understandable at normal playback speed without requiring frame-by-frame inspection.
- Animations should be clear but lightweight, avoiding excessive distraction or covering important parts of the UI for too long.
- The system should support multiple keyboard events in sequence and render them in a way that remains readable.
- Mouse and keyboard overlays should be visually consistent so the recording feels like a single coherent annotation system.
## Proposed Experience
### Mouse Annotations
- Clicks use a brief animated pulse or ring centered on the pointer.
- Drag gestures use a visible start indicator and a short-lived path or trail that communicates direction and distance.
- The styling should feel similar to established screen recording tools that visualize cursor interactions for demos and tutorials.
### Keyboard Annotations
- Key events appear as a compact on-screen overlay, likely near the bottom of the video or another consistent location that does not interfere with important UI.
- Modifier keys and the main key should be shown together as a single composed event.
- The overlay should be readable, transient, and consistent across repeated actions.
## API Expectations
The recording framework should expose an API that enables these annotations for a given test recording session. The API should be simple enough that a test author can opt into annotated videos without changing how the test itself expresses input events.
At a minimum, the API should:
- Turn overlay capture on for a recording
- Observe or receive input events already emitted by the test framework
- Render those events into a visual overlay layer in the final video output
## Design Principles
- Prefer familiar annotation patterns over novel visuals.
- Optimize for clarity to someone watching the video for the first time.
- Keep the overlay declarative and easy to enable in tests.
- Ensure the feature improves debugging value without materially changing test authoring ergonomics.
## Success Criteria
- A viewer can correctly identify when a click, drag, or keyboard action occurred by watching the recording alone.
- Keyboard shortcuts are understandable from the video, including modifier keys.
- The overlay feels polished and familiar, matching expectations set by other screen recording applications with animated interaction annotations.
+277
View File
@@ -0,0 +1,277 @@
# Integration Test Recording Overlays Technical Spec
## Summary
This spec describes the implementation of the integration test recording overlay feature described in `specs/APP-3872/PRODUCT.md`.
The current branch already contains the core implementation for annotated integration test recordings. This document treats that implementation as the starting point, but makes the product spec the source of truth. Where the branch behavior diverged from the intended product behavior, the implementation has been adjusted and this document reflects the corrected design.
## Goals
- Provide an opt-in API for annotated integration test video recordings.
- Capture mouse and keyboard event metadata during a recording session.
- Composite those annotations into the final recorded video rather than requiring a separate viewer.
- Keep the feature understandable and ergonomic for test authors.
## Non-Goals
- Building a generic event-inspection UI outside the generated recording artifacts.
- Changing how integration tests express events today.
- Supporting every input event type in the first version beyond click, click-and-drag, and keyboard events.
## Current Branch Foundation
The branch already implements the main pieces needed for this feature:
- A `VideoRecorder` abstraction for start/stop recording, frame capture, screenshot export, and MP4 finalization
- An `OverlayLog` that stores timestamped visual annotation events
- Integration-step hooks that observe mouse and keyboard input as tests dispatch events
- Final compositing that draws overlays onto recorded frames at encode time
- A manual end-to-end exercise test in `integration/src/test/video_recording.rs`
This tech spec keeps that architecture and refines behavior where needed to better match the product spec.
## API Surface
### Test Author API
The recording API is exposed through `TestStep` helpers:
- `with_start_recording()`
- `with_stop_recording()`
- `with_take_screenshot(...)`
This means annotated recording is opt-in at the test level. A test author does not need to change how clicks, drags, or keystrokes are authored once recording has started.
### Environment-Based Debugging API
The existing environment variable `WARP_INTEGRATION_TEST_VIDEO` remains available as a convenience override for automatic recording during test runs. This is useful for debugging, but the primary product-facing API remains the explicit step-based recording controls.
## High-Level Architecture
### 1. Event capture in the integration test runner
Integration events are intercepted in the step runner before they are dispatched into the app. Relevant mouse and keyboard events are translated into overlay events and appended to an in-memory `OverlayLog`.
The recorded overlay event types are:
- `MouseDown`
- `MouseMove`
- `MouseUp`
- `KeyPress`
Keyboard events are derived from parsed `Keystroke` values and rendered using a display formatter that includes modifier keys and the main key in a single visual token.
Important behavior:
- Overlay events are recorded only while the `VideoRecorder` is actively recording.
- This prevents pre-recording or post-recording interactions from leaking into the visual annotation timeline.
This active-recording gating is an intentional correction to the earlier branch behavior.
### 2. Frame capture loop
When integration tests are running with the `integration_tests` feature enabled, the driver starts a foreground capture loop backed by the existing window frame-capture path. The loop:
- Sleeps when recording is off
- Requests a frame capture while recording is on
- Stores captured frames together with wall-clock timestamps
This keeps recording overhead effectively limited to periods where recording is enabled.
### 3. Final video compositing
At finalization time, captured frames are encoded into MP4. Before each frame is handed to the encoder, the overlay renderer advances an `OverlayState` to the frame timestamp and draws any active annotations onto the frame buffer.
This approach has a few benefits:
- It avoids mutating the live app UI just to show recording-only annotations.
- It keeps the overlay logic isolated from the product UI.
- It allows overlay rendering to be deterministic from the captured timeline.
If MP4 encoding fails, the implementation falls back to writing PNG frames.
### 4. How the overlay pixels are actually produced
The implementation does not ask WarpUI to render a second overlay scene and it does not add overlays after MP4 encoding. Instead, the compositor mutates each captured frame's RGBA pixel buffer in memory before the frame is converted to RGB/YUV and handed to OpenH264.
The concrete flow is:
- `VideoRecorder::finalize(...)` drains the captured `TimestampedFrame` list
- `encode_to_mp4(...)` clones the frame's RGBA byte buffer when overlay events are present
- `OverlayState::advance_to(...)` applies all overlay events whose timestamps are at or before the current frame time
- `OverlayState::render_onto(...)` rasterizes the active click, drag, and keyboard visuals directly into that RGBA buffer
- the composited RGBA data is converted to RGB, then YUV, and then encoded into the output MP4
This means the overlays are effectively burned into the frame pixels before encoding. The original capture remains unchanged except for the pixels touched by the overlay primitives.
### 5. Software rasterization model
Overlay rendering is implemented as a small software rasterizer in `ui/src/integration/overlay.rs`.
The renderer works on a flat RGBA byte slice and uses alpha blending per destination pixel:
- `blend_pixel(...)` mixes overlay color into the destination image
- `draw_filled_circle(...)` is used for the held-mouse indicator and drag anchor
- `draw_click_ring(...)` paints the animated post-click ring by filling only the annulus
- `draw_thick_line(...)` stamps circular samples along each drag segment
- `draw_key_overlay(...)` paints the rounded pill background for keyboard events
- `draw_text(...)` writes glyph pixels directly into the same RGBA buffer
There is no separate retained overlay surface. We simply replace some destination pixels with alpha-blended overlay colors.
### 6. Coordinate scaling
Overlay events are recorded in logical coordinates, while captured frames are stored in device pixels. The driver records the window backing scale factor into `OverlayLog`, and the compositor multiplies event coordinates by that scale factor before drawing circles, lines, and key pills. This is how the overlay positions line up with the captured image content.
## Rendering Model
### Clicks
Clicks are represented by:
- A filled pointer indicator while the mouse button is held
- An animated expanding ring after mouse up
This matches the familiar click pulse pattern used by screen recording and demo tools.
### Click and drag
Drag gestures are represented by:
- A visible drag start anchor
- A trail connecting the recorded drag points
- A visible end-state ring on mouse up
- A live pointer indicator while the drag is in progress
The earlier branch implementation already had a drag trail and end-state ring, but it did not emphasize the drag start strongly enough. The implementation has been adjusted to render an explicit drag-start anchor so the gesture is easier to read in playback.
### Keyboard events
Keyboard events are rendered as transient pill overlays near the bottom of the frame. Each pill contains:
- Any modifier keys involved in the event
- The main key being fired
Examples:
- `⌘C`
- `⌃A`
- `⇧Enter`
To better match the product requirement that rapid keyboard sequences remain readable, the implementation now renders multiple recent key events as a small stack instead of showing only the most recent keypress. This is another intentional correction relative to the earlier branch behavior.
### Keyboard glyph rendering details
The current implementation does not use system fonts or vector glyph rasterization for keyboard overlays. Instead, it uses:
- an embedded 8x16 bitmap font table for printable ASCII characters
- handwritten 8x16 bitmap glyphs for modifier and special-key symbols such as `⌘`, `⌃`, `⌥`, `⇧`, `↩`, `⇥`, and the arrow keys
- integer upscaling (`FONT_SCALE`) to enlarge those bitmaps inside the pill
`keystroke_display_text(...)` still formats the keyboard shortcut text as the expected Unicode symbols, but `draw_text(...)` does not shape or rasterize them with a font engine. `get_glyph(...)` maps each character to either the ASCII bitmap table or one of the custom symbol bitmaps and then writes the glyph pixels directly into the frame buffer.
This is why the modifier keys appear correctly today, but also why the resulting text can look pixelated in videos.
To improve readability, the current implementation now prefers plain-text labels for ambiguous non-modifier special keys such as `Enter`, `Tab`, `Backspace`, and `Delete` instead of rendering those as symbolic glyphs. Modifier keys still use symbolic forms such as `⌘`, `⌃`, `⌥`, and `⇧`.
### Improving glyph quality
Yes, we can improve the key and modifier glyph quality by switching to higher-resolution source bitmaps. That is the lowest-risk follow-up if the current 8x16 glyphs scaled by `FONT_SCALE` look too blocky in the final MP4.
The simplest version of that change would be:
- replace the 8x16 ASCII and modifier-symbol bitmaps with a larger source set such as 16x32 or 24x48
- either reduce the amount of integer upscaling or keep a smaller scale factor
- keep the same direct-to-buffer rendering model and alpha blending pipeline
This would preserve the current deterministic, cross-platform rendering behavior while making the keyboard overlays look cleaner. It is a much smaller change than introducing a full font rasterization stack.
## Event-to-Overlay Mapping
### Mouse mapping
- `LeftMouseDown` and the other supported mouse-down variants create `MouseDown`
- `LeftMouseDragged` creates `MouseMove` in the overlay timeline
- `LeftMouseUp` creates `MouseUp`
- Saved-position click helpers also emit matching synthetic overlay events so helper-driven interactions look the same as hand-authored events in recordings
### Keyboard mapping
- `KeyDown` events are converted to `KeyPress`
- The display text is derived from the parsed `Keystroke`, not the raw typed character stream
- This ensures modifier state is preserved in the overlay output
## Timing Model
All overlay events and captured frames use wall-clock timestamps from the test process.
During encoding:
- Each captured frame advances overlay state up to that frame timestamp
- Expiring visual effects fade out according to fixed durations
- Gaps between captured frames are normalized into repeated output frames at the target FPS
This means annotations are synchronized to the recorded interaction timeline rather than to test-step boundaries.
## Data Flow
1. The driver creates a `VideoRecorder`, `ActionLog`, `OverlayLog`, and artifacts directory for the test run.
2. A test step enables recording.
3. The capture loop begins accumulating timestamped frames while the recorder is active.
4. Integration events are dispatched as normal.
5. Relevant events are mirrored into the `OverlayLog`.
6. Finalization encodes the captured frames and composites overlay annotations frame-by-frame.
7. Artifacts are written, including:
- `recording.mp4`
- screenshots requested by the test
- `recording.log`
## Artifacts
The implementation produces:
- Annotated video output
- Requested screenshots
- A plain-text action log for timestamped event auditing
The action log is useful for debugging and complements the overlay video, but it is not the primary user-facing product surface.
## Implementation Details by Module
### `ui/src/integration/driver.rs`
Responsible for:
- Creating per-test recording state
- Starting the frame capture loop
- Initializing the overlay scale factor from the active window backing scale
- Handling screenshot capture after steps
- Finalizing the recording and writing artifacts
### `ui/src/integration/step.rs`
Responsible for:
- Exposing the step-level recording API
- Intercepting integration events
- Translating supported input events into overlay events
- Ensuring overlay events are only recorded while recording is active
### `ui/src/integration/overlay.rs`
Responsible for:
- Defining overlay event types
- Converting keystrokes into display strings
- Tracking transient overlay state across the event timeline
- Rendering click, drag, and keyboard overlays into RGBA frame buffers via direct pixel mutation and alpha blending
### `ui/src/integration/video_recorder.rs`
Responsible for:
- Managing recording lifecycle
- Running the timestamped frame capture loop
- Encoding MP4 output
- Invoking overlay compositing during finalization
- Falling back to PNG frames if video encoding fails
### `integration/src/test/video_recording.rs`
Acts as the manual end-to-end validation flow for:
- screenshots
- start/stop recording
- click overlays
- drag overlays
- keyboard overlays
## Decisions and Corrections
### Keep overlays composited at encode time
We should continue rendering overlays into captured frames during finalization instead of drawing them live in the app. This keeps the production UI untouched and keeps the feature scoped to integration recording.
### Keep recording opt-in
The explicit start/stop recording API matches the product spec and keeps the feature ergonomic for tests that only want overlays for a narrow slice of execution.
### Correct overlay collection to honor recording state
Overlay collection must be tied to active recording. This avoids stale annotations from appearing in the output video and better matches the product requirement that overlays appear when the feature is enabled.
### Render short keyboard history, not just the latest key
Showing a short stack of recent key events better matches the product goal of making recordings understandable at normal playback speed, especially for shortcuts or rapid key sequences.
### Make drag start visible
The drag path alone is not always enough to communicate where a gesture began. A dedicated start anchor makes the recording easier to interpret.
### Prefer higher-resolution bitmaps before introducing real font rendering
If the current keyboard overlays are too pixelated, the best next step is to increase the source bitmap resolution rather than immediately moving to system fonts. Higher-resolution bitmaps would improve visual quality without adding font discovery, shaping, rasterization libraries, or platform-specific output differences to the integration test pipeline.
## Validation
Implementation was validated with:
- `cargo check -p warpui_core --features integration_tests --manifest-path /Users/zach/Projects/warp_5/Cargo.toml`
- `cargo check -p integration --manifest-path /Users/zach/Projects/warp_5/Cargo.toml`
For manual validation, the existing `integration/src/test/video_recording.rs` flow should be used to inspect:
- click visibility
- drag readability
- keyboard modifier rendering
- stacked key annotations during rapid sequences
## Future Extensions
- Add richer styling configuration if test authors need different overlay density or durations
- Add support for more pointer event types if product requirements expand
- Replace the current 8x16 keyboard bitmaps with a higher-resolution glyph set to reduce visible pixelation in encoded videos
- Optionally export structured overlay metadata alongside the rendered video if downstream tooling needs it
+365
View File
@@ -0,0 +1,365 @@
# APP-3875: Vertical Tabs v2 — Summary Tab Item Mode
## Summary
Add a new `Summary` tab item mode for vertical tabs when `View as = Tabs`.
This is a follow-up to APP-3828. `Focused session` remains the default tab item for `View as = Tabs`, while `Summary` provides a tab-level overview of the work inside the tab. In Summary mode, each tab renders as a fixed expanded-style summary card with:
- a primary line derived from conversation / command labels across the tab
- a second line derived from working directories across the tab
- branch lines derived from the tab's unique branch contexts, with diff stats and PR chips shown on the branch line rather than on pane rows
## Problem
The current `View as = Tabs` mode is still fundamentally pane-scoped. It reduces each tab to a single representative row, but that row is just the focused pane rendered with the existing pane-row UI.
That works when the user only cares about the current focused pane, but it breaks down when a tab contains multiple active panes or multiple related workflows. In that case, the representative row hides the rest of the tab's work:
- the row title only describes the focused pane
- the working directory only describes the focused pane
- diff stats and PR chips only describe the focused pane's branch context
- the tab gives no concise overview of the other work happening inside it
Users need a second Tabs-mode representation that answers "what is inside this tab?" rather than only "which pane in this tab is focused right now?"
## Goals
- Add a new `Tab item` section directly under `View as` in the vertical tabs settings popup.
- Make `Tab item` available only when `View as = Tabs`.
- Keep `Focused session` as the default tab item mode for `View as = Tabs`.
- Add `Summary` as a second tab item mode.
- Make `Summary` render a tab-level summary card rather than a focused-pane row.
- Make `Summary` hide `Density` and the other focused-pane row controls that do not apply to the summary card.
- Define the summary card in terms of:
- work labels on the first line
- working directories on the second line
- branch lines below, with diff stats and PR chips keyed to the branch context
- Keep the summary card stable and predictable rather than heuristic-heavy.
- Preserve the user's previous focused-session display settings when they temporarily switch into and out of `Summary`.
## Non-goals
- Adding `Summary` to `View as = Panes`.
- Adding a compact-density summary layout.
- Making branch lines or chips independently clickable.
- Replacing branch lines with pane-preview lines.
- Introducing fuzzy matching, semantic merging, or aggressive rewriting of work labels.
- Redesigning tab headers, drag-and-drop, rename behavior, or the existing tab-level hover sidecar.
- Reworking the existing `Focused session` behavior beyond adding the new `Tab item` control next to it.
## Figma / design references
- Popup exploration: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7628-124093&t=LSuxL7FNk3EXOfvJ-0
- Summary card exploration: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7633-127452&t=LSuxL7FNk3EXOfvJ-0
### Intentional deviations from the exploratory mocks
- `Summary` is a Tabs-only `Tab item` mode, not a density option.
- When `Summary` is selected, `Density` is hidden rather than adapted.
- The lower lines in Summary mode are branch lines, not pane-preview lines.
- Branch rows are grouped by branch context rather than rendered once per pane.
## User experience
### Popup structure
The vertical tabs settings popup keeps `View as` as its top section:
- `Panes`
- `Tabs`
When `View as = Panes`:
- the `Tab item` section is hidden
- the existing pane-oriented controls continue to behave as they do today
When `View as = Tabs`:
- a `Tab item` section appears directly under `View as`
- the `Tab item` section has exactly two options:
- `Focused session`
- `Summary`
`Focused session` is the default tab item mode for `View as = Tabs`.
### Popup state transitions
- Clicking `Focused session` or `Summary` updates the panel immediately.
- The popup remains open after changing `Tab item`.
- Switching from `Focused session` to `Summary` hides:
- `Density`
- `Pane title as`
- `Additional metadata`
- `Show`
- Switching back from `Summary` to `Focused session` restores those sections exactly as they were before; Summary does not overwrite the stored values for those controls.
- Switching from `Tabs` back to `Panes` hides `Tab item` entirely.
- If the user later returns to `View as = Tabs`, Warp restores the last selected `Tab item` mode.
### Focused session behavior
When `View as = Tabs` and `Tab item = Focused session`, behavior remains the same as APP-3828:
- each tab renders one representative row
- the representative row is derived from the focused pane in that tab
- `Density`, `Pane title as`, `Additional metadata`, and `Show` continue to affect that row exactly as they do today
This mode remains the default Tabs behavior.
### Summary behavior
When `View as = Tabs` and `Tab item = Summary`, each tab renders a fixed expanded-style summary card instead of a focused-pane row.
The summary card represents the tab as a whole, not any specific pane.
Summary mode does not expose density variants. It always uses the Summary card layout described below.
### Summary card structure
Each summary card contains up to four regions, in this order:
1. primary line
2. working-directory line
3. up to three visible branch lines
4. optional overflow line (`+ N more`)
The card may omit regions that have no data. For example:
- a tab with no branch context has no branch lines
- a tab with no working-directory data has no working-directory line
The absence of a later region does not insert placeholder text or empty rows.
### Summary card icon
Summary mode renders the pane circle icon as a tab-level pane-kind summary instead of using only the focused pane's icon.
- If all visible panes in the tab are the same pane kind, render one icon for that kind.
- Terminal panes are distinguished by their semantic terminal icon treatment, so plain terminals, Oz/ambient-agent terminals, and CLI-agent terminals do not all collapse to the generic terminal icon.
- If the tab has two or more pane kinds, render two icons using the same stacked arrangement as the current agent/status icon treatment:
- the oldest pane kind is the main icon
- the second-oldest distinct pane kind is the smaller secondary icon in the bottom-right
- Choose pane kinds by sorting the tab's visible panes by pane creation order, then taking the first two distinct pane kinds.
- If the oldest pane is closed, recompute from the remaining visible panes.
- Ignore any additional pane kinds beyond the first two distinct kinds for the icon treatment.
### Primary line
The primary line summarizes the work happening in the tab.
It is derived from work labels gathered across the tab's visible panes in tab order.
#### Work-label sources
For terminal-like panes, the work label should prefer conversation / command-oriented text, using the same general precedence as the current focused-session labeling:
- CLI agent display title, if present
- conversation display title, if present
- terminal title, if it is meaningful
- last completed command, if present
- otherwise the terminal fallback label such as `New session`
For non-terminal panes, the work label falls back to the pane title or pane-type label when no conversation / command label exists.
#### Normalization rules
Work-label normalization is intentionally conservative:
- trim leading and trailing whitespace
- collapse repeated internal whitespace
- drop empty labels
- dedupe exact-equivalent normalized labels while preserving the first-seen display text
Summary mode must not:
- semantically rewrite labels
- fuzzy-match different labels
- merge distinct labels like `cargo test` and `cargo + code review`
- treat different agent names as interchangeable
#### Rendering rules
- Render the first four unique work labels in first-seen order.
- Join them with ` • `.
- If more than four unique labels remain, append ` + N more`.
- The line is single-line and truncates visually when it does not fit.
Examples:
- `Claude • Oz • cargo • code review`
- `Claude • cargo • code review + 2 more`
### Working-directory line
The second line summarizes where the work in the tab is happening.
- Gather unique working directories exposed by panes in first-seen order.
- Render them as a list separated by ` • `.
- The line is single-line and truncates visually when it does not fit.
- If no pane exposes working-directory data, omit the line.
Warp should not try to pick one "most representative" directory in Summary mode. The card should show the stable list of unique working directories instead.
Examples:
- `~/warp-internal`
- `~/warp-internal • ~/warp-server • ~/warp-terraform`
### Branch lines
The lower lines in Summary mode are branch lines, not pane-preview lines.
Each branch line represents a unique branch context present in the tab and is the place where diff stats and PR chips appear in Summary mode.
#### Branch grouping
Branch lines are coalesced by unique repository + branch context.
That means panes from different repositories do not collapse into one branch line merely because they share the same branch name, such as `main`.
#### Branch-line ordering
- Branch lines are ordered by first appearance in the tab's visible pane order.
- Render at most three branch lines.
- If more than three unique branch contexts exist, render an overflow line below them:
- `+ N more`
#### Branch-line contents
Each visible branch line shows:
- the branch label on the left
- diff stats on the right, if available for that branch context
- a PR chip on the right, if available for that branch context
A branch line may show:
- just the branch label
- the branch label plus diff stats
- the branch label plus PR chip
- the branch label plus both diff stats and PR chip
If a pane contributes no branch context, it does not create a branch line.
#### Coalesced metadata behavior
Within one coalesced repository + branch group:
- diff stats are shown once for the group
- the PR chip is shown once for the group
If multiple panes in the same coalesced group expose the same logical branch metadata, Warp should simply coalesce them into that one rendered line rather than treating them as separate rows.
### Summary-card interactions
In v1, the summary card remains a single tab-level target.
- clicking the card activates the tab
- clicking the card focuses the tab's active pane
- branch lines are informational only
- diff stats and PR chips inside Summary mode are informational only
Summary mode does not introduce child-level click targets for branches or chips.
### Selection, hover, and tab-level behavior
Summary mode keeps the existing tab-level interaction model:
- the active tab retains active/selected styling
- hover styling remains tab-level
- tab header behavior remains unchanged
- rename behavior remains unchanged
- drag-and-drop behavior remains unchanged
- close behavior remains unchanged
- the existing tab-level hover sidecar behavior remains unchanged
### Single-pane tabs
A single-pane tab can still render a summary card.
In that case, the summary card may contain:
- one primary work label
- one working directory
- one branch line
Summary mode should still look intentional for single-pane tabs, even though it provides less aggregation benefit there than for multi-pane tabs.
### Mixed tabs and missing data
Summary mode must handle mixed-content tabs gracefully.
Examples:
- A tab may include panes that contribute work labels but no branch context.
- A tab may include panes that contribute branch context but no working-directory data.
- A tab may include only one visible branch line and no working-directory line.
Warp must not insert placeholder copy such as `No branch` or `No directory`.
### Search behavior
When `View as = Tabs` and `Tab item = Summary`, search/filtering operates on the tab summary data rather than on a focused-pane row.
Search matching includes the full underlying summary content for the tab, including:
- all normalized work labels, including labels not visible because of `+ N more`
- all working-directory values gathered for the tab
- all coalesced branch labels, including branch groups not visible because of the branch overflow line
- PR labels / identifiers present in the summary card
- diff-stat text shown for branch lines
This means a tab can match the search query even if the matching branch or work label is currently hidden behind an overflow line.
## Success criteria
1. The vertical tabs popup shows a `Tab item` section directly under `View as` when `View as = Tabs`.
2. The `Tab item` section is hidden when `View as = Panes`.
3. `Tab item` has exactly two options: `Focused session` and `Summary`.
4. `Focused session` remains the default Tabs-mode representation.
5. Selecting `Summary` updates the panel immediately and leaves the popup open.
6. When `Summary` is selected, `Density`, `Pane title as`, `Additional metadata`, and `Show` are hidden.
7. Returning from `Summary` to `Focused session` restores the previously selected values for those hidden controls.
8. In Summary mode, each tab renders one summary card rather than one focused-pane row.
9. The summary card's first line is derived from work labels across the tab, not just the focused pane.
10. Work-label deduplication is conservative and exact-equivalent only after whitespace normalization.
11. The primary line uses ` • ` between visible work labels.
12. The working-directory line shows the unique working directories in stable first-seen order rather than choosing one heuristic "best" directory.
13. The working-directory line uses ` • ` between visible directories.
14. The Summary pane circle renders one pane-kind icon for homogeneous tabs.
15. The Summary pane circle renders two pane-kind icons for heterogeneous tabs, selected from the two oldest distinct pane kinds by pane creation order, with the secondary icon in the same bottom-right placement as the existing agent/status composite icon.
16. The lower rows in Summary mode are branch lines, not pane previews.
17. Branch lines are coalesced by repository + branch context, so two repositories on `main` do not collapse into one row.
18. Each branch line can show branch label, diff stats, and PR chip in one row.
19. Summary mode shows at most three visible branch lines, followed by `+ N more` when additional branch contexts exist.
20. Branch lines and chips in Summary mode are informational only and do not create new click targets.
21. Tabs with no working-directory data or no branch data still render sensible summary cards without placeholder text.
22. Search in Summary mode matches the full summary dataset for the tab, including values hidden behind overflow lines.
23. Switching away from Summary does not erase the user's focused-session density or pane-row display preferences.
24. Existing tab header, selection, hover, close, rename, drag, and sidecar behavior remain unchanged.
## Validation
- Open the vertical tabs popup, switch to `View as = Tabs`, and verify a `Tab item` section appears directly beneath `View as`.
- Verify `Focused session` is selected by default in Tabs mode.
- Switch between `Focused session` and `Summary` and verify the panel updates immediately without closing the popup.
- While `Summary` is selected, verify `Density`, `Pane title as`, `Additional metadata`, and `Show` are hidden.
- Switch back to `Focused session` and verify those controls return with their previous values intact.
- Create a tab with multiple terminal/agent panes and verify the Summary primary line contains multiple work labels rather than just the active pane's label.
- Verify exact-duplicate work labels are deduped, while distinct labels remain distinct.
- Create a tab spanning multiple working directories and verify the second line shows the unique directories in first-seen order.
- Verify the primary and working-directory lines use ` • ` separators between visible values.
- Create a homogeneous tab and verify Summary renders one pane-kind icon.
- Create a heterogeneous tab and verify Summary renders two pane-kind icons selected from the two oldest distinct pane kinds, with the oldest as the main icon and the second-oldest distinct kind as the bottom-right secondary icon.
- Create a tab with multiple panes on the same repository + branch and verify they produce one branch line rather than multiple pane-preview rows.
- Create a tab with two different repositories both on `main` and verify they render as separate branch lines.
- Verify diff stats and PR chips appear on the branch line rather than being keyed to a focused pane row.
- Create more than three unique branch contexts in one tab and verify only three branch lines are shown, followed by `+ N more`.
- Verify tabs with no branch data omit the branch section entirely.
- Verify tabs with no working-directory data omit the working-directory line entirely.
- Click a Summary card and verify Warp activates the tab and focuses its active pane.
- Verify clicking on branch lines or chips does not trigger separate actions.
- Search for a work label, directory, hidden-overflow branch, PR number, and diff-stat text, and verify the correct tab still matches in Summary mode.
- Verify tab headers, drag-and-drop, rename, close, and hover-sidecar behavior are unchanged while Summary mode is selected.
## Open questions
None.
+475
View File
@@ -0,0 +1,475 @@
# APP-3875: Tech Spec — Summary Tab Item Mode for Vertical Tabs
## Problem
APP-3875 adds a second tab-level representation for vertical tabs when `View as = Tabs`.
Today, Tabs mode is implemented by reusing the existing pane-row UI for the tab's focused pane. That keeps the implementation simple, but it makes the tab item fundamentally pane-scoped:
- render logic is driven by a single representative `PaneId`
- search logic is driven by the same representative pane's search fragments
- diff stats and PR chips are rendered as pane-level terminal metadata
The new `Summary` mode needs a different unit of representation:
- the card must summarize the whole tab, not one pane
- the lower rows are branch lines, not pane previews
- search needs to match hidden summary content, not just visible rows
- the popup must hide the focused-session controls when Summary is selected, without losing the underlying settings
Technically, this means we need to introduce a Tabs-only item mode and a tab-level aggregation model while preserving the existing focused-session behavior and reusing as much of the current selection / hover / rename / drag infrastructure as possible.
## Relevant code
- `specs/APP-3875/PRODUCT.md` — agreed user-facing behavior for Summary mode
- `specs/APP-3828/PRODUCT.md` — current `View as = Tabs` focused-session behavior this feature extends
- `app/src/workspace/tab_settings.rs (171-279)` — current synced vertical-tabs settings (`VerticalTabsViewMode`, `VerticalTabsDisplayGranularity`, `VerticalTabsPrimaryInfo`, `VerticalTabsCompactSubtitle`)
- `app/src/workspace/action.rs (234-241)` — existing vertical-tabs setting actions
- `app/src/workspace/action.rs (624-848)``should_save_app_state_on_action` coverage for vertical-tabs popup actions
- `app/src/workspace/action_tests.rs (1-50)` — current tests for vertical-tabs action persistence behavior
- `app/src/workspace/view.rs (18224-18337)` — workspace-side action handling for the vertical-tabs popup settings
- `app/src/workspace/view/vertical_tabs.rs (231-327)` — shared row wrapper behavior (`render_pane_row_element`) for click, hover, selection styling, and inline rename
- `app/src/workspace/view/vertical_tabs.rs (428-669)``VerticalTabsPanelState`, popup-local mouse state, and `matching_tab_indices`
- `app/src/workspace/view/vertical_tabs.rs (1048-1314)` — main vertical-tabs render path (`render_groups`, `render_tab_group`), including the current flat Tabs-mode list
- `app/src/workspace/view/vertical_tabs.rs (1982-2180)``PaneProps`, search-fragment generation, and terminal primary-line helpers
- `app/src/workspace/view/vertical_tabs.rs (2155-2781)` — terminal metadata helpers and current clickable diff/PR badge rendering
- `app/src/workspace/view/vertical_tabs.rs (2398-2492)``render_title_override` and inline rename editor behavior
- `app/src/workspace/view/vertical_tabs.rs (2890-3319)``render_settings_popup`
- `app/src/workspace/view/vertical_tabs.rs (4087-4369)` — compact row rendering and the current vertical-tabs unit-test module hookup
- `app/src/workspace/view/vertical_tabs_tests.rs (1-259)` — existing pure helper tests for vertical-tabs behavior
- `app/src/terminal/view/tab_metadata.rs (24-119)` — terminal metadata accessors used by the current focused-session rows (`display_working_directory`, `current_git_branch`, `current_pull_request_url`, `current_diff_line_changes`)
- `app/src/terminal/view.rs:2753``TerminalView::current_repo_path`, needed to distinguish same-named branches across repositories
## Current state
### Settings and popup model
The vertical-tabs popup currently has two orthogonal axes:
- `View as``Panes` vs `Tabs`
- `Density``Compact` vs `Expanded`
It also exposes focused-pane controls:
- `Pane title as`
- `Additional metadata` (compact only)
- `Show` (expanded only)
There is no Tabs-only concept of "how should a tab item be represented?" The popup assumes that every rendered item is still a pane row.
### Tabs mode is already a flat list
Current `View as = Tabs` behavior is implemented as a flat list, not a grouped header + body UI:
- `uses_outer_group_container` returns `false` for `VerticalTabsDisplayGranularity::Tabs`
- `render_groups` adds spacing between flat tab items in Tabs mode
- `render_tab_group` skips the outer group header/container path when `uses_outer_group_container` is false
This matches the mock and is the right foundation for Summary mode. We do not need to invent a new container hierarchy for this feature.
### Rendering and search are pane-derived
The current Tabs-mode implementation still relies on a representative pane:
- `pane_ids_for_display_granularity(...)` returns one representative `PaneId` in Tabs mode
- `matching_tab_indices(...)` searches by building `PaneProps` for that representative pane
- the search branch in `render_groups(...)` also matches representative-pane `PaneProps`
- `render_tab_group(...)` iterates the selected pane IDs and renders either `render_compact_pane_row(...)` or `render_pane_row(...)`
This means render and search both inherit the limitations of pane-scoped data.
### Pane metadata is available in the right places, but not yet aggregated
The required summary inputs already exist in the codebase, mostly on `TerminalView`:
- work-label inputs:
- conversation display title
- CLI agent display title
- terminal title
- last completed command
- directory input:
- `display_working_directory(...)`
- branch metadata:
- `current_git_branch(...)`
- `current_diff_line_changes(...)`
- `current_pull_request_url(...)`
- `current_repo_path(...)`
However, these values are only consumed today for a single pane at a time. There is no tab-level aggregation model, no stable dedupe / coalescing logic, and no summary-specific search fragments.
### Diff and PR badges are interactive today
The existing helpers:
- `render_terminal_diff_stats_badge(...)`
- `render_terminal_pull_request_badge(...)`
wrap the visual chip content in `Hoverable` and dispatch actions on click. That matches focused-session rows, but it does not match Summary mode, where branch lines and chips are informational only.
## Proposed changes
### 1. Add a synced Tabs-only item-mode setting
Add a new enum in `app/src/workspace/tab_settings.rs`:
```rust
#[derive(Default, Debug, serde::Serialize, serde::Deserialize, PartialEq, Copy, Clone)]
pub enum VerticalTabsTabItemMode {
#[default]
FocusedSession,
Summary,
}
```
Register it in `TabSettings` with the same sync behavior as the existing vertical-tabs popup settings:
- `SupportedPlatforms::ALL`
- `SyncToCloud::Globally(RespectUserSyncSetting::Yes)`
- `hierarchy: "appearance.tabs"`
This setting is only used when `vertical_tabs_display_granularity == Tabs`, but it should still be persisted independently so Warp can restore the user's last chosen Tabs-mode representation.
### 2. Add a workspace action for the new setting
Add `WorkspaceAction::SetVerticalTabsTabItemMode(VerticalTabsTabItemMode)` beside the existing vertical-tabs popup actions.
Handle it in `Workspace::handle_action` exactly like the other vertical-tabs setting writes:
- write to `settings.vertical_tabs_tab_item_mode`
- call `ctx.notify()`
Update `should_save_app_state_on_action` and `action_tests.rs` so the new action is explicitly marked as not requiring workspace-state persistence.
### 3. Extend popup-local state and restructure `render_settings_popup`
Add two mouse states to `VerticalTabsPanelState`:
- `focused_session_option_mouse_state`
- `summary_option_mouse_state`
Keep the existing density and focused-session option mouse states unchanged so Summary mode can temporarily hide, rather than replace, those controls.
Update `render_settings_popup(...)` so the popup hierarchy becomes:
1. `View as`
2. `Tab item` (only when `View as = Tabs`)
3. `Density` and the focused-session controls (only when `View as = Tabs && Tab item = FocusedSession`, or when `View as = Panes`)
Concretely:
- if `View as = Panes`, do not render `Tab item`
- if `View as = Tabs`, render a `Tab item` section directly under `View as`
- if `Tab item = Summary`, do not render:
- `Density`
- `Pane title as`
- `Additional metadata`
- `Show`
This preserves the stored values of those controls while making the Summary-mode popup match the product behavior.
### 4. Introduce a tab-level Summary aggregation model
Add a new pure aggregation layer in `vertical_tabs.rs` for Summary mode rather than pushing summary derivation directly into the renderer.
Suggested data shape:
```rust
struct VerticalTabsSummaryData {
primary_labels: Vec<String>,
working_directories: Vec<String>,
branch_entries: Vec<VerticalTabsSummaryBranchEntry>,
}
struct VerticalTabsSummaryBranchEntry {
repo_path: PathBuf,
branch_name: String,
diff_stats: Option<GitLineChanges>,
pull_request_label: Option<String>,
}
```
The exact field names can vary, but the design should keep two properties:
1. the full aggregated data is available for search, even when some items are visually hidden behind overflow
2. ordering is explicit and stable; do not rely on `HashMap` iteration order for rendering
Implementation guidance:
- preserve first-seen order with `Vec` plus a set / index map keyed by normalized value
- use one aggregation pass over the tab's `visible_pane_ids()`
- build Summary data from the full tab, not just the representative pane
Summary mode also needs a small tab-level icon selection helper:
- map each visible pane to a stable pane-kind enum used only for Summary icon rendering
- distinguish terminal-backed agent sessions from plain terminals so Oz/ambient-agent and CLI-agent terminal panes render their semantic agent icons rather than the generic terminal icon
- sort candidates by pane creation order, using the pane view `EntityId` as the current stable creation-order key
- render `Single(kind)` if all visible panes share the same kind
- render `Pair { primary, secondary }` for heterogeneous tabs, where `primary` is the oldest pane kind and `secondary` is the second-oldest distinct pane kind
- position the secondary icon using the same bottom-right anchor, offset, and cutout-ring sizing as the existing agent/status composite icon
- recompute from the current visible panes each render so closing the oldest pane naturally changes the selected icons
### 5. Reuse existing pane-derived helpers instead of re-implementing label logic
Summary mode should reuse the same data precedence rules the focused-session code already uses wherever possible.
For work labels:
- reuse `terminal_primary_line_data(...)` for terminal panes
- reuse `PaneProps::new(...)` / `displayed_title()` for non-terminal panes where practical
For working-directory contributions:
- terminal panes use `TerminalView::display_working_directory(...)`
- code panes can contribute the already-derived parent-directory subtitle that `PaneProps::new(...)` computes
- other panes should only contribute if they already expose a meaningful directory-like value; otherwise they contribute nothing
This avoids creating a second set of heuristics that drift from the current row behavior.
### 6. Coalesce branch lines by repository + branch
Summary branch lines must be keyed by repository + branch, not branch name alone.
Implementation plan:
- only panes that can expose both `current_repo_path()` and `current_git_branch()` contribute a branch line
- use `(repo_path, branch_name)` as the grouping key
- preserve first-seen order by recording the first time each key appears while scanning visible panes
- store diff stats and PR label once per coalesced entry
This keeps same-named branches in different repositories distinct and matches the product spec's branch-line behavior.
For v1, if multiple panes in the same coalesced group expose the same logical metadata, simply keep the first observed non-empty values for:
- diff stats
- PR label
That is sufficient because the product semantics treat these as logically branch-scoped. We do not need a more complex merge strategy.
### 7. Add summary-specific normalization and search helpers
Add pure helpers for:
- work-label normalization
- work-label deduplication with first-seen display preservation
- summary search-fragment generation
Search fragments for Summary mode should include the full, untruncated data set:
- all work labels
- all working directories
- all branch names
- all PR labels
- diff-stat text for all coalesced branch entries
This is intentionally different from the render path, which only shows a prefix and then `+ N more`.
### 8. Keep row-level interaction, selection, hover, and rename by reusing the existing wrapper
Do not build Summary mode as an entirely separate interaction surface.
Instead, keep a representative `PaneProps` for the tab's focused pane and use it to drive the existing shared row wrapper:
- `render_pane_row_element(...)` for click, hover, selection background, and detail-sidecar targeting
- `render_title_override(...)` for custom tab title and inline rename editor behavior
The Summary renderer should therefore have the shape:
- build `SummaryData` from the full tab
- build representative `PaneProps` from the focused pane
- render Summary content inside the existing wrapper using the representative `PaneProps`
This gives Summary mode the current Tabs-mode behavior "for free":
- click focuses the tab's active pane
- hover uses the existing tab-level styling
- double-click rename continues to work in the same place
- detail-sidecar hover targeting remains tab-scoped via `VerticalTabsDetailTarget::Tab`
### 9. Add a dedicated Summary renderer
Add a new renderer in `vertical_tabs.rs`, for example:
```rust
fn render_summary_tab_item(
representative_props: PaneProps<'_>,
summary: &VerticalTabsSummaryData,
app: &AppContext,
) -> Box<dyn Element>
```
This renderer should:
- render a fixed expanded-style card
- render a Summary-specific pane-kind icon rather than the focused pane's icon
- render the primary line, working-directory line, visible branch lines, and optional overflow line
- cap visible work labels and visible branch entries independently
- omit empty sections instead of rendering placeholders
Because Summary mode is informational-only at the branch level, the branch lines should not introduce nested click handlers or badge hover handlers.
### 10. Split badge visuals from badge interactivity
Refactor the current terminal diff / PR badge helpers so the visual badge content can be reused without the focused-session click behavior.
Suggested direction:
- keep existing interactive helpers for focused-session rows
- extract passive visual helpers for:
- diff-stats badge content
- PR badge content
- Summary mode uses the passive versions in branch lines
This avoids duplicating the chip visuals while keeping Summary mode intentionally non-interactive.
### 11. Branch render and search logic based on mode, not only granularity
Current render and search logic branch only on `VerticalTabsDisplayGranularity`.
Update the Tabs-mode path so it branches on both:
- display granularity
- tab item mode
Suggested structure:
- `Panes` → existing pane-based render and search
- `Tabs + FocusedSession` → existing representative-pane render and search
- `Tabs + Summary` → summary render and summary search
This can be implemented with a small resolved-mode helper rather than scattering nested conditionals throughout the file.
### 12. Add pure tests for the new aggregation behavior
Extend `vertical_tabs_tests.rs` with pure helper tests covering:
- work-label normalization
- exact-equivalent dedupe while preserving first-seen display text
- working-directory dedupe and stable ordering
- branch coalescing by repository + branch
- same branch name in different repos stays distinct
- overflow-count behavior for primary labels and branch lines
- summary search fragments include hidden-overflow values
Keep these helpers pure so they do not require a full `AppContext` or UI harness.
### 13. Keep focused-session behavior untouched
Do not refactor the existing focused-session row renderers beyond what is needed to:
- add the new setting/action
- hide irrelevant popup sections in Summary mode
- share badge visuals where necessary
Focused-session Tabs mode is already shipped behavior from APP-3828. The safest implementation is to keep that path intact and add Summary as a parallel path.
## End-to-end flow
```mermaid
flowchart TD
A[User opens vertical-tabs popup] --> B[Selects View as = Tabs]
B --> C[Selects Tab item = Summary]
C --> D[WorkspaceAction::SetVerticalTabsTabItemMode]
D --> E[TabSettings.vertical_tabs_tab_item_mode updated]
E --> F[render_settings_popup hides Density and pane-row controls]
E --> G[render_groups resolves Tabs + Summary mode]
G --> H[Aggregate full-tab SummaryData from visible panes]
H --> I[Build representative PaneProps from focused pane]
I --> J[render_summary_tab_item inside existing row wrapper]
H --> K[summary_search_fragments]
K --> L[matching_tab_indices and render_groups search path]
```
At runtime, Summary mode therefore follows the same top-level control flow as the current popup settings, but diverges at render/search time into a tab-level aggregation path.
## Risks and mitigations
### Risk: render and search drift apart
If Summary render data and Summary search fragments are built in separate ad hoc code paths, the panel will render one thing and search another.
Mitigation:
- use one `VerticalTabsSummaryData` aggregation path
- derive both render output and search fragments from that shared data
### Risk: same-named branches collapse incorrectly
If branch grouping keys only use the branch name, two different repos on `main` will merge incorrectly.
Mitigation:
- group by `(repo_path, branch_name)`
- add a unit test for two repos on `main`
### Risk: custom tab rename behavior regresses in Summary mode
Current Tabs-mode rows support inline rename because the row wrapper and `render_title_override(...)` are wired to tab-level rename state.
Mitigation:
- keep representative `PaneProps`
- route Summary primary-line rendering through the same title-override / rename-editor path
### Risk: Summary data is unavailable for some non-terminal panes
Branch metadata is readily available on `TerminalView`, but not every pane type exposes repo / branch context today.
Mitigation:
- let non-terminal panes contribute work labels and directories where available
- omit branch contributions from panes that do not expose branch context
- render sparse summary cards without placeholder text
### Risk: passive Summary chips drift visually from focused-session chips
If Summary mode re-implements diff/PR chips from scratch, the appearance can drift from the current vertical-tabs styling.
Mitigation:
- extract shared visual badge helpers
- keep only the event-handling wrapper different between focused-session and Summary mode
## Testing and validation
### Unit tests
- `app/src/workspace/action_tests.rs`
- add coverage for `SetVerticalTabsTabItemMode(...)`
- `app/src/workspace/view/vertical_tabs_tests.rs`
- label normalization and dedupe
- directory dedupe and stable ordering
- branch coalescing by repo + branch
- same branch name in different repos
- overflow counting
- summary search fragments include hidden entries
- Summary pane-kind icon selection for homogeneous tabs, heterogeneous tabs, and oldest-pane removal
### Manual / UI validation
- verify `Tab item` appears only when `View as = Tabs`
- verify `Summary` hides `Density`, `Pane title as`, `Additional metadata`, and `Show`
- verify switching back to `Focused session` restores the prior focused-session settings
- verify a multi-pane tab renders a summary card instead of a focused-pane row
- verify homogeneous Summary tabs render one pane-kind icon
- verify heterogeneous Summary tabs render the two oldest distinct pane-kind icons with the secondary icon in the bottom-right
- verify branch lines are grouped by repo + branch and carry diff stats / PR chips
- verify branch lines and chips are not independently clickable
- verify clicking the card still focuses the active pane
- verify search matches hidden overflow data
- verify double-click rename still works in Summary mode
### Broader validation
When implementation lands, run the same validation expected for other vertical-tabs work:
- targeted unit tests
- relevant workspace / vertical-tabs test suites
- visual verification of the new Summary mode in a real app session
## Follow-ups
- Add interactive branch-line affordances (for example, opening code review from a branch line) if product later wants child-level actions
- Consider a compact Summary representation if product later decides Summary should coexist with `Density`
- Expand branch-context contribution beyond terminal panes if more pane types gain stable repo / branch metadata
- Consolidate focused-session and Summary badge rendering around a single shared visual badge API once both paths exist
+63
View File
@@ -0,0 +1,63 @@
# APP-3877: Tech Spec — Prep 1: TextureCache Cascade Eviction
## Context
This is the first preparatory changeset for the image cache debounce strategy described in the broader APP-3877 plan. It establishes the per-size eviction API on `ImageCache` and verifies that GPU memory is freed correctly when an `ImageCache` entry is dropped.
### Relevant files
- `crates/warpui_core/src/image_cache.rs:799``ImageCache`, stores `RwLock<HashMap<u64, HashMap<RenderedImageCacheKey, Rc<Image>>>>`. Whole-asset eviction exists (`evict_image`); per-size eviction does not yet exist.
- `crates/warpui_core/src/rendering/texture_cache.rs:26``TextureCache<T>`, stores a `Vec<TextureInfo<T>>` where each entry holds `Weak<StaticImage>` and a `last_accessed_frame` counter.
- `crates/warpui_core/src/elements/image.rs:302``Image::paint()` calls `ImageCache::image()`, receives `Rc<Image>`, immediately unwraps the inner `Arc<StaticImage>`, and pushes it to the `Scene`. The `Rc<Image>` is never stored beyond `paint()`.
- `crates/warpui_core/src/scene.rs:109``Scene::Image { asset: Arc<StaticImage> }` — the scene stores a strong reference during the current frame only.
- `crates/warpui/src/rendering/wgpu/renderer/image.rs:53``Pipeline::texture_cache: TextureCache<TextureInfo>`, populated from `scene.images[*].asset` each frame.
### How the cascade currently works
`TextureCache::end_frame()` already evicts entries in two cases:
1. `asset.strong_count() == 0` — the backing `Arc<StaticImage>` has no remaining strong holders; the asset has been dropped entirely.
2. `frame_index - last_accessed_frame >= MAX_UNUSED_FRAMES` — the texture has not been rendered in 10+ frames, regardless of whether the asset is still alive.
For `CacheOption::BySize` resized images, `ImageCache` allocates a fresh `Arc<StaticImage>` per `(asset, size)` entry that is shared with no other subsystem. After `Image::paint()` returns and the scene for that frame is discarded, the sole persistent strong holder is `ImageCache`. Dropping that entry — via either `evict_image` or the new `evict_size` — reduces the strong count to zero, which causes `end_frame()` to evict the corresponding GPU texture on the next frame.
No changes to `TextureCache` are required. The `Weak<StaticImage>` mechanism already provides the correct cascade behavior.
## Proposed changes
### 1. Add `evict_size` to `ImageCache`
Add a private method that removes a single `(cache_key, RenderedImageCacheKey)` entry, cleaning up the outer map if the inner map becomes empty. The main changeset will call this from inside `image()` during its lazy eviction pass.
```rust
fn evict_size(&self, cache_key: u64, rendered_key: RenderedImageCacheKey) {
let mut cache = self.images.write();
if let Some(inner_map) = cache.get_mut(&cache_key) {
inner_map.remove(&rendered_key);
if inner_map.is_empty() {
cache.remove(&cache_key);
}
}
}
```
### 2. Add a test-only `StaticImage` constructor
`StaticImage` currently has no public constructor. `TextureCache` tests need `Arc<StaticImage>` to exercise `get_or_insert_by_asset`. Add a `pub(crate) mod test_utils` under `#[cfg(test)]` in `image_cache.rs` with a `make_static_image(width, height)` helper.
### 3. Add tests
**`image_cache_tests.rs`**
- `test_evict_image_drops_arc_for_resized_bysize`: load a `BySize` image at a size different from the source (forcing a resize and a fresh `Arc`), capture a `Weak<StaticImage>`, drop the local `Rc<Image>` clone, assert `strong_count == 1` (only `ImageCache` holds it), call `evict_image`, assert `strong_count == 0`.
- `test_evict_size_drops_arc_for_single_entry`: same setup with two different sizes; call `evict_size` for one entry and assert that only the targeted size's `Arc` is released while the other remains alive.
**`texture_cache_tests.rs`** (new file, wired in via `#[path]`)
- `test_end_frame_evicts_when_asset_dropped`: insert an asset, drop the `Arc`, call `end_frame`, verify the entry count drops to zero.
- `test_end_frame_evicts_after_max_unused_frames`: insert an asset, keep the `Arc` alive, advance `frame_index` past `MAX_UNUSED_FRAMES` by calling `end_frame` repeatedly without re-accessing the texture, verify the entry is evicted.
- `test_end_frame_retains_recently_used_entry`: insert, re-access each frame, advance past `MAX_UNUSED_FRAMES`, verify the texture is retained because it was used.
## Testing and validation
All new behavior is verified by unit tests above. No rendering hardware is required — `TextureCache<T>` is generic and tests use `T = ()`. Run with:
```
cargo nextest run -p warpui_core
```
+133
View File
@@ -0,0 +1,133 @@
# Sidecars for Tab Config Menu
Linear: [APP-3886](https://linear.app/warpdotdev/issue/APP-3886/sidecars-for-tab-config-menu)
Figma: [House of Agents sidecar design](https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7877-45160&m=dev)
## Summary
Add a sidecar panel to every actionable item in the tab configs menu that exposes per-item actions: **Make default**, **Edit config**, and **Remove** (**Edit config** and **Remove** will not be shown for non-user-created tab-configs (i.e. terminal & agent)). Unify the "Make default" choice with the existing "Default mode for new sessions" setting so there is a single source of truth for what Cmd+T does. Flatten the Windows-only Terminal shell submenu into top-level menu items so every item can have a sidecar.
## Problem
Users can create tab configs but have no lightweight way to manage them from the menu. Editing requires manually navigating to `~/.warp/tab_configs/`, there is no way to delete a config from the UI, and there is no way to set a tab config as the default Cmd+T action. The "Default mode for new sessions" setting only offers Terminal and Agent, with no way to select a tab config.
## Goals
- Surface "Make default", "Edit config", and "Remove" actions via a sidecar on menu items.
- Extend the "Default mode for new sessions" setting to include all tab configs, creating a single source of truth for what Cmd+T opens.
- Flatten the Windows Terminal shell submenu into top-level items so every item gets uniform sidecar treatment.
- Respect the correct editor setting (`open_code_panels_file_editor`) when opening tab config files.
## Non-goals
- Inline editing of tab config TOML content from within the sidecar.
- Drag-to-reorder tab configs in the menu.
- A new UI for creating tab configs (the existing "New Tab Config" and "New worktree config" flows remain).
- A sidecar for the "New worktree config" submenu item (it keeps its existing repo-list sidecar and is not something a user would set as default).
- A sidecar for the "New Tab Config" item (it's a creation action, not a selectable default).
## User experience
### Menu structure changes
The tab configs menu items become:
1. **Agent** (if AI enabled)
2. **Terminal** (on all platforms; on Windows this will be multiple items for each terminal type, including the default terminal item)
3. **Additional shell variants** (Windows only — e.g., PowerShell, CMD — listed as individual top-level items instead of nested in a Terminal submenu)
4. **Cloud Oz** (if AI enabled + AgentView + CloudMode flags)
5. **User tab configs** (from `~/.warp/tab_configs/` and `~/.warp/default_tab_configs/`)
6. Separator
7. **New worktree config** (submenu with repo-list sidecar — unchanged)
8. **New Tab Config** (creation action — unchanged)
Items 15 each get the new action sidecar. Items 78 do not.
### Sidecar trigger
When a user hovers over an actionable item (items 15 above), a sidecar panel appears to the right of the menu, following the existing sidecar positioning and safe-zone pattern. The sidecar replaces itself as the user moves between items.
When the user hovers over a separator, "New worktree config", or "New Tab Config", the action sidecar hides. "New worktree config" continues to show its own repo-list sidecar as it does today.
### Sidecar layout
The sidecar panel contains:
1. **Title**: The item name (e.g., "Terminal", "PowerShell", "Oz", or the tab config's `name` field).
2. **Subtitle**: For user tab configs, the full path to the `.toml` source file displayed in a subdued/secondary text style (e.g., `~/.warp/tab_configs/my_config.toml`). For built-in items, no subtitle.
3. **Buttons**: Varies by item type (see below).
### Sidecar buttons by item type
**Built-in items (Terminal, shell variants, Oz, Cloud Oz):**
- "Make default" only.
**User tab configs:**
- "Make default"
- "Edit config"
- "Remove" (destructive/red styling)
### "Make default" behavior
Sets the selected item as the action triggered by Cmd+T (`workspace:new_tab` keybinding).
This directly writes the **"Default mode for new sessions"** setting (see below). The sidecar choice and the settings page dropdown are always in sync — changing one updates the other.
- **Terminal**: Sets default to `Terminal`.
- **A shell variant** (Windows): Sets default to `Terminal`. (Shell-specific defaults are out of scope; this just ensures Cmd+T opens a terminal.)
- **Oz**: Sets default to `Agent`.
- **Cloud Oz**: Sets default to `Cloud Agent` (i.e. the tab you open to when you click this option now)
- **A user tab config**: Sets default to that config (identified by source file path).
When Cmd+T fires:
- If default is `Terminal` → open a terminal tab (existing behavior).
- If default is `Agent` → open an agent tab (existing behavior).
- If default is a tab config → open that config using the same flow as clicking it in the menu (params modal if it has params, direct open if not).
### Extending "Default mode for new sessions"
The existing `DefaultSessionMode` enum (`Terminal | Agent`) is extended to also include a tab-config variant that references a config by source file path. The settings dropdown in the AI/session settings page lists:
- Terminal
- Agent
- Every currently-loaded tab config (by name)
Selecting a tab config from the dropdown sets it as the Cmd+T default, same as clicking "Make default" in the sidecar. The dropdown should update dynamically as tab configs are added/removed (driven by the filesystem watcher).
If the selected tab config is deleted (file removed from disk), the setting falls back to `Terminal` (or whatever `DefaultSessionMode`'s default is).
### Visual indicator for current default
The menu item that is currently the default shows a **Cmd+T keybinding indicator** (matching the Figma design). This is the same keybinding hint pattern used elsewhere in the menu. Only one item displays this indicator at a time — whichever item is the current default.
### "Edit config" behavior
Opens the tab config `.toml` file in the user's configured editor, respecting the **"Choose an editor to open files from the code review panel, project explorer, and global search"** setting (`open_code_panels_file_editor`), **not** the "Choose an editor to open file links" setting (`open_file_editor`).
When the editor resolves to Warp, the file opens in a new tab with the file tree open and focused on the config file.
This editor-setting fix also applies to:
- The existing **"New Tab Config"** button at the bottom of the menu.
- The **"Open file"** action from tab config error toasts (`OpenTabConfigErrorFile`).
### "Remove" behavior
1. A Warp modal (following existing modal prior art, e.g., the close-session confirmation dialog) appears asking the user to confirm deletion, indicating the config name and that the file will be permanently deleted.
2. On confirm, the `.toml` file is deleted from disk.
3. The filesystem watcher picks up the deletion and removes the config from the menu.
4. If the removed config was the current default, the setting reverts to `Terminal`.
5. On cancel, nothing happens.
### Edge cases
- **Default config file deleted externally**: When Cmd+T fires and the stored config path no longer exists, clear the default silently and fall through to `Terminal` behavior. The next watcher event will also clean up the settings dropdown.
- **Config parse error after edit**: Handled by the existing error toast mechanism. The sidecar does not need special handling.
- **Menu keyboard navigation**: Arrow keys update the sidecar to reflect the currently-selected item, matching the existing sidecar behavior.
- **Sidecar safe zone**: Uses the existing safe-zone mechanism so the mouse can travel from the menu to the sidecar without it closing.
- **Multiple configs with the same name**: Each is listed separately; the default is identified by file path, not name, so there is no ambiguity.
- **Windows Terminal flattening**: Removing the Terminal submenu means `NewSessionSidecarKind::Terminal` and `configure_terminal_new_session_sidecar` are no longer needed. The shell items become regular top-level menu entries.
## Success criteria
1. Hovering over any actionable item (Terminal, shell variants, Oz, user tab configs) in the tab configs menu shows a sidecar.
2. The sidecar for user tab configs shows the config name, file path, and three buttons (Make default, Edit config, Remove).
3. The sidecar for built-in items shows only "Make default".
4. Clicking "Make default" updates the "Default mode for new sessions" setting.
5. The settings dropdown in the AI/session settings page lists Terminal, Agent, and all loaded tab configs, and stays in sync with sidecar choices.
6. Pressing Cmd+T opens the currently-configured default (terminal, agent, or tab config).
7. The default persists across app restarts.
8. Clicking "Edit config" opens the `.toml` file using `open_code_panels_file_editor`.
9. Clicking "Remove" shows a confirmation dialog; confirming deletes the file and removes the config from the menu.
10. If the default config is removed (via UI or externally), Cmd+T reverts to Terminal.
11. Keyboard navigation in the menu updates the sidecar.
12. "New Tab Config" and tab config error toasts also respect `open_code_panels_file_editor`.
13. On Windows, Terminal shell variants appear as individual top-level items instead of a submenu.
## Validation
- **Unit tests**: `DefaultSessionMode` extension stores and retrieves tab config paths correctly. Falls back to `Terminal` when the stored path doesn't exist. `resolve_file_target` for tab config files uses `open_code_panels_file_editor`.
- **Manual / computer-use verification**: Open the tab configs menu, hover over items, verify sidecar appears with correct content. Exercise Make default, Edit config, and Remove. Verify Cmd+T behavior changes. Verify settings dropdown stays in sync. Verify Cmd+T reverts after removing the default config.
- **Regression**: Existing "New worktree config" sidecar behavior is unaffected. Existing Cmd+T behavior is unchanged when no custom default is set.
+334
View File
@@ -0,0 +1,334 @@
# Sidecars for Tab Config Menu — Tech Spec
Linear: [APP-3886](https://linear.app/warpdotdev/issue/APP-3886/sidecars-for-tab-config-menu)
Product spec: `specs/APP-3886/PRODUCT.md`
## Problem
The tab configs menu has no per-item management actions. Users cannot set a default, edit, or remove tab configs from within the menu. The `DefaultSessionMode` setting only supports `Terminal | Agent`, with no way to select a tab config. The sidecar infrastructure exists for worktree/shell submenus but not for action panels.
## Relevant code
**Tab configs menu & sidecar:**
- `app/src/workspace/view.rs:5159``unified_new_session_menu_items()` builds the menu (shared by both horizontal and vertical tabs)
- `app/src/workspace/view.rs:5280``open_tab_configs_menu()` opens the menu, takes `is_vertical_tabs` param for width
- `app/src/workspace/view.rs:7838``update_new_session_sidecar()` dispatches sidecar content based on hovered item
- `app/src/workspace/view.rs:7675``configure_terminal_new_session_sidecar()` (Windows-only, to be removed)
- `app/src/workspace/view.rs:7731``configure_worktree_new_session_sidecar()`
- `app/src/workspace/view.rs:20291` — dropdown menu overlay rendering (different anchoring for vertical vs horizontal)
- `app/src/workspace/view.rs:20331` — sidecar overlay rendering (shared by both modes, anchored to hovered item label)
- `app/src/workspace/view.rs:966``new_session_sidecar_menu` field (existing `Menu`-based sidecar)
- `app/src/workspace/view.rs:1650` — sidecar menu construction in `build_menus()`
- `app/src/workspace/view/vertical_tabs.rs:1031``render_new_tab_button()` for vertical tabs (dispatches same `ToggleNewSessionMenu` action)
- `app/src/workspace/view/vertical_tabs.rs:766``VERTICAL_TABS_ADD_TAB_POSITION_ID`
**Tab config data model:**
- `app/src/tab_configs/tab_config.rs:128``TabConfig` struct (no `source_path` field)
- `app/src/user_config/util.rs:167``parse_tab_config_dir_entry()` (path known here, only stored for errors)
- `app/src/user_config/mod.rs:105``WarpConfig::tab_configs()` accessor
- `app/src/user_config/native.rs:248``load_tab_configs()` and filesystem watcher
**DefaultSessionMode setting:**
- `app/src/settings/ai.rs:253``DefaultSessionMode` enum (`Terminal | Agent`, derives `Copy`, `EnumIter`)
- `app/src/settings/ai.rs:1087` — stored as `default_session_mode_internal` in `AISettings`
- `app/src/settings/ai.rs:1227``AISettings::default_session_mode()` accessor (gates on AI enabled)
- `app/src/settings_view/features_page.rs:3273``update_default_session_mode_dropdown()` builds dropdown from `iter()`
**Cmd+T flow:**
- `app/src/workspace/view.rs:18317``AddDefaultTab` handler (the Cmd+T entry point; routes based on `DefaultSessionMode`)
- `app/src/app_menus.rs:1084``open_new_default_tab_or_window()` (macOS native menu callback; always dispatches `CustomAction::NewTab``AddDefaultTab`)
- `app/src/workspace/view.rs:9637``add_new_session_tab_with_default_mode()` (checks `DefaultSessionMode`)
- `app/src/workspace/view.rs:5430``open_tab_config()` (opens a tab config, shows params modal if needed)
**Editor setting for opening files:**
- `app/src/util/openable_file_type.rs:97``resolve_file_target()` (uses `open_file_editor`)
- `app/src/util/openable_file_type.rs:112``resolve_file_target_with_editor_choice()` (accepts explicit editor choice)
- `app/src/util/file/external_editor/settings.rs:66``open_code_panels_file_editor` setting
- `app/src/workspace/view.rs:5469``create_and_open_new_tab_config()` (currently uses wrong setting)
**Confirmation dialog prior art:**
- `app/src/workspace/close_session_confirmation_dialog.rs``CloseSessionConfirmationDialog` pattern
**Windows shell listing:**
- `app/src/workspace/view.rs:5191``#[cfg(target_os = "windows")]` Terminal submenu parent
- `app/src/workspace/view.rs:7675``configure_terminal_new_session_sidecar()` (lists shells)
## Current state
### Menu structure
`unified_new_session_menu_items()` builds the menu. This function is shared across both horizontal and vertical tab bar modes — the same items appear regardless of layout. The menu is opened via `open_tab_configs_menu()` which accepts `is_vertical_tabs` only to adjust the menu width (268px for vertical, default for horizontal).
Current order:
1. Agent (with Cmd+T shortcut label if default is Agent)
2. Terminal (submenu on Windows, regular item elsewhere; Cmd+T shortcut if default is Terminal)
3. Cloud Oz
4. User tab configs (from `WarpConfig::tab_configs()`)
5. Separator + "New worktree config" (submenu) + "New Tab Config"
### Sidecar positioning (horizontal vs vertical)
The dropdown menu is positioned differently in each mode:
- **Horizontal tabs**: anchored to `NEW_TAB_BUTTON_POSITION_ID` (lower-left of the + button)
- **Vertical tabs**: anchored to `VERTICAL_TABS_ADD_TAB_POSITION_ID` (below the + button)
The sidecar overlay anchors to the hovered menu item's label text, so it's positioned the same way in both modes. The new action sidecar will use the same anchoring mechanism and work in both modes without special handling.
Note: The vertical tabs panel has its own separate "detail sidecar" (`render_detail_sidecar` in `vertical_tabs.rs`) that shows pane details when hovering rows in the panel. This is a completely different system from the new-session menu sidecar and is unrelated to this feature.
### DefaultSessionMode
A `Copy + EnumIter` enum stored in `AISettings`. The settings dropdown iterates it. `default_session_mode()` returns `Terminal` when AI is disabled, otherwise returns the stored value.
### TabConfig
Has no `source_path` field. The file path is available during parsing but discarded for successfully parsed configs.
## Proposed changes
### 1. Add `source_path` to `TabConfig`
**File:** `app/src/tab_configs/tab_config.rs`
Add a skipped field:
```rust
#[serde(skip)]
pub source_path: Option<PathBuf>,
```
**File:** `app/src/user_config/util.rs`
In `parse_tab_config_dir_entry()`, populate `source_path` on successfully parsed configs:
```rust
Some(parsed.map(|mut config| {
config.source_path = Some(item.path().into());
config
}).map_err(...))
```
### 2. Extend `DefaultSessionMode` with `TabConfig` and `CloudAgent` variants + companion path setting
**File:** `app/src/settings/ai.rs`
Add `CloudAgent` and `TabConfig` variants to `DefaultSessionMode`:
```rust
pub enum DefaultSessionMode {
#[default]
Terminal,
Agent,
CloudAgent,
TabConfig,
}
```
This preserves `Copy` and `EnumIter`. No `Shell` variant is needed — shell-specific defaults are handled by the existing `NewSessionShell` setting (see "Setting interaction" below).
Add a companion setting in `AISettings` to store the tab config file path:
```rust
default_tab_config_path: DefaultTabConfigPath {
type: String,
default: String::new(),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never,
private: false,
hierarchy: "general",
}
```
`SyncToCloud::Never` because tab config file paths are machine-local.
The companion is only read when mode is `TabConfig`. For all other modes it's ignored.
Add a helper on `AISettings`:
```rust
fn resolved_default_tab_config(&self, app: &AppContext) -> Option<TabConfig>
```
Reads `default_tab_config_path`, finds the matching `TabConfig` in `WarpConfig::tab_configs()` by `source_path`, and returns it. Returns `None` if the path is empty, the file doesn't exist, or the config isn't loaded (triggers fallback to `Terminal`).
### Setting interaction: `DefaultSessionMode` × `NewSessionShell`
Two settings, two concerns:
- **`DefaultSessionMode`** (`Terminal | Agent | CloudAgent | TabConfig`) — controls *what kind of thing* Cmd+T opens.
- **`NewSessionShell`** (existing, in `SessionSettings`) — controls *which shell binary* a terminal session uses. Only relevant when the mode resolves to opening a terminal.
How they interact on Cmd+T:
- `Terminal` → opens a terminal tab using whatever `NewSessionShell` is set to.
- `Agent` → opens agent view. `NewSessionShell` irrelevant.
- `CloudAgent` → opens ambient agent tab. `NewSessionShell` irrelevant.
- `TabConfig` → opens the stored tab config. `NewSessionShell` irrelevant (config defines its own panes/commands).
"Make default" from the sidecar:
- **Terminal item** → sets `DefaultSessionMode::Terminal`. Doesn't touch `NewSessionShell`.
- **A specific shell** (Windows, e.g., PowerShell) → sets `DefaultSessionMode::Terminal` *and* updates `NewSessionShell` to that shell. Now Cmd+T → terminal → PowerShell.
- **Agent / Cloud Oz** → sets `DefaultSessionMode` to `Agent` / `CloudAgent`. Doesn't touch `NewSessionShell`.
- **A user tab config** → sets `DefaultSessionMode::TabConfig` + stores config path in `default_tab_config_path`.
Key invariant: `NewSessionShell` is *always* the authority for which shell a terminal uses. `DefaultSessionMode` never duplicates that.
### 3. Create action sidecar render function
**New file:** `app/src/tab_configs/action_sidecar.rs`
A free function `render_action_sidecar()` (not a `View`) that returns `Box<dyn Element>`. Called directly from the Workspace render method. This is simpler than a View since the sidecar has no internal state — it just reads the current item and settings to produce an element tree.
The function takes a struct describing what to show:
```rust
enum SidecarItemKind {
BuiltIn { name: String, default_mode: DefaultSessionMode, shell: Option<AvailableShell> },
UserTabConfig { config: TabConfig },
}
```
Button clicks dispatch `WorkspaceAction` variants directly (`TabConfigSidecarMakeDefault`, `TabConfigSidecarEditConfig`, `TabConfigSidecarRemoveConfig`), which the Workspace handles inline in `handle_action`.
**File:** `app/src/tab_configs/mod.rs` — add `pub(crate) mod action_sidecar;`
### 4. Add `RemoveTabConfigConfirmationDialog`
**New file:** `app/src/tab_configs/remove_confirmation_dialog.rs`
Follows the `CloseSessionConfirmationDialog` pattern (`app/src/workspace/close_session_confirmation_dialog.rs`):
- Title: "Remove tab config?"
- Body: "This will permanently delete {config_name} ({file_path})."
- Buttons: Cancel / Remove (destructive)
Events: `RemoveTabConfigConfirmationEvent::Confirm { path } | Cancel`
On confirm, delete the file from disk. The filesystem watcher handles menu refresh. If the removed config was the default, clear `default_tab_config_path` and set `DefaultSessionMode` back to `Terminal`.
### 5. Wire up action sidecar in Workspace
**File:** `app/src/workspace/view.rs`
Add new fields to `Workspace`:
```rust
tab_config_action_sidecar_item: Option<SidecarItemKind>,
tab_config_action_sidecar_mouse_states: SidecarMouseStates,
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
```
The sidecar is shown when `tab_config_action_sidecar_item` is `Some`. No separate `bool` is needed.
**In `update_new_session_sidecar()`**: Extend the match to handle all actionable items (Terminal, shell variants, Agent, Cloud Oz, user tab configs). For these, set `tab_config_action_sidecar_item` to `Some(item_kind)`. For "New worktree config", keep existing behavior. For "New Tab Config" and separators, set it to `None`.
**In `render()`**: Add a second positioned overlay that calls `render_action_sidecar()` when `tab_config_action_sidecar_item` is `Some` (same positioning logic as the existing sidecar). This overlay uses the same `OffsetPositioning::offset_from_save_position_element` anchored to the hovered menu item label, so it works identically in both horizontal and vertical tabs modes.
**Event handlers**: `WorkspaceAction::TabConfigSidecar*` variants are handled directly in `handle_action`. Subscribe to `RemoveTabConfigConfirmationEvent`.
### 6. Rename `AddTab``AddDefaultTab` and route Cmd+T through it
**Files:** `app/src/workspace/action.rs`, `app/src/workspace/view.rs`, `app/src/workspace/mod.rs`, `app/src/app_menus.rs`
Rename `WorkspaceAction::AddTab` to `WorkspaceAction::AddDefaultTab` to clearly distinguish it from the explicit `AddTerminalTab` and `AddAgentTab` actions:
- `AddDefaultTab` = "open whatever the user's default is" (the Cmd+T action). Checks `DefaultSessionMode` and routes accordingly.
- `AddTerminalTab` = "always open a terminal, ignoring default" (explicit override, has its own keybinding).
- `AddAgentTab` = "always open an agent tab" (explicit override).
The `AddDefaultTab` handler checks the effective `DefaultSessionMode`:
1. `TabConfig` → call `resolved_default_tab_config()`. If found, call `open_tab_config()`. If missing, clear to `Terminal` and fall through.
2. `CloudAgent` → call `add_ambient_agent_tab()`.
3. `Agent` / `Terminal` → existing behavior (`add_terminal_tab` internally respects Agent mode).
**macOS native menu (Cmd+T routing):**
On macOS, Cmd+T is handled by the native menu system, not the WarpUI keybinding system. The native menu's "New Terminal Tab" item holds Cmd+T for non-Agent modes; "New Agent Tab" holds it for Agent mode. Both callbacks ultimately dispatch through `CustomAction::NewTab``AddDefaultTab`.
The callback `open_new_default_tab_or_window` (`app/src/app_menus.rs`) always dispatches `CustomAction::NewTab`, which the binding system maps to `WorkspaceAction::AddDefaultTab`. This means Cmd+T always goes through the `AddDefaultTab` handler regardless of the current mode — the handler is the single place that routes based on `DefaultSessionMode`.
### 7. Flatten Windows Terminal shell items
**File:** `app/src/workspace/view.rs`
In `unified_new_session_menu_items()`, replace the `#[cfg(target_os = "windows")]` block (`view.rs:5191`) that creates a submenu parent with code that lists each `AvailableShell` as an individual top-level `MenuItem` with `AddTabWithShell` action.
Remove `NewSessionSidecarKind::Terminal`, `configure_terminal_new_session_sidecar()`, and related dead code.
### 8. Fix editor setting for tab config file opens
**File:** `app/src/workspace/view.rs`
In `create_and_open_new_tab_config()` (`view.rs:5469`), change:
```rust
let target = resolve_file_target(&path, settings, None);
```
to:
```rust
let target = resolve_file_target_with_editor_choice(
&path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
```
Apply the same fix to `save_current_tab_as_new_config()` (`view.rs:5500`) and the `OpenTabConfigErrorFile` handler (`view.rs:18250`).
The "Edit config" button in the action sidecar will also use `resolve_file_target_with_editor_choice` with `open_code_panels_file_editor`.
### 9. Update settings dropdown
**File:** `app/src/settings_view/features_page.rs`
Replace the `default_session_mode_dropdown: ViewHandle<Dropdown<FeaturesPageAction>>` (`features_page.rs:1222`) with a `FilterableDropdown<FeaturesPageAction>`. The `FilterableDropdown` component (`app/src/view_components/filterable_dropdown.rs`) already supports search/filter, arrow key navigation, and the same `DropdownItem` API — it wraps a `Menu` with a search editor, so users can type to narrow down the list when many tab configs are present.
In `update_default_session_mode_dropdown()` (`features_page.rs:3273`), after the `DefaultSessionMode::iter()` items (Terminal, Agent), append an item for each loaded tab config from `WarpConfig::tab_configs()`, using the config name as the display label and dispatching a new `FeaturesPageAction` variant that sets both `DefaultSessionMode::TabConfig` and `default_tab_config_path`.
Subscribe to `WarpConfigUpdateEvent::TabConfigs` to rebuild the dropdown when configs change.
### 10. Cmd+T keybinding indicator in menu
**File:** `app/src/workspace/view.rs`
In `unified_new_session_menu_items()`, the Cmd+T shortcut label is currently assigned to either the Agent or Terminal item based on `default_is_agent`. Replace this with a comprehensive check of the effective default:
1. `DefaultSessionMode::TabConfig` → attach the shortcut label to the matching tab config's menu item (matched by `source_path`).
2. `DefaultSessionMode::Agent` → attach to the Agent item (existing behavior).
3. `DefaultSessionMode::CloudAgent` → attach to the Cloud Oz item.
4. `DefaultSessionMode::Terminal` → attach to the "Terminal" item.
Note: Per-shell shortcut label logic (i.e., showing Cmd+T on the specific shell item when a shell is made default on Windows) is not implemented in v1. The shortcut label always appears on the "Terminal" item when mode is `Terminal`, regardless of which shell was selected via "Make default".
## End-to-end flow
### Hovering a tab config in the menu
1. User hovers over a tab config item in the dropdown (works identically in horizontal and vertical tabs).
2. `handle_new_session_menu_event``ItemHovered``update_new_session_sidecar()`.
3. Match identifies the item as a user tab config (by checking if the action is `SelectTabConfig`).
4. Populates `tab_config_action_sidecar` with the config's name, path, and all three buttons.
5. Sets `show_tab_config_action_sidecar = true`, hides `show_new_session_sidecar`.
6. Render places the sidecar overlay anchored to the hovered item.
### "Make default" for a tab config
1. User clicks "Make default" in the action sidecar.
2. Sidecar dispatches `WorkspaceAction::TabConfigSidecarMakeDefault { mode: TabConfig, tab_config_path: Some(path), shell: None }`.
3. Workspace handler sets `default_session_mode_internal` to `TabConfig` and `default_tab_config_path` to the file path.
4. Menu closes. Next time it opens, the Cmd+T shortcut label appears on that config's menu item.
5. Settings dropdown updates via the `AISettingsChangedEvent` subscription.
### Cmd+T with tab config default
1. User presses Cmd+T → native menu dispatches `CustomAction::NewTab``AddDefaultTab` action.
2. Handler checks `DefaultSessionMode::TabConfig`.
3. Reads `default_tab_config_path`, finds matching config in `WarpConfig::tab_configs()` via `resolved_default_tab_config()`.
4. Calls `open_tab_config()` → shows params modal if needed, else opens directly.
5. If config file is missing, clears settings to `Terminal` and opens a normal terminal tab.
### "Remove" flow
1. User clicks "Remove" in the sidecar.
2. Sidecar dispatches `WorkspaceAction::TabConfigSidecarRemoveConfig { name, path }`.
3. Workspace opens `RemoveTabConfigConfirmationDialog` with the config name and path.
4. User confirms → dialog emits `Confirm { path }`.
5. Handler deletes the file. If removed config was the default, clears `default_tab_config_path` and sets mode to `Terminal`.
6. Filesystem watcher reloads configs; menu updates on next open.
## Risks and mitigations
- **Backward compat for `DefaultSessionMode` serialization**: Adding a `TabConfig` variant changes serialized values. Old clients reading a `TabConfig` value will fail to deserialize and fall back to the default (`Terminal`). This is acceptable — the worst case is losing the default preference on downgrade.
- **Race between file deletion and watcher**: After "Remove" deletes the file, there's a brief window where the config is still in `WarpConfig::tab_configs()`. The watcher debounce handles this. The sidecar closes the menu on removal, so the user won't see a stale entry.
- **Large number of tab configs**: The settings dropdown and menu will list all configs. No pagination is needed for v1, but configs with identical names are disambiguated by file path in the sidecar subtitle.
## Testing and validation
- **Unit tests**:
- `TabConfig.source_path` is populated after parsing.
- `DefaultSessionMode::TabConfig` + `default_tab_config_path` round-trips through serialization.
- `resolved_default_tab_config()` returns `None` when path is empty, missing, or not in loaded configs.
- `resolve_file_target_with_editor_choice` is used with `open_code_panels_file_editor` for all tab config file opens.
- **Integration / computer-use**:
- **Both horizontal and vertical tabs**: Open the tab configs menu in each mode, hover items, verify sidecar appears and is positioned correctly.
- Make default → verify Cmd+T behavior and settings dropdown sync.
- Edit config → verify correct editor opens.
- Remove → verify confirmation dialog, file deletion, menu update, and default fallback.
- Keyboard navigation through menu → sidecar updates.
- Vertical tabs: verify the action sidecar doesn't conflict with the vertical tabs detail sidecar (they are independent systems).
- **Regression**:
- "New worktree config" repo-list sidecar unchanged.
- Existing `DefaultSessionMode::Terminal` and `Agent` behavior unchanged.
- Vertical tabs detail sidecar (hover-to-preview pane details) unaffected.
## Follow-ups
- Potential for tab config reordering in the menu.
- Richer sidecar content (preview of pane layout, param summary).
+108
View File
@@ -0,0 +1,108 @@
# CLI Agent Image Paste
## Summary
Allow users to paste screenshots into the CLI agent rich input (e.g. when composing prompts for Claude Code, Gemini CLI, Codex) and have them delivered to the CLI agent as images. Images appear as removable chips in the rich input before submission, matching the existing agent mode UX.
## Problem
When using CLI agents like Claude Code through Warp's rich input, users cannot share visual context (screenshots, UI mockups, error dialogs) with the agent. The only workaround is to save the image to disk, note the file path, and manually type a reference to it — which breaks the conversational flow.
Warp's agent mode already supports pasting images as attachment chips, but the CLI agent rich input did not render chips or deliver images on submission.
## Goals
- Users can paste screenshots into the CLI agent rich input and see them as removable image chips.
- On submission, attached images are delivered to the CLI agent so it can actually see them.
- The chip UX (add, remove, limits) matches existing agent mode behavior with no new UI to learn.
- Works with any CLI agent that supports clipboard image paste out of the box.
## Non-goals
- Drag-and-drop image files into the CLI agent rich input (should work for free via existing infrastructure, but not explicitly targeted or tested).
- Supporting CLI agents that cannot read images from the clipboard (e.g. agents with no Ctrl+V image support).
- Inline image preview/thumbnails within the rich input — chips show filename only, matching agent mode.
- Sending images to Warp's own agent mode backend through this path (that uses a separate server-side flow).
## Figma
Figma: none provided. The UI reuses existing attachment chip rendering from agent mode — no new visual design needed.
## User experience
### Attaching images
1. User opens the CLI agent rich input (Ctrl+G or footer button) while a CLI agent is running.
2. User takes a screenshot or copies an image to the clipboard.
3. User pastes (Cmd+V / Ctrl+V) into the rich input.
4. An image chip appears above the editor, showing the filename (e.g. `pasted-image-1713121234.png`) with an × button to remove it.
5. Multiple images can be pasted. Each appears as a separate chip. The same per-query and per-conversation limits from agent mode apply.
### Removing images
- Clicking the × on a chip removes that image. This uses the existing `DeleteAttachment` action — identical to agent mode.
### Submitting with images
- When the user submits the prompt (Enter), images are delivered to the CLI agent first, followed by the text prompt.
- The image chips disappear after submission.
- If no images are attached, submission behaves exactly as before.
### Delivery mechanism
Images are delivered by simulating what a user would do manually: for each attached image, Warp writes the image data to the system clipboard and sends Ctrl+V (`0x16`) to the PTY. The CLI agent (e.g. Claude Code) reads the image from the clipboard natively.
- A 500ms delay is inserted between each image paste to give the CLI agent time to read from the clipboard before it's overwritten with the next image. This was tested empirically in prototype - we need a relatively significant delay here for the CLI agent to pick up the paste correctly.
- After all images are pasted, the text prompt is sent using the agent-specific submission strategy (inline, bracketed paste, or delayed enter).
## Alternate approaches considered
### 1. Save images to temp files and include file paths in the prompt text
The first approach implemented was to decode each attached image from base64, write it to a temp file on disk (e.g. `/var/folders/.../warp-cli-image-1776199745040956000.png`), and prepend a `[Attached images: /path/to/file]` block to the prompt text.
**Why we didn't choose this:**
- The temp file paths are ugly and OS-specific (`/var/folders/...` on macOS).
- CLI agent then has to go read the files from given paths.
- Requires cleanup logic (tracking temp files, deleting on session end).
- Not how a user would naturally share an image with a CLI agent.
### 2. Don't intercept image paste at all — let raw Ctrl+V pass through to the PTY
Instead of showing chips, let the paste keypress go directly to the CLI agent's PTY so its own image handling takes over.
**Why we didn't choose this:**
- Loses the chip UI entirely — no visual feedback before submission, no ability to remove an accidentally pasted image, no multi-image staging.
### 3. Encode images inline in the prompt (base64 or data URI)
Embed the image data directly in the prompt text sent to the PTY.
**Not considered seriously because:**
- No CLI agent parses inline base64 image data from stdin.
- Would produce enormous, unreadable prompt text.
## Success criteria
1. Pasting a screenshot (Cmd+V) into the CLI agent rich input produces an image chip above the editor.
2. The chip shows a filename and an × close button.
3. Clicking × removes the chip and the underlying pending attachment.
4. Submitting with one attached image: Claude Code shows `[Image #1]` in its prompt and can describe the image content.
5. Submitting with two attached images: Claude Code shows `[Image #1] [Image #2]` and can distinguish between them.
6. Submitting with no attached images behaves identically to the previous behavior (no regression).
7. Image attachment limits (per-query and per-conversation) are enforced, with toast messages for excess images.
## Validation
- **Manual test — single image**: Paste a screenshot, type a prompt referencing the image, submit. Verify Claude Code sees and describes the image correctly.
- **Manual test — multiple images**: Paste two different screenshots, submit. Verify Claude Code sees both as distinct images (`[Image #1]` and `[Image #2]`).
- **Manual test — remove chip**: Paste an image, click ×, submit. Verify no image is sent to the CLI agent.
- **Manual test — no images**: Submit a text-only prompt. Verify behavior is unchanged.
- **Manual test — limits**: Paste more images than the per-query limit. Verify a toast appears and excess images are not attached.
- **Build verification**: `cargo fmt` and `cargo clippy` pass with no warnings.
## Open questions
- **Delay tuning**: The 500ms delay between image pastes is sufficient for Claude Code but may need adjustment for other CLI agents. Need to explore.
- **Non-image-paste agents**: For CLI agents that don't support Ctrl+V image paste, should we fall back to the file path approach or simply not send images?
Need to check if this applies to any CLI agents.
+119
View File
@@ -0,0 +1,119 @@
# CLI Agent Image Paste — Tech Spec
## Problem
The CLI agent rich input (used for composing prompts to Claude Code, Gemini CLI, etc.) did not support image attachments. The existing image paste infrastructure in Warp's agent mode needed to be extended to (1) render chips in the CLI agent input layout, (2) allow the paste flow to work when CLI agent input is open, and (3) deliver images to the CLI agent on submission by simulating clipboard-based Ctrl+V paste.
## Relevant code
- `app/src/terminal/input/cli_agent.rs` — CLI agent rich input rendering
- `app/src/terminal/input/agent.rs` — Agent mode input rendering (reference for chip layout)
- `app/src/terminal/input.rs` — Paste handling (`process_paste_event`, `can_attach_on_filepaths_paste_or_dragdrop`, `process_and_attach_clipboard_image`, `handle_pasted_or_dragdropped_image_filepaths`)
- `app/src/terminal/view/use_agent_footer/mod.rs``submit_cli_agent_rich_input`, new `paste_images_then_submit_text`
- `app/src/ai/blocklist/context_model.rs``BlocklistAIContextModel`, `PendingAttachment`, `ImageContext`, `pending_images()`, `clear_pending_images()`
- `app/src/ai/agent/mod.rs``ImageContext` struct (base64 data + mime_type + file_name)
- `crates/warpui_core/src/clipboard.rs``Clipboard` trait, `ClipboardContent`, `ImageData`
## Current state
### Before this change
- **Agent mode input** (`render_agent_input`): Renders attachment chips above the editor when `FeatureFlag::ImageAsContext` is enabled and input is in AI mode. Uses `render_attachment_chips()`.
- **CLI agent input** (`render_cli_agent_input`): Renders editor + footer only. No chip rendering.
- **Paste flow**: `process_paste_event``handle_pasted_image_data` / `handle_pasted_or_dragdropped_image_filepaths`. The gating function `can_attach_on_filepaths_paste_or_dragdrop` allows attachment when in agent mode, buffer is empty, or in active agent view — but has no explicit CLI agent input check.
- **Image paste side effect**: Both `process_and_attach_clipboard_image` and `handle_pasted_or_dragdropped_image_filepaths` try to enter the fullscreen agent view via `try_enter_agent_view` when an image is pasted. Irrelevant and incorrect for CLI agent mode.
- **Submission**: `submit_cli_agent_rich_input` sends only text bytes to the PTY. No image handling.
- **Chip removal**: `DeleteAttachment` action on `TerminalView` removes from `ai_context_model.pending_attachments`. Already works regardless of input mode.
- **Chip state**: `Input.attachment_chips` is populated from `BlocklistAIContextEvent::UpdatedPendingContext`. Already fires for any attachment change.
## Proposed changes
### 1. Render attachment chips in CLI agent input
**File**: `app/src/terminal/input/cli_agent.rs`
Add chip rendering above the editor in `render_cli_agent_input`, gated on `FeatureFlag::ImageAsContext`. Uses the same `render_attachment_chips()` + `spacing::UDI_CHIP_MARGIN` pattern as `render_agent_input` in `agent.rs`.
New imports: `FeatureFlag`, `spacing`.
### 2. Allow image paste in CLI agent mode
**File**: `app/src/terminal/input.rs`
- **`can_attach_on_filepaths_paste_or_dragdrop`**: Add early return `true` when `CLIAgentSessionsModel::as_ref(ctx).is_input_open(self.terminal_view_id)`. This makes the intent explicit rather than relying on the implicit AI mode set when CLI agent input opens.
- **`handle_pasted_or_dragdropped_image_filepaths`**: Wrap the `try_enter_agent_view` call in `if !is_cli_agent_input_open` so we skip entering the fullscreen agent view when pasting images in CLI agent mode.
- **`process_and_attach_clipboard_image`**: Same guard — skip `try_enter_agent_view` when CLI agent input is open.
### 3. Deliver images on submission via clipboard simulation
**File**: `app/src/terminal/view/use_agent_footer/mod.rs`
New constant:
- `CLI_AGENT_IMAGE_PASTE_DELAY: Duration = Duration::from_millis(500)` — delay between sequential image pastes to let the CLI agent read from the clipboard.
Modified method — `submit_cli_agent_rich_input`:
- Before sending text, extract `pending_images()` from `ai_context_model` and `clear_pending_images()`.
- Pass the images vec into the new `paste_images_then_submit_text` (instead of directly calling `write_cli_agent_text_then_submit`).
New method — `paste_images_then_submit_text`:
- Recursive: processes one image at a time from the vec.
- For each image: decode base64 → raw bytes, write to system clipboard as `ClipboardContent { images: Some(vec![ImageData { ... }]) }`, send `0x16` (Ctrl+V) to PTY.
- Spawn a timer with `CLI_AGENT_IMAGE_PASTE_DELAY` before processing the next image.
- Base case: when no images remain, fall through to `write_cli_agent_text_then_submit` for the text prompt.
## End-to-end flow
```mermaid
sequenceDiagram
participant User
participant RichInput as CLI Agent Rich Input
participant ContextModel as BlocklistAIContextModel
participant Clipboard as System Clipboard
participant PTY as CLI Agent PTY
User->>RichInput: Cmd+V (paste screenshot)
RichInput->>ContextModel: append_pending_images()
ContextModel-->>RichInput: UpdatedPendingContext event
RichInput->>RichInput: render attachment chips
User->>RichInput: types prompt text
User->>RichInput: Enter (submit)
RichInput->>ContextModel: pending_images() → extract
RichInput->>ContextModel: clear_pending_images()
loop For each image
RichInput->>Clipboard: write(ImageData)
RichInput->>PTY: write 0x16 (Ctrl+V)
Note right of PTY: CLI agent reads clipboard
RichInput->>RichInput: wait 500ms
end
RichInput->>PTY: write text + CR (submit strategy)
```
## Risks and mitigations
- **Clipboard clobbering**: Writing images to the system clipboard overwrites the user's previous clipboard contents. Mitigation: this is the same behavior as if the user manually Ctrl+V'd images. Could save/restore clipboard in a follow-up.
- **Delay sensitivity**: The 500ms delay works for Claude Code but may be insufficient for slower CLI agents or too long for fast ones. Mitigation: the constant is isolated and easy to tune; could be made per-agent in a follow-up.
- **CLI agents without Ctrl+V image support**: Agents that don't handle clipboard image paste will ignore the Ctrl+V or insert garbage. Mitigation: this is a non-goal for v1; the feature targets Claude Code which has known support.
- **Race between image paste and text submit**: If the delay is too short, the text arrives before the CLI agent finishes processing the last image. Mitigation: the 500ms delay was validated empirically with Claude Code.
## Testing and validation
- **Build**: `cargo fmt` and `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` pass clean.
- **Manual — single image**: Paste one screenshot, submit with prompt. Claude Code shows `[Image #1]` and describes the image.
- **Manual — two images**: Paste two different screenshots, submit. Claude Code shows `[Image #1] [Image #2]` and describes each distinctly.
- **Manual — chip removal**: Paste an image, click ×, submit. No image sent to CLI agent.
- **Manual — text only**: Submit without images. No regression in behavior.
- **Manual — agent mode**: Verify existing agent mode image paste still works (no regression from the `can_attach_on_filepaths_paste_or_dragdrop` and `process_and_attach_clipboard_image` changes).
## Follow-ups
- Save and restore clipboard contents after image paste submission.
- Per-agent paste delay tuning (some agents may need more or less than 500ms).
- Fallback strategy for CLI agents that don't support Ctrl+V image paste (e.g. file path references).
- Telemetry for CLI agent image paste (number of images, success rate).
- Drag-and-drop image files into CLI agent rich input (likely works already but needs explicit testing).
+69
View File
@@ -0,0 +1,69 @@
# Always Show Comment Buttons Regardless of AI State
## Summary
Code review comment and add-as-context buttons should always be visible in the code review panel, even when global AI is disabled. When AI is disabled, users can still send comments to CLI agent terminals (Claude Code, Gemini, etc.). The disabled "Send to Agent" button should clearly communicate *why* it is disabled.
## Problem
When the global AI toggle is off (`is_any_ai_enabled` returns false), all comment and add-as-context buttons vanish from the code review panel — header dropdown, per-file headers, and editor gutter buttons. This prevents users from creating or submitting review comments to CLI agents, which do not depend on Warp AI. There is no way to distinguish between "AI is disabled" and "all terminals are busy" when the send button is unavailable.
## Goals
- Comment and add-as-context buttons are always visible when their feature flags are enabled, regardless of AI state.
- When AI is disabled, only CLI agent terminals are considered valid review comment destinations.
- The disabled "Send to Agent" button displays a tooltip that differentiates between "AI is disabled" and "all terminals are busy."
## Non-Goals
- Changing the behavior of the AI toggle itself.
- Enabling non-CLI terminals for review comments when AI is disabled.
- Changing the "Send to Agent" button's enabled/disabled logic beyond what the destination already controls.
## Figma
Figma: none provided
## User Experience
### Button visibility
All of the following buttons are always visible when their respective feature flags are enabled, regardless of whether global AI is on or off:
- **Header dropdown** (wide and compact layouts): The `⋮` dropdown containing "Add comment" and "Add diff set as context."
- **Per-file header**: The per-file "add as context" button.
- **Editor gutter buttons**: Inline comment and add-as-context buttons on diff lines.
- **Editor gutter actions**: The `NewCommentOnLine` and `RequestOpenSavedComment` actions are always available. Comments can always be created; the AI gate applies only at submission time.
### Terminal availability when AI is disabled
When global AI is disabled:
- Only terminals running a CLI agent are considered available for review comments.
- Non-CLI, non-executing Warp terminals are **not** available as destinations.
- If AI is toggled on or off, terminal availability updates immediately.
When global AI is enabled:
- Behavior is unchanged from today. Any idle, non-executing terminal is a valid destination.
### Send button tooltip differentiation
The "Send to Agent" button tooltip communicates the specific reason it is disabled. Priority order:
1. CLI agent destination is selected → existing CLI tooltip (enabled state).
2. AI is disabled and no CLI terminals available → **"AI must be enabled to send comments to Agent"**
3. No AI credits → "Agent code review requires AI credits" (existing).
4. All terminals are busy → **"All terminals are busy"**
5. No sendable comments → existing tooltip.
6. Default → existing tooltip.
### State transitions
- Toggling AI on: terminal availability recomputes immediately; non-CLI terminals become available; tooltip updates.
- Toggling AI off: terminal availability recomputes immediately; non-CLI terminals become unavailable; tooltip changes to the AI-disabled message if no CLI terminals exist.
## Success Criteria
1. With AI disabled: the header dropdown, per-file add-as-context button, and all editor gutter comment/context buttons are visible and functional for creating comments.
2. With AI disabled and a CLI agent running: comments can be sent to the CLI agent terminal.
3. With AI disabled and no CLI agents: the "Send to Agent" button is disabled with tooltip "AI must be enabled to send comments to Agent."
4. With AI enabled and all terminals busy: the "Send to Agent" button is disabled with tooltip "All terminals are busy."
5. Toggling AI on/off updates terminal availability and tooltip text without requiring any other user action.
6. With AI enabled: behavior is identical to current behavior (no regressions).
## Validation
- Manual testing: toggle AI on/off with and without CLI agents running; verify button visibility and tooltip text in each state.
- Code review: confirm all `is_ai_enabled` gates on button visibility are removed.
- Build verification: `cargo check`, `cargo fmt`, WASM build.
## Open Questions
None currently.
+94
View File
@@ -0,0 +1,94 @@
# Always Show Comment Buttons Regardless of AI State — Technical Spec
## Problem
The code review panel gates all comment and add-as-context button visibility on `AISettings::is_any_ai_enabled()`. This hides the buttons entirely when AI is disabled, even though CLI agent terminals (Claude Code, Gemini) can accept review comments without Warp AI. The implementation needs to decouple button visibility from AI state, restrict non-CLI terminal availability when AI is off, and surface the correct reason when the send button is disabled.
## Relevant Code
- `app/src/code_review/code_review_header.rs (127-231)` — header dropdown buttons gated by `is_ai_enabled`
- `app/src/code_review/code_review_view.rs:5175` — per-file "add as context" button gated by `is_ai_enabled`
- `app/src/code/editor/element.rs (418, 516, 959, 1170, 1176, 1315, 1517)``EditorWrapper` field, constructor param, and 5 usage sites
- `app/src/code/editor/view.rs (628, 2216)` — both `EditorWrapper::new` call sites pass `is_ai_enabled`
- `app/src/code/editor/view/actions.rs (1070, 1082)``NewCommentOnLine` and `RequestOpenSavedComment` gated by AI check
- `app/src/workspace/right_panel.rs (341, 1067, 1154, 1184, 1218)``RightPanelView::new`, `route_review_comments`, `is_terminal_available_for_review`, `find_available_terminal_for_review`, `recompute_terminal_availability`
- `app/src/code_review/comment_list_view.rs (864, 888)``send_button_tooltip_text`, `render_send_button`
## Current State
- `is_ai_enabled` is checked by `AISettings::as_ref(ctx).is_any_ai_enabled(ctx)`, which evaluates global toggle + login state + remote session org policy.
- `AISettingsChangedEvent::IsAnyAIEnabled` is auto-generated by the `define_settings_group!` macro and fires when AI state changes.
- `ReviewDestination` enum in `right_panel.rs`: `None`, `Warp`, `Cli(CLIAgent)`.
- `recompute_terminal_availability` currently computes `ReviewDestination` purely from terminal state — returns `Warp` for any idle, non-executing terminal regardless of AI setting.
- `RightPanelView` does not currently subscribe to `AISettingsChangedEvent`.
## Proposed Changes
### 1. Remove AI gate from header and per-file buttons
**Files**: `code_review_header.rs`, `code_review_view.rs`
Remove `is_ai_enabled` from the visibility conditions in `render_wide_layout` (line 138), `render_compact_layout` (line 220), and `render_file_header` (line 5175). The conditions become purely feature-flag-based:
- Header: `FeatureFlag::DiffSetAsContext.is_enabled() && !has_no_changes`
- Per-file: `FeatureFlag::DiffSetAsContext.is_enabled()`
### 2. Remove `is_ai_enabled` from `EditorWrapper`
**Files**: `code/editor/element.rs`, `code/editor/view.rs`, `code/editor/view/actions.rs`
- Delete `is_ai_enabled: bool` field (line 418) and constructor parameter (line 516).
- Simplify the 5 internal usages to check only the feature flag:
- Line 959: `if FeatureFlag::InlineCodeReview.is_enabled()`
- Line 1170: `self.add_hunk_as_context_button.is_some()`
- Line 1176: `FeatureFlag::InlineCodeReview.is_enabled() && ...`
- Lines 1315, 1517: `FeatureFlag::InlineCodeReview.is_enabled()`
- Remove the `is_ai_enabled` argument from both `EditorWrapper::new` call sites in `view.rs` (lines 628, 2216).
- Remove the `AISettings::as_ref(ctx).is_any_ai_enabled(ctx)` guard from `NewCommentOnLine` (line 1070) and `RequestOpenSavedComment` (line 1082) in `actions.rs`.
### 3. Filter non-CLI terminals when AI is disabled
**File**: `right_panel.rs`
- Add `ai_enabled: bool` parameter to `is_terminal_available_for_review` (line 1154). When `false`, reject terminals that don't have an active CLI agent.
- Thread `ai_enabled` through `find_available_terminal_for_review` (line 1184).
- In `recompute_terminal_availability` (line 1218), read `AISettings::as_ref(ctx).is_any_ai_enabled(ctx)` and pass it down.
- In `route_review_comments` (line 1067), also read `is_any_ai_enabled` and pass it through so routing respects AI state at submission time.
### 4. Subscribe to AI settings changes
**File**: `right_panel.rs``new()` (around line 341)
Subscribe to the `AISettings` model, matching on `AISettingsChangedEvent::IsAnyAIEnabled`, and call `recompute_terminal_availability(ctx)`. This ensures the send button's enabled state and tooltip update immediately when the user toggles AI.
Import needed: `use crate::settings::ai::{AISettings, AISettingsChangedEvent}`.
### 5. Differentiate disabled button tooltip
**File**: `comment_list_view.rs`
- Add `ai_enabled: bool` parameter to `send_button_tooltip_text` (line 864). New priority:
1. CLI agent destination → existing CLI tooltip
2. `!ai_enabled` → "AI must be enabled to send comments to Agent"
3. `!ai_available` → "Agent code review requires AI credits" (existing)
4. `ReviewDestination::None` → "All terminals are busy" (clarified)
5. `!has_sendable_comments` → existing
6. Default → existing
- In `render_send_button` (line 888), read `AISettings::as_ref(ctx).is_any_ai_enabled(ctx)` and pass it to the tooltip function.
## End-to-End Flow
1. User opens code review panel with AI disabled.
2. Comment and add-as-context buttons are visible (changes 1+2).
3. User creates an inline comment via gutter button.
4. `recompute_terminal_availability` runs, sees AI is off, filters to CLI-only terminals (change 3).
5. If a CLI agent terminal exists → `ReviewDestination::Cli(agent)` → send button enabled.
6. If no CLI agents → `ReviewDestination::None` → send button disabled, tooltip says "AI must be enabled to send comments to Agent" (change 5).
7. User toggles AI on → subscription fires (change 4) → recompute includes non-CLI terminals → button updates.
## Risks and Mitigations
- **Deadlock risk**: Changes 34 read `AISettings` in `right_panel.rs`. The AI settings model is separate from the terminal model, so no lock contention with existing `TerminalModel` locks.
- **Regression risk**: Removing `is_ai_enabled` from `EditorWrapper` touches many lines. Mitigated by the field being entirely removed (compiler will catch any missed references).
## Testing and Validation
- Build verification: `cargo check`, `cargo fmt`, `cargo clippy`.
- Manual testing: toggle AI on/off with and without CLI agents; verify button visibility and tooltip in each combination.
- WASM build per repo convention.
## Parallelization
Changes 1+2 (button visibility) are independent from changes 3+4+5 (terminal availability and tooltip). These can be implemented concurrently:
- **Agent A**: Changes 1+2 — `code_review_header.rs`, `code_review_view.rs`, `code/editor/element.rs`, `code/editor/view.rs`, `code/editor/view/actions.rs`
- **Agent B**: Changes 3+4+5 — `right_panel.rs`, `comment_list_view.rs`
## Follow-Ups
None currently.
+102
View File
@@ -0,0 +1,102 @@
# TECH.md — APP-3904: Don't override pane/tab representation for expanded edit tool call
## Problem
When an agent edit tool call is expanded into a pane (via `ExpandEditToPane`), the vertical tabs sidebar changes its representation for that tab from the original pane (e.g. "Create Korean Poem File" with agent icon and metadata) to a generic "Requested Edit" code-diff pane. The entire sidebar row — title, icon, subtitle, badge — changes to reflect the replacement `CodeDiffPane` instead of the original agent conversation pane.
The sidebar should continue showing the original pane's full representation while a temporary replacement is active.
## Relevant code
- `app/src/pane_group/pane/code_diff_pane.rs:22-45``CodeDiffPane::from_view` hardcodes `PaneConfiguration` title to "Requested Edit"
- `app/src/workspace/view.rs:6428-6460``open_code_diff` creates the `CodeDiffPane` and calls `replace_pane` with `is_temporary: true`
- `app/src/pane_group/tree.rs:55-112``HiddenPane` / `HiddenPaneReason::TemporaryReplacement` tracks the original→replacement mapping
- `app/src/pane_group/tree.rs:368-372``PaneData::is_temporary_replacement` checks if a pane is a replacement
- `app/src/pane_group/mod.rs:4456-4502``PaneGroup::replace_pane` orchestrates temporary replacement; original stays in `pane_contents`
- `app/src/workspace/view/vertical_tabs.rs:2065-2136``PaneProps::new` resolves display properties (typed, title, subtitle, icon, badge) from the pane's configuration and type
- `app/src/workspace/view/vertical_tabs.rs:2348-2391``PaneGroup::resolve_pane_type` maps `PaneId``TypedPane` which drives icon/badge/kind
## Current state
The `ExpandEditToPane` feature flag controls how code diff views are opened:
- **Enabled**: The focused pane is temporarily replaced with a `CodeDiffPane`. The original pane is hidden via `HiddenPaneReason::TemporaryReplacement(replacement_id)` and kept in `pane_contents` for later restoration.
- **Disabled**: The diff opens in a new tab.
When the sidebar renders tab rows, `PaneProps::new` resolves all display properties from the visible pane. For a temporary replacement, the visible pane is the `CodeDiffPane`, so the sidebar shows:
- Icon: `WarpIcon::Diff` (instead of the original terminal/agent icon)
- Title: "Requested Edit" (hardcoded in `CodeDiffPane::from_view`)
- Type: `TypedPane::CodeDiff` (loses all terminal-specific metadata like conversation title, working directory, git branch)
- Badge/subtitle: empty
The pane header ("Requested Edit" with Refine/Done/Accept buttons) shown *inside* the pane content area is correct and is rendered by `CodeDiffView::render_header_content` — that is not affected by this change.
## Proposed changes
### 1. Add `original_pane_for_replacement` lookup to `PaneData`
In `app/src/pane_group/tree.rs`, add a method that returns the original hidden pane's ID given a replacement pane ID:
```rust
pub fn original_pane_for_replacement(&self, replacement_pane_id: PaneId) -> Option<PaneId>
```
This scans `hidden_panes` for a `TemporaryReplacement` entry whose associated replacement ID matches. It follows the same pattern as the existing `is_temporary_replacement` method.
### 2. Expose through `PaneGroup`
In `app/src/pane_group/mod.rs`, add a thin delegation method:
```rust
pub fn original_pane_for_replacement(&self, replacement_pane_id: PaneId) -> Option<PaneId>
```
### 3. Update `PaneProps::new` to use original pane for display
In `app/src/workspace/view/vertical_tabs.rs`, modify `PaneProps::new` so that when the requested `pane_id` is a temporary replacement, it resolves the `PaneConfiguration` and `TypedPane` from the original hidden pane:
```
let display_pane_id = pane_group
.original_pane_for_replacement(pane_id)
.unwrap_or(pane_id);
let display_pane = pane_group.pane_by_id(display_pane_id)?;
let pane_configuration = display_pane.pane_configuration();
let typed = pane_group.resolve_pane_type(display_pane_id, app);
```
This makes the sidebar row render the original pane's icon, title, subtitle, badge, and all terminal-specific metadata (conversation title, working directory, git branch, status indicators).
Fields that should still use the replacement `pane_id`:
- `pane_id` — click/focus targets the visible replacement pane
- `is_focused` — the replacement pane is what actually holds focus
- `is_being_dragged` — drag state belongs to the visible pane
## End-to-end flow
1. User is in an agent conversation (terminal pane, sidebar shows "Create Korean Poem File" with agent icon)
2. Agent produces an edit tool call; user expands it
3. `open_code_diff` creates a `CodeDiffPane` and calls `replace_pane(focused_pane_id, new_pane, true)`, which:
- Adds the original terminal pane to `hidden_panes` as `TemporaryReplacement(replacement_id)`
- Swaps the tree node to the `CodeDiffPane`
4. Sidebar re-renders. `PaneProps::new` receives the replacement `pane_id`:
- Looks up `original_pane_for_replacement(pane_id)` → finds the hidden terminal pane
- Resolves `typed`, `pane_configuration`, title, icon, etc. from the original terminal pane
- Sidebar row shows "Create Korean Poem File" with agent icon and metadata (unchanged)
5. User accepts/rejects the edit → `close_temporary_replacement_pane` reverts to the original pane
6. Sidebar naturally shows the original pane again (no special handling needed)
## Risks and mitigations
- **Original pane removed prematurely**: For temporary replacements, `replace_pane` explicitly skips removing the original from `pane_contents`. The original pane is guaranteed to exist for lookup. No new risk.
- **Multiple temporary replacements**: If multiple diffs are expanded in sequence on the same pane, the previous replacement is reverted first (via `close_temporary_replacement_pane`) before a new one is created. The lookup remains 1:1.
- **Non-`ExpandEditToPane` path**: When the flag is disabled, diffs open in a new tab (not a replacement). `original_pane_for_replacement` returns `None`, and `PaneProps::new` falls through to the existing behavior. No regression.
- **Detail sidecar**: The detail sidecar (hover popup) also resolves from `PaneProps`. Using the original pane's type means the sidecar will show terminal-specific detail (working directory, git branch, etc.) instead of code-diff detail. This is the correct behavior since the tab still conceptually represents the agent conversation.
## Testing and validation
- Manual: expand an agent edit tool call with `ExpandEditToPane` enabled. Verify the sidebar row keeps the original pane's icon, title, subtitle, and any badges. Verify clicking the sidebar row still focuses the diff pane. Verify accept/reject restores the original pane normally.
- Manual: repeat with `ExpandEditToPane` disabled. Verify no regression — diff opens in a new tab with "Requested Edit" title as before.
- Unit test: Add a test in `tree_tests.rs` for `original_pane_for_replacement` — verify it returns `Some(original_id)` after a temporary replacement and `None` otherwise.
## Follow-ups
- Consider whether `CodeDiffPane` still needs a hardcoded "Requested Edit" title at all, since the pane header text comes from `CodeDiffView::render_header_content` independently. The `PaneConfiguration` title is only relevant when the pane is shown in a new tab (non-replacement path). Leaving it as-is is safe.
+139
View File
@@ -0,0 +1,139 @@
# Product Spec: GitHub PR Prompt Chip — Default Inclusion with Validation
## Summary
Show the GitHub PR prompt chip by default in both the terminal prompt and the agent view footer. Reuse the chip's existing runtime behavior to validate whether the chip can work for the user. If the chip hits a deterministic `gh` readiness failure, suppress it from future default layouts and surface a warning in vertical tabs when relevant.
## Problem
The PR chip's current default inclusion is inconsistent:
- **Terminal prompt default:** chip is absent, so users with a working `gh` CLI do not discover it unless they manually customize their prompt.
- **Agent view footer default:** chip is present, so users without a working `gh` CLI can get a silent no-op.
The first proposed design added a separate proactive `gh` readiness model, but that duplicates capabilities already present in the chip runtime: required executable detection, local-session gating, command execution, failure suppression, and command-based invalidation.
## Goals
- Include the GitHub PR chip by default in both terminal prompt and agent view footer.
- Avoid mutating users' saved prompt or footer customizations.
- Reuse the PR chip runtime as the validation path rather than adding a redundant proactive readiness model.
- Suppress the chip from default layouts after deterministic readiness failures such as missing or unauthenticated `gh`.
- Keep the vertical tabs "Show: PR link" setting decoupled from validation, while showing a warning if PR links are enabled but cannot work.
## Non-Goals
- Adding a `gh auth` flow or prompting users to install `gh`.
- Detecting whether the current repo is hosted on GitHub beyond what the existing chip script already does.
- Changing CLI agent footer defaults.
- Changing the persisted `vertical_tabs_show_pr_link` setting default or type.
- Suppressing the chip permanently after transient failures such as timeouts, network errors, GitHub outages, or rate limits.
- Changing behavior for custom prompt/footer configurations except for the existing runtime chip behavior if the user manually included the chip.
## Figma
None provided.
## User Experience
### Definitions
- **Default prompt/footer:** the runtime-resolved chip set used when the user has not customized that surface (`PromptSelection::Default` or `AgentToolbarChipSelection::Default`).
- **Custom prompt/footer:** chip set explicitly saved by the user. This feature does not remove chips from custom configurations.
- **Validation state:** a hidden app state used to decide whether default layouts should keep showing the PR chip. It begins unvalidated.
- **Deterministic readiness failure:** a failure that shows the PR chip cannot work until the user changes local setup, such as `gh` missing from `$PATH` or `gh` installed but unauthenticated.
- **Transient failure:** a failure that should not suppress defaults, such as network errors, timeouts, GitHub outages, API/rate-limit errors, or unexpected `gh` failures.
### Behavior Rules
1. **Initial default terminal prompt:** The GitHub PR chip is included in the effective default prompt, positioned after `GitDiffStats`.
2. **Initial default agent view footer:** The GitHub PR chip is included in the effective default agent footer, in the existing agent-footer PR chip position after `GitDiffStats` and before `NLDToggle`.
3. **Successful validation:** If the PR chip successfully reaches the `gh`-backed path and resolves a PR URL, records "no PR found" as a benign empty result, or otherwise completes in a way that demonstrates `gh` is installed and authenticated, the chip remains in default layouts.
4. **Benign empty states:** Non-GitHub repo, no Git repo, detached HEAD, no `origin`, non-GitHub remote, and no open PR should not be treated as readiness failures. These states may produce no chip value, but they should not suppress the default.
5. **Deterministic readiness failure:** If the PR chip fails because `gh` is missing or unauthenticated, the chip is suppressed from future effective default terminal prompt and agent footer layouts.
6. **Transient failure:** If the PR chip fails for a transient reason, the chip remains in default layouts and may retry according to the existing chip invalidation/runtime behavior.
7. **Custom prompt or footer:** If a user explicitly added the GitHub PR chip to a custom prompt or footer, keep it there regardless of validation state. Existing runtime disabled/failure behavior still applies.
8. **Remote / SSH sessions:** The existing PR chip `local_only` runtime policy remains in effect. Remote-session failures should not rewrite custom settings. Whether they should mark the default as suppressed depends on implementation details, but the user-facing result should avoid showing a non-working PR chip in remote default layouts.
9. **No stored prompt/footer mutation:** The saved prompt/footer setting remains `Default` or `Custom` as-is. Suppression is applied to the effective default layout, not by rewriting a saved chip list.
10. **Re-enabling after setup changes:** If the user installs or authenticates `gh` after the default has been suppressed, they can manually add the PR chip from the prompt/footer editor. Automatic revalidation can be considered later but is not required for this change.
### Vertical Tabs "Show: PR link" Setting
The vertical tabs settings popup (expanded mode) has a "Show: PR link" toggle (`vertical_tabs_show_pr_link` in `TabSettings`). This setting's default value and persistence are not changed — it remains a plain `bool` defaulting to `true`. The toggle is decoupled from PR chip validation.
When "Show: PR link" is enabled but the PR chip has been suppressed due to a deterministic readiness failure, a warning icon is shown next to the toggle label in the settings popup.
11. **"Show: PR link" enabled and validation is not suppressed:** No warning. PR badges render in expanded vertical tab rows when a PR URL is available.
12. **"Show: PR link" enabled and validation is suppressed:** A warning icon appears inline next to the "PR link" label in the settings popup. Hovering the icon shows a tooltip explaining that the GitHub CLI must be installed and authenticated. No PR badges render unless the user manually re-enables/fixes the PR chip flow.
13. **"Show: PR link" disabled:** No warning icon, regardless of validation state. No PR badges render.
### Vertical Tabs Warning Details
When the warning is shown, it is rendered only in the expanded vertical-tabs settings popup's "Show" section on the "PR link" row.
- Row layout: `[check icon] [8px gap] [PR link label] [4px gap] [warning icon]`.
- Icon: `Icon::AlertTriangle`.
- Icon size: 12px by 12px.
- Icon color: the standard theme warning color (e.g., `theme.ui_warning_color()`), not the error color.
- Tooltip trigger: hovering the warning icon only. Hovering the rest of the row continues to behave like today: it highlights the clickable row and clicking toggles "Show: PR link".
- Tooltip text: "Requires the GitHub CLI to be installed and authenticated".
- Tooltip positioning: above the warning icon, using the standard Warp tooltip styling and overlay behavior.
- The warning icon is omitted entirely when "Show: PR link" is disabled or validation is not suppressed.
### What Does NOT Change
- The `GithubPrPromptChip` feature flag still gates the chip's existence entirely.
- The chip's shell scripts still handle repo/branch/no-PR cases.
- The chip's runtime behavior still handles local-session gating, dependencies, timeout, failure suppression, fingerprint caching, and invalidation.
- The `available_chips()` and `agent_footer_available_chips()` lists still expose `GithubPullRequest` when the feature flag is on so users can manually add it.
- The vertical tabs "Show: Diff stats" setting is unchanged.
## Success Criteria
1. A user with a working authenticated `gh` CLI on a fresh Warp install sees the PR chip in both terminal prompt and agent view footer without configuration.
2. A user without `gh` initially sees the PR chip in default layouts until the PR chip runtime deterministically detects the missing dependency; afterward, the chip is suppressed from default layouts.
3. A user with unauthenticated `gh` initially sees the PR chip in default layouts until the PR chip runtime deterministically detects the auth failure; afterward, the chip is suppressed from default layouts.
4. Users with custom prompt/footer configurations see no saved customization changes.
5. No saved prompt/footer chip lists are rewritten by suppression.
6. No-PR, non-GitHub repo, non-git directory, detached HEAD, and non-GitHub remote states do not suppress the PR chip by themselves.
7. Transient failures do not suppress the PR chip by themselves.
8. When suppression has occurred and "Show: PR link" is enabled, vertical tabs settings show the specified warning icon and tooltip.
9. When "Show: PR link" is disabled, vertical tabs settings do not show the warning icon.
## Validation
- **Unit tests:** Default terminal prompt includes `GithubPullRequest` before suppression.
- **Unit tests:** Default agent footer includes `GithubPullRequest` before suppression.
- **Unit tests:** Custom prompt/footer configurations are not modified by suppression.
- **Unit tests:** Missing `gh` and unauthenticated `gh` transition validation state to suppressed.
- **Unit tests:** Benign empty states and transient failures do not transition validation state to suppressed.
- **Manual test (happy path):** With authenticated `gh`, open Warp with default settings in a GitHub repo branch with an open PR. Confirm chip appears in terminal prompt and agent footer and renders "PR #N".
- **Manual test (missing `gh`):** Remove/rename `gh` from `$PATH`. Open Warp with default settings. Confirm the chip is suppressed after deterministic failure.
- **Manual test (unauthenticated):** Run `gh auth logout`. Open Warp with default settings. Confirm the chip is suppressed after deterministic auth failure.
- **Manual test (custom prompt):** Add the PR chip manually to a custom prompt. Confirm suppression does not remove it from the saved custom config.
- **Manual test (vertical tabs warning):** With "Show: PR link" enabled and validation suppressed, confirm the warning icon appears next to the "PR link" label and the tooltip text matches the spec.
## Open Questions
1. Which exact chip-runtime failure values should count as deterministic unauthenticated `gh` failures? The implementation should prefer narrow matching to avoid suppressing defaults after transient errors.
2. Should suppression ever automatically reset after the user runs a successful `gh auth login` or installs `gh`? This is not required for this change, but could be added later if the one-way suppression feels too sticky.
+204
View File
@@ -0,0 +1,204 @@
# Technical Spec: GitHub PR Prompt Chip — Default Inclusion with Validation
## Problem
We want the GitHub PR chip to be included in terminal prompt and agent view defaults without introducing a separate proactive readiness model. The existing chip runtime already knows how to check executables, run shell commands, suppress failures, and invalidate after relevant commands. The implementation should reuse that path to validate default inclusion and only add minimal state for remembering deterministic setup failures.
## Relevant Code
- `specs/APP-3908/PRODUCT.md` — product behavior.
- `app/src/context_chips/prompt.rs (1-348)``PromptSelection`, persisted prompt config, and `PromptConfiguration::default_prompt()`.
- `app/src/context_chips/current_prompt.rs (923-1424)` — chip state, chip execution, `chips_to_run`, snapshots, prompt string, and command-based invalidation.
- `app/src/context_chips/context_chip.rs (76-365)` — chip runtime capabilities and disabled reasons.
- `app/src/context_chips/mod.rs (142-341)``GithubPullRequest` chip policy, `gh`/`git` dependencies, timeout, failure suppression, and `git`/`gh`/`gt` invalidation.
- `app/src/context_chips/builtins.rs (147-180)` and `app/src/context_chips/scripts/github_pull_request_prompt_chip.*` — PR URL shell generator and benign empty cases.
- `app/src/context_chips/prompt_snapshot.rs (1-91)` — prompt snapshots currently order chips from `Prompt::chip_kinds()`.
- `app/src/context_chips/prompt_type.rs (1-184)` — dynamic/static prompt resolution and agent footer chip resolution.
- `app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs (1-210)` — agent footer defaults and available toolbar items.
- `app/src/terminal/session_settings.rs (76-311)``AgentToolbarChipSelection` and `CLIAgentToolbarChipSelection` default/custom variants.
- `app/src/terminal/model/session.rs (816-1014)``Session::load_external_commands`, `Session::executable_names`, and command execution.
- `app/src/workspace/tab_settings.rs (129-293)` — persisted `vertical_tabs_show_pr_link` bool.
- `app/src/workspace/view/vertical_tabs.rs (1-200, 2688-2924, 3021-3655)` — vertical tabs settings popup, PR badge rendering, and toggle row rendering.
- `app/src/workspace/view.rs (18171-18369)` — vertical tabs settings actions.
## Current State
The terminal prompt default omits `GithubPullRequest`. `PromptSelection::Default` is converted into a concrete `PromptConfiguration::default_prompt()` in the `Prompt` singleton, and downstream paths frequently call `Prompt::chip_kinds()`.
The agent footer default includes `GithubPullRequest` whenever `FeatureFlag::GithubPrPromptChip` is enabled. The default is returned from `AgentToolbarItemKind::default_left()`.
The PR chip already has a runtime policy:
- required executables: `gh` and `git`
- `local_only: true`
- 5s timeout
- `suppress_on_failure`
- fingerprint invalidation on `git`, `gh`, and `gt`
The shell scripts intentionally exit 0 with empty output for benign states such as not being in a git repo, detached HEAD, missing origin, non-GitHub remote, or no PR.
Vertical tabs render PR badges through `TerminalView::current_pull_request_url()`, which currently reads from `agent_view_chips()`. `vertical_tabs_show_pr_link` is a persisted bool defaulting to `true`.
## Proposed Changes
### 1. Add a small validation state
Add a hidden app setting or model-backed persisted state that tracks the default PR chip validation outcome:
```rust
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum GithubPrPromptChipDefaultValidation {
#[default]
Unvalidated,
Validated,
Suppressed,
}
```
This should be global, private, and synced the same way other user preference defaults are synced only if product wants suppression to follow the user across machines. If suppression should be machine-specific because `gh` installation/auth is machine-specific, use non-synced local storage instead. Recommendation: make this **local/non-synced** because `gh` readiness depends on the current machine/session environment.
Only `Suppressed` affects default inclusion. `Unvalidated` and `Validated` both include the PR chip in defaults.
### 2. Include the PR chip in both defaults unless suppressed
Update `PromptConfiguration::default_prompt()` or the effective prompt-default resolver so the default chip list includes `ContextChipKind::GithubPullRequest` after `GitDiffStats` when:
- `FeatureFlag::GithubPrPromptChip` is enabled
- validation state is not `Suppressed`
Do not rewrite `SessionSettings.saved_prompt`.
Update `AgentToolbarItemKind::default_left()` or a new parameterized helper so `ContextChipKind::GithubPullRequest` is included when:
- `FeatureFlag::GithubPrPromptChip` is enabled
- validation state is not `Suppressed`
Do not change `CLIAgentToolbarChipSelection` defaults.
Implementation detail: because the current default helpers are static, prefer introducing effective default helpers that take `GithubPrPromptChipDefaultValidation` as input instead of reading global state inside low-level static helpers. This keeps unit tests simple and avoids hidden dependencies.
### 3. Feed runtime failures back into validation state
Extend `CurrentPrompt::fetch_chip_value_once` completion handling for `ContextChipKind::GithubPullRequest` so deterministic setup failures update validation state:
- `ChipAvailability::Disabled(RequiresExecutable { command: "gh" })``Suppressed`
- unauthenticated `gh` error from the PR shell command → `Suppressed`
- successful `gh`-backed execution → `Validated`
Do **not** suppress for:
- successful empty output from benign states
- timeouts
- generic non-auth command errors
- network errors
- rate-limit/API outages
For auth failures, use narrow matching. The shell script currently prints the raw `gh pr view` error to stderr for non-benign failures. Add a helper that classifies `CommandOutput` stderr/stdout into:
```rust
enum GithubPrPromptChipValidationOutcome {
Validated,
SuppressDueToMissingGh,
SuppressDueToUnauthenticatedGh,
NoValidationChange,
}
```
Keep this helper small and unit-tested. If auth error strings are ambiguous, prefer `NoValidationChange` over over-suppressing.
### 4. Keep the PR chip running for validation while unvalidated
If the chip is included by default while `Unvalidated`, the existing chip runtime will run it and produce the validation signal.
After state transitions to `Suppressed`, rebuild effective default chip sets so:
- default terminal prompt no longer includes the PR chip
- default agent footer no longer includes the PR chip
- custom prompt/footer selections are unchanged
The `CurrentPrompt::handle_model_event` invalidation for `gh` commands can remain as-is for chip data. Automatic revalidation after suppression is not required for this feature, but a future follow-up could reset `Suppressed` after a successful `gh` command.
### 5. Keep vertical tabs setting decoupled, but show warning
Keep `vertical_tabs_show_pr_link` unchanged in `TabSettings`.
Add `show_pr_link_warning_mouse_state: MouseStateHandle` to `VerticalTabsPanelState`.
In `render_settings_popup`, compute:
```rust
let show_pr_link_warning =
show_pr_link && validation_state == GithubPrPromptChipDefaultValidation::Suppressed;
```
Update `render_show_toggle_option` to accept an optional warning config:
```rust
struct ShowToggleWarning {
mouse_state: MouseStateHandle,
tooltip: &'static str,
}
```
For the PR link row only, pass a warning when `show_pr_link_warning` is true. Render the warning icon inline after the label:
- row layout: check icon, 8px gap, label, 4px gap, warning icon
- icon: `UiIcon::AlertTriangle`
- size: 12px by 12px
- color: `theme.ui_warning_color()`
- tooltip text: `Requires the GitHub CLI to be installed and authenticated`
- tooltip trigger: hover on the warning icon only
- tooltip position: above the warning icon, using `appearance.ui_builder().tool_tip(...)` and `Stack`
The row's existing click behavior remains unchanged.
### 6. Decouple vertical tab PR badge value from agent footer customization
Change `TerminalView::current_pull_request_url()` so it does not depend on `agent_view_chips()`. Use `PromptType::latest_chip_value(&ContextChipKind::GithubPullRequest, ctx)` or a direct `CurrentPrompt` accessor.
This ensures vertical tab PR badges are controlled by `vertical_tabs_show_pr_link` and chip availability, not by whether the user customized the agent footer.
## End-to-End Flow
```mermaid
flowchart TD
A[Default prompt/footer resolution] --> B{Validation state Suppressed?}
B -->|No| C[Include GitHub PR chip]
B -->|Yes| D[Omit GitHub PR chip from effective defaults]
C --> E[Existing chip runtime executes]
E --> F{Outcome}
F -->|Deterministic missing/unauth gh| G[Set validation state Suppressed]
F -->|Successful gh-backed result| H[Set validation state Validated]
F -->|Benign empty/transient failure| I[No validation change]
G --> D
G --> J[Vertical tabs Show PR link warning if setting enabled]
```
## Risks and Mitigations
- **False suppression from transient errors:** Keep classification narrow. Unknown failures should not transition to `Suppressed`.
- **Benign empty results do not validate auth:** This is acceptable; keep the chip in defaults while `Unvalidated` unless a deterministic readiness failure occurs.
- **Suppression may be sticky after the user fixes `gh`:** Product accepts one-way suppression for this iteration. Users can manually add the chip; automatic reset can be a follow-up.
- **Prompt/default resolution paths are scattered:** Audit and update prompt display, prompt snapshots, prompt string, copy menu, and agent footer chip resolution so all effective defaults agree.
- **Machine-specific state:** If validation state is synced, suppression from one machine could hide the chip on another where `gh` works. Prefer non-synced local storage.
- **Vertical tabs warning state source:** The warning should use the same validation state as prompt/footer suppression to avoid another readiness model.
## Testing and Validation
- Unit test default prompt resolution includes PR chip for `Unvalidated` and `Validated`, and omits it for `Suppressed`.
- Unit test default agent footer resolution includes PR chip for `Unvalidated` and `Validated`, and omits it for `Suppressed`.
- Unit test custom prompt/footer configs are not changed by validation state.
- Unit test validation transitions:
- missing `gh``Suppressed`
- unauthenticated `gh` output → `Suppressed`
- successful PR URL → `Validated`
- no PR / non-GitHub remote / no git repo → no suppression
- timeout / unknown error → no suppression
- Unit test or lightweight UI test `render_show_toggle_option` warning behavior.
- Manual validation for authenticated `gh`, missing `gh`, unauthenticated `gh`, custom prompt, and vertical tabs warning.
## Follow-Ups
- Reset `Suppressed` automatically after detecting a successful `gh auth login` or a later successful PR chip execution.
- Improve the prompt editor disabled tooltip to distinguish missing `gh` from unauthenticated `gh`.
- Add telemetry for validation transitions if rollout needs observability.
+321
View File
@@ -0,0 +1,321 @@
# APP-3909: Tech Spec — Layout-aware binding descriptions for tab actions
## Problem
PR #24105 updates the right-click tab context menu to show "Close tabs below" instead of "Close tabs to the right" when vertical tabs are enabled. The tab-layout-dependent label is computed inline in `app/src/tab.rs:close_tab_menu_items`, but the underlying `EditableBinding` for `workspace:close_tabs_right_active_tab` is still registered with a hardcoded `"Close tabs to the right"` description. That description is the source of truth for several other surfaces that all still show the horizontal-axis wording even when vertical tabs are enabled:
- the macOS menu bar (Tab > Close Tabs to the Right)
- the command palette (Cmd-P search for "close tabs")
- the settings keybindings page
- the resource center keybindings panel
The same inconsistency already exists for `workspace:move_tab_left` / `workspace:move_tab_right`: the tab context menu swaps them to "Move tab up" / "Move tab down" when vertical tabs are enabled, but every other surface continues to show "Move tab left" / "Move tab right". The scope of this spec is all three bindings, not just the one that triggered the review comment.
The goal is to give tab-layout-dependent bindings a single source of truth for their label that every consumer honors, and to make it **compile-time impossible** for a future consumer to miss the dynamic resolution.
## Relevant code
- `app/src/workspace/mod.rs:772-797` — registration of `workspace:move_tab_left` / `workspace:move_tab_right` with static descriptions.
- `app/src/workspace/mod.rs:892-899` — registration of `workspace:close_tabs_right_active_tab` with a static description.
- `app/src/tab.rs:266-356``modify_tab_menu_items` and `close_tab_menu_items`, which already branch on `uses_vertical_tabs` inline.
- `crates/warpui_core/src/keymap.rs:64-116``BindingDescription` definition and the `in_context` lookup API.
- `crates/warpui_core/src/keymap.rs:145-221``BindingLens`, `EditableBinding`, `EditableBindingLens`.
- `crates/warpui_core/src/core/app.rs:1718-1726``AppContext::description_for_custom_action`, used by the menu bar to resolve a custom action's description.
- `app/src/app_menus.rs:486-511``make_new_tab_menu`, where `CustomAction::CloseTabsRight`, `MoveTabLeft`, and `MoveTabRight` are wired.
- `app/src/app_menus.rs:1163-1186``custom_action_updater`, the per-menu-item update callback that pulls `description.in_context(MAC_MENUS_CONTEXT)` into `MenuItemPropertyChanges.name` on every menu open.
- `app/src/util/bindings.rs:714-782``CommandBinding` and its `from_binding` / `From<BindingLens<'_>>` / `From<EditableBindingLens<'_>>` constructors. These are the cache-population entry points that clone `BindingDescription` into a reusable value type.
- `app/src/search/action/data_source.rs:68-94``CommandBindingDataSource::on_binding_source_changed`, the command-palette cache-population site.
- `app/src/search/action/data_source.rs:142-182``FuzzyActionSearcher::search`, which fuzzy-matches against cached descriptions without access to `AppContext`.
- `app/src/search/action/data_source.rs:261-298``FullTextActionSearcher::rebuild_search_index`, which builds a Tantivy index from cached descriptions without access to `AppContext`.
- `app/src/search/action/search_item.rs:71-156``MatchedBinding::render_label` and `accessibility_label`, which read `binding.description` without access to `AppContext`.
- `app/src/settings_view/keybindings.rs:761-800``on_page_selected`, the settings keybindings cache-population site.
- `app/src/resource_center/keybindings_page.rs:81-100``KeybindingsView::new`, the resource center cache-population site. Built once per panel lifetime.
- `app/src/workspace/tab_settings.rs:352-360``TabSettings::use_vertical_tabs` definition.
## Current state
### Binding registration
`EditableBinding::new(name, description, action)` stores a `BindingDescription` by value on the binding. `BindingDescription` holds an immutable default `String` and an optional map of `DescriptionContext`-keyed overrides (for example, `MAC_MENUS_CONTEXT` lets a binding provide a shorter label for the macOS menu bar). `in_context(context)` returns an `&str`; there is no way to compute the description at lookup time.
### How each surface reads the description
**Menu bar.** `make_new_tab_menu` calls `updateable_custom_item_without_checkmark(CustomAction::CloseTabsRight, ctx)`, which constructs a `CustomMenuItem` whose updater is `custom_action_updater`. The updater runs every time the menu is opened, calls `ctx.description_for_custom_action(action, MAC_MENUS_CONTEXT)` via `update_custom_action_binding`, and copies the result into `MenuItemPropertyChanges.name`. Because the updater re-runs on each open, the menu bar is the one surface that could already pick up a dynamic label — the only static piece is the binding's own description.
**Command palette.** When the palette opens, `set_command_palette_binding_source` emits a change on the `BindingSource` model, which triggers `CommandBindingDataSource::on_binding_source_changed`. That method iterates `ctx.key_bindings_for_view(window_id, view_id)` and converts each `BindingLens` to a `CommandBinding` via `CommandBinding::from_binding`. The conversion clones the `BindingDescription` into the `CommandBinding`. The fuzzy/full-text searcher then builds its index from the cached descriptions — those downstream consumers run without `AppContext` and can only see the concrete cached string.
**Settings keybindings page.** `on_page_selected` calls `ctx.editable_bindings().map(CommandBinding::from)`, using `From<EditableBindingLens<'_>> for CommandBinding` to clone descriptions into the cache.
**Resource center keybindings panel.** `KeybindingsView::new` calls `ctx.get_key_bindings().map(CommandBinding::from)` once during view construction. The cached descriptions live for the lifetime of the panel.
### Key constraint: downstream consumers without `&AppContext`
Several `CommandBinding` consumers read the description from a path that does **not** have `AppContext`:
- `search_item::MatchedBinding::accessibility_label` (no context parameter)
- `FuzzyActionSearcher::search` (operates on cached descriptions)
- `FullTextActionSearcher::rebuild_search_index` (builds a Tantivy index from cached descriptions)
- `util/bindings::filter_bindings_including_keystroke` (no context parameter)
- `settings_view/keybindings::render_summary` (only has `Appearance`)
Any solution has to materialize dynamic descriptions into concrete `String`s at **cache-population time** (when `AppContext` is available) so these downstream consumers continue to see ordinary `String`s. This rules out "resolve at render time" designs that don't plumb `AppContext` all the way down.
### Why this is the moment to fix the framework, not each surface
The immediate bug is small. The structural risk is that there are four cache-population sites today and the set will grow. A purely per-surface fix relies on every future author remembering to call an override helper after constructing a `CommandBinding`, and nothing in the type system catches a miss. That is exactly the class of bug that prompted this spec.
## Proposed changes
Add first-class support for dynamic description overrides in `warpui_core::keymap`. Make `CommandBinding::from_lens(lens, ctx)` the only way to materialize a `CommandBinding` from a lens, so the compiler forces every cache-population site to pass `&AppContext` and resolve dynamic description overrides at construction time. Define the three tab-layout overrides next to the binding registrations, and teach the menu-bar updater to resolve dynamically too.
### 1. Framework: optional dynamic override on `BindingDescription`
In `crates/warpui_core/src/keymap.rs`:
- Add a new private field `dynamic_override: Option<Arc<dyn Fn(&AppContext) -> Option<String> + Send + Sync>>` to `BindingDescription`. Using a boxed closure (via `Arc` so `Clone` stays cheap) lets registrations define resolvers inline with captured state, rather than forcing every dynamic binding to have a free function.
- Replace the `#[derive(PartialEq, Eq, Debug)]` on `BindingDescription` with manual impls. The derived impls don't work because `Arc<dyn Fn>` is neither `PartialEq` nor `Debug`. The manual `PartialEq`/`Eq` compares the static `description` + `custom` overrides and ignores `dynamic_override`; this is safe because the only consumers of description equality (the dedup loops in `settings_view/keybindings.rs` and `resource_center/keybindings_page.rs`) operate on post-materialization `CommandBinding`s whose `dynamic_override` is always `None`. The manual `Debug` impl prints `dynamic_override: "<dynamic>"` when present.
- Add `BindingDescription::with_dynamic_override(self, impl Fn(&AppContext) -> Option<String> + Send + Sync + 'static) -> Self`.
- Add `BindingDescription::resolve(&self, ctx: &AppContext, context: DescriptionContext) -> Cow<'_, str>` that returns a title-cased `Cow::Owned(override)` when `dynamic_override` returns `Some`, and otherwise falls back to `Cow::Borrowed(self.in_context(context))`.
- Add `BindingDescription::has_dynamic_override(&self) -> bool` for cache-population code that only needs to know whether to materialize.
- Keep `in_context` unchanged. It still returns `&str` and still returns the static default even for bindings with a dynamic override. That keeps the non-context read paths compiling during migration and gives downstream consumers a safe static fallback if a cache was somehow populated without resolution.
`BindingDescription` is defined in the same crate as `AppContext` (`crates/warpui_core/src/core/app.rs`), and `AppContext` already owns the `keystroke_matcher: Matcher` that holds bindings. There is no layering or crate-graph concern here.
### 2. App layer: inline override closures at registration
Add a small `uses_vertical_tabs(ctx: &AppContext) -> bool` helper in `app/src/workspace/tab_settings.rs` (or `tab.rs`) and reuse it from `tab.rs:close_tab_menu_items` and `tab.rs:modify_tab_menu_items` so there is exactly one definition of the predicate.
Then update the three `EditableBinding::new` calls in `app/src/workspace/mod.rs` with inline override closures. Each closure returns `Some(...)` only when vertical tabs need a label different from the static fallback. `resolve` applies the same `titlecase` normalization used by `BindingDescription::new`, so the tab context menu, menu bar, command palette, and keybindings pages all agree:
```rust path=null start=null
EditableBinding::new(
"workspace:close_tabs_right_active_tab",
BindingDescription::new("Close tabs to the right").with_dynamic_override(|ctx| {
uses_vertical_tabs(ctx).then(|| "close tabs below".into())
}),
WorkspaceAction::CloseTabsRightActiveTab,
)
```
The same pattern is applied to `workspace:move_tab_left` (swaps to "Move Tab Up") and `workspace:move_tab_right` (swaps to "Move Tab Down"). The tab context-menu literals in `tab.rs:modify_tab_menu_items` / `close_tab_menu_items` are updated to match the same Title Case so every surface is consistent.
The static `"Close tabs to the right"` is retained as the non-context fallback so downstream read paths without `AppContext` remain sensible, and so `titlecase` normalization still runs once at registration.
### 3. App layer: compile-enforced cache-population API
In `app/src/util/bindings.rs`:
- Remove `impl From<BindingLens<'_>> for CommandBinding` and `impl From<EditableBindingLens<'_>> for CommandBinding`. They cannot express an `&AppContext` dependency, which is exactly what we want the type system to enforce.
- Replace them with:
```rust path=null start=null
impl CommandBinding {
pub fn from_lens(lens: BindingLens<'_>, ctx: &AppContext) -> Option<Self> { ... }
pub fn from_editable_lens(lens: EditableBindingLens<'_>, ctx: &AppContext) -> Self { ... }
}
```
- Each constructor inspects the source `BindingDescription`. If `has_dynamic_override()` is true, it stores `lens.description.materialized(ctx)` on the `CommandBinding`. If there is no dynamic override, the existing `clone()` path is preserved.
- `CommandBinding::from_binding` becomes a thin wrapper over `from_lens` for callers that currently pass a `BindingLens` without constructing one themselves (search for the three remaining call sites and migrate them).
### 4. Call-site migration (four sites)
All four sites already have `&AppContext` available, so the migration is mechanical.
- `app/src/search/action/data_source.rs:on_binding_source_changed` — change `CommandBinding::from_binding(binding)` to `CommandBinding::from_lens(binding, ctx)`.
- `app/src/settings_view/keybindings.rs:on_page_selected` — change `ctx.editable_bindings().map(CommandBinding::from)` to `ctx.editable_bindings().map(|lens| CommandBinding::from_editable_lens(lens, ctx))`.
- `app/src/resource_center/keybindings_page.rs:KeybindingsView::new` — same treatment: `ctx.get_key_bindings().map(|lens| CommandBinding::from_lens(lens, ctx))`.
- Any remaining callers of the deleted `From` impls that `cargo build` surfaces.
Additionally, `KeybindingsView::new` subscribes to `TabSettings` via `ctx.observe` (pattern already used in `settings_view/appearance_page.rs`) and rebuilds `self.bindings` / `self.binding_results` when `use_vertical_tabs` flips. This is the one surface whose cache lifetime is longer than a single open and therefore needs explicit invalidation.
### 5. Menu bar: delegate to `resolve` in the updater
In `app/src/app_menus.rs:custom_action_updater`, replace `description.in_context(bindings::MAC_MENUS_CONTEXT).to_string()` with `description.resolve(ctx, bindings::MAC_MENUS_CONTEXT).into_owned()`. This single change gives the macOS menu bar dynamic labels for **every** binding that opts in, without per-`CustomAction` special-casing. No changes to `make_new_tab_menu` are required.
The menu bar is the only surface that can safely call `resolve` at render time, because `custom_action_updater` already takes `&mut AppContext` and re-runs on every menu open.
### 6. Tests
Add unit tests in `crates/warpui_core/src/keymap_test.rs` exercising:
- `BindingDescription::new("static").resolve(ctx, Default)` returns `Cow::Borrowed("Static")` (preserves title-casing).
- `BindingDescription::new("static").with_dynamic_override(|_| Some("dynamic".into())).resolve(ctx, Default)` returns `Cow::Owned("Dynamic")`.
- `has_dynamic_override()` reports correctly.
- `in_context` still returns the static fallback on a description that also has a dynamic override.
Add an integration-ish test alongside `CommandBinding::from_lens` in `app/src/util/bindings.rs` (or a new test file) that constructs an `EditableBinding` with `.with_dynamic_override(...)`, builds a lens, and asserts the materialized `CommandBinding.description.in_context(Default)` returns the dynamic value.
## End-to-end flow
### Command palette open
1. User presses Cmd-P. `Workspace::open_command_palette``set_command_palette_binding_source`.
2. The binding source model notifies, waking `CommandBindingDataSource::on_binding_source_changed`.
3. For each `BindingLens` in `ctx.key_bindings_for_view(...)`, the data source calls `CommandBinding::from_lens(lens, ctx)`.
4. Inside `from_lens`, `lens.description.has_dynamic_override()` is true for `workspace:close_tabs_right_active_tab`. The constructor calls `lens.description.materialized(ctx)` → the inline override closure → reads `TabSettings::use_vertical_tabs` and returns `Some("close tabs below")`.
5. `from_lens` stores a fresh `BindingDescription::new_preserve_case("Close Tabs Below")` in the cached `CommandBinding`.
6. The fuzzy/full-text searcher rebuilds its index from the materialized strings. The user typing "below" now matches.
### macOS menu bar open
1. User opens the Tab menu. macOS sends `menuNeedsUpdate`.
2. Cocoa calls each menu item's updater, landing in `custom_action_updater`.
3. The updater resolves the active binding for the `CustomAction` via `ctx.update_custom_action_binding`, reads `binding.description`, and calls `description.resolve(ctx, MAC_MENUS_CONTEXT)`.
4. `resolve` sees `dynamic_override` is set and returns `Cow::Owned("Close Tabs Below".into())`.
5. `MenuItemPropertyChanges.name` is set to the dynamic value; Cocoa updates the menu item label.
### TabSettings toggle while the resource center panel is open
1. User toggles "Use vertical tabs" in settings. `TabSettings` model emits a change.
2. `KeybindingsView` is subscribed via `ctx.observe`, reacts by rebuilding `self.bindings` and `self.binding_results`.
3. The rebuilt cache re-invokes `CommandBinding::from_lens(..., ctx)`, re-resolving dynamic description overrides.
4. `ctx.notify()` schedules a redraw. The panel now shows "Close Tabs Below".
```mermaid
flowchart TD
R[EditableBinding registration in workspace/mod.rs<br/>static + with_dynamic_override override]
KM[warpui_core Matcher / Keymap]
R --> KM
subgraph Cache-population surfaces have AppContext
CP[Command palette<br/>on_binding_source_changed]
SK[Settings keybindings<br/>on_page_selected]
RC[Resource center<br/>KeybindingsView::new + TabSettings observer]
MB[Menu bar<br/>custom_action_updater]
end
KM -->|BindingLens| CP
KM -->|EditableBindingLens| SK
KM -->|BindingLens| RC
KM -->|BindingLens| MB
CP -->|from_lens lens ctx| CB1[CommandBinding with materialized description]
SK -->|from_editable_lens lens ctx| CB2[CommandBinding with materialized description]
RC -->|from_lens lens ctx| CB3[CommandBinding with materialized description]
MB -->|description.resolve ctx MAC_MENUS_CONTEXT| MN[MenuItemPropertyChanges.name]
CB1 --> FZ[FuzzyActionSearcher / FullTextActionSearcher]
CB1 --> AL[MatchedBinding::accessibility_label]
CB2 --> KR[KeybindingRow::render_summary]
CB3 --> RR[Resource center render_section]
```
## Alternatives considered
### A. Per-surface overrides via a shared helper
Add `apply_tab_layout_overrides(&mut CommandBinding, &AppContext)` in `app/src/util/bindings.rs` with a match statement over binding name, and call it from every cache-population site (plus a custom updater in the menu bar).
Pros: smallest diff, contained to the app crate.
Cons: nothing in the type system forces a future cache-population site to call it. The "list of layout-aware bindings" lives in a centralized match statement rather than alongside the binding registrations. The menu bar has to duplicate the label-resolution logic in a custom updater. This is the primary risk we are trying to eliminate, so it's a non-starter.
### B. Layout-agnostic wording
Rename the labels to be layout-neutral (for example, "Close tabs after this one", "Move tab toward end"). Remove the context-menu branching.
Pros: zero dynamic code; a single source of truth.
Cons: regresses UX for horizontal-tabs users who are used to the directional wording. Reverts the cleaner context-menu wording shipped with the vertical-tabs effort. Does not match the reviewer's intent.
### D. Register two bindings with `enabled_predicate`
Register both a "close tabs to the right" and a "close tabs below" binding and gate them with `enabled_predicate`.
Cons: `EnabledPredicate` is `fn() -> bool` with no `AppContext`; the vertical-tabs setting is a user preference, not a feature flag. Two different binding `name`s break custom keystroke persistence (user-defined keystrokes stick to one name). Does not generalize without four bindings for the `MoveTab{Left,Right}` pair.
### E. Menu bar only, file follow-up for everything else
Pros: smallest possible diff.
Cons: leaves the command palette and keybindings pages inconsistent. Only a marginal improvement over the reviewer's stated minimum bar.
### Conclusion
Approach C (the one specified above) is the only option that makes the cache-population invariant compile-time-enforced while keeping per-binding logic co-located with the registration. The framework change is bounded (≈ 40 lines in `warpui_core` plus a four-site app migration) and the resulting API is reusable for any future dynamic binding label.
## Risks and mitigations
### Stale cache in the resource center panel
Risk: `KeybindingsView::new` materializes dynamic descriptions once, so toggling vertical tabs while the panel is open leaves the strings stale.
Mitigation: subscribe to `TabSettings` via `ctx.observe` and rebuild `self.bindings` / `self.binding_results` on change. Pattern already used in `settings_view/appearance_page.rs`.
### Accidental use of `in_context` instead of `resolve`
Risk: a consumer with `AppContext` calls `binding.description.in_context(...)` and silently gets the static fallback instead of the dynamic value.
Mitigation: this is intentional for downstream consumers without `AppContext`, but for consumers that do have it we want them to go through `resolve`. The main offender would be a new surface added in the future. Mitigated structurally by routing all `CommandBinding` construction through `from_lens(..., ctx)` — consumers should read `binding.description.in_context(...)` on a `CommandBinding`, never on a `BindingLens` directly, and the materialization inside `from_lens` means the `CommandBinding`'s description is already the resolved string. Add a rustdoc comment on `BindingDescription::in_context` explaining the two modes and pointing to `resolve`.
### Closure `Send + Sync + 'static` bound
Risk: closures passed to `with_dynamic_override` must be `Send + Sync + 'static`. A future dynamic override that tries to capture a non-`Send` handle would fail to compile.
Mitigation: the bound is correct for our use case (reading global app state through `&AppContext`) and is the same bound we'd hand-write if we cared about thread safety anyway. The error message is a standard trait-bound complaint, not unusual for Rust.
### Manual `PartialEq`/`Eq`/`Debug` impls
Risk: `Arc<dyn Fn>` isn't `PartialEq` or `Debug`, so the derived impls on `BindingDescription` break. A hand-written impl that ignores `dynamic_override` could be subtly wrong.
Mitigation: equality is only consumed by the dedup loops in `settings_view/keybindings.rs` and `resource_center/keybindings_page.rs`, both of which run against post-materialization `CommandBinding`s (whose `dynamic_override` is always `None`). The manual `PartialEq` compares the static string + custom overrides and drops `dynamic_override` from the comparison — safe by construction. The `Debug` impl prints `dynamic_override: "<dynamic>"` when present, which is sufficient for diagnostics.
### Migration churn
Risk: removing the `From<BindingLens<'_>>` / `From<EditableBindingLens<'_>>` impls breaks any downstream code that depended on them.
Mitigation: grep results show exactly four call sites in the app crate, all with `AppContext` in scope. There are no external consumers. The compile error is the feature, not a bug.
### Title-casing inconsistency
Risk: `BindingDescription::new` runs the static description through `titlecase`, but `resolve` returns the raw result of the dynamic override.
Mitigation: `resolve` title-cases any dynamic override before returning it, matching the static normalization performed by `BindingDescription::new`. Dynamic override closures can therefore return sentence-case labels such as `"close tabs below"` without duplicating title-cased fallback strings.
## Testing and validation
### Unit tests
- `crates/warpui_core/src/keymap_test.rs`:
- `BindingDescription::new("foo").resolve(&ctx, Default)` returns `Cow::Borrowed("Foo")`.
- `BindingDescription::new("foo").with_dynamic_override(|_| Some("bar".into())).resolve(&ctx, Default)` returns `Cow::Owned("Bar")`.
- `has_dynamic_override()` returns true after `with_dynamic_override`.
- A description with both `with_custom_description(MAC_MENUS_CONTEXT, ...)` and a dynamic override that returns `None` falls back to the custom description for that context.
- `in_context` still returns the static default when a dynamic override is present (no regressions on the non-context read path).
- Two `BindingDescription`s with the same static string but different dynamic overrides compare `eq` (equality ignores `dynamic_override`).
### Targeted test run
```bash
cargo nextest run -p warp_app
cargo nextest run -p warpui_core
cargo test --doc -p warpui_core
```
### Manual validation
Toggle vertical tabs on and off with the appearance settings while observing the four surfaces:
- Tab context menu (should already work, unchanged by this spec).
- Tab > Close Tabs menu bar.
- Cmd-P and search for "close tabs".
- Settings > Keyboard Shortcuts, search for "close tabs".
- Help > Keyboard Shortcuts, search for "close tabs".
Repeat for "move tab" to verify `MoveTabLeft` / `MoveTabRight` as well. Verify that:
- With horizontal tabs: labels read "Close Tabs to the Right", "Move Tab Left", "Move Tab Right".
- With vertical tabs: labels read "Close Tabs Below", "Move Tab Up", "Move Tab Down".
- Toggling the setting with the resource center keybindings panel already open updates the labels without reopening the panel.
### Presubmit
Run `./script/presubmit` before pushing the PR; specifically ensure `cargo fmt` and `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` pass.
## Follow-ups
- Deprecate `BindingDescription::in_context` on consumers that do have `AppContext`. Likely requires walking the remaining call sites and deciding whether to add a lint or a `#[deprecated]` marker.
- Evaluate extending dynamic descriptions to other user-preference-dependent labels that may exist (for example, feature-flag-gated actions whose names change as flags flip). Out of scope for this change.
- Consider whether `FixedBinding` should also expose a `with_dynamic_override` builder for parity. Not required today; all layout-aware bindings are editable.
- If we later migrate `EnabledPredicate` to take `&AppContext`, the dynamic-description mechanism and the enabled-predicate mechanism should share the same resolver pattern.
+132
View File
@@ -0,0 +1,132 @@
# APP-3918: Git Operations Button in Code Review Header
## Summary
Add a context-aware git operations button to the code review header that surfaces the most relevant git action (Commit, Push, or Create PR) based on the current repository state. This builds on the header UI refactor in APP-3632.
## Problem
Users viewing diffs in the code review panel must leave the panel to commit, push, or create a PR. There is no inline way to advance changes through the git workflow from the code review context. This creates friction — especially for quick commit-and-push flows — and breaks the user's focus.
## Goals
- Surface the next logical git action directly in the code review header.
- Reduce the number of steps to go from reviewing diffs to committing / pushing / opening a PR.
- Provide a dropdown with related git operations so users aren't limited to only the primary action.
## Non-goals
- The actual commit, push, and create-PR dialogs are handled by child branches in the stack (`edward/commit-dialog`, `edward/push-dialog`, `edward/pr-dialog`). This branch only wires the button and dropdown; dialog implementation is out of scope.
- Staging individual files or hunks from the button (staging is done elsewhere).
## Figma
https://www.figma.com/design/T2CtyXgIdjtrLfC03K1n1H/Code-review-2.0?node-id=6138-21140&m=dev
## User Experience
### Button appearance
The git operations button is a split button rendered in the right section of the inner code review header (to the left of the file-nav and overflow buttons). It consists of:
1. **Primary action button** — an `ActionButton` with `SecondaryTheme`, `ButtonSize::Small`, an icon, and a label describing the current primary action.
2. **Chevron button** — a separate `ActionButton` with a `ChevronDown` icon adjoined to the primary button's right edge, opening a dropdown menu of related actions.
The two buttons use `AdjoinedSide::Right` (primary) and `AdjoinedSide::Left` (chevron) so they visually merge into one split button.
### Primary action mode
The button's label, icon, and click behavior are determined by the current `PrimaryGitActionMode`, which is recomputed whenever diff stats or unpushed-commit state changes:
1. **Commit** — when there are uncommitted changes (diff stats are non-empty).
- Label: "Commit", Icon: `GitCommit`
- Click dispatches `OpenCommitDialog`
- Chevron visible
2. **Publish** — when there is no upstream tracking branch but there are local commits.
- Label: "Publish", Icon: `UploadCloud`
- Click dispatches `PublishBranch` (pushes and sets upstream in one step)
- Chevron **hidden**
3. **Push** — when there are no uncommitted changes, branch has upstream, and there are unpushed commits.
- Label: "Push", Icon: `ArrowUp`
- Click dispatches `OpenPushDialog`
- Chevron visible
4. **View PR** — when there is nothing to commit or push and a PR exists for the branch.
- Label: "PR #N", Icon: `Github`
- Click opens the PR URL in the browser
- Chevron **hidden**
5. **Create PR** — when there is nothing to commit or push, branch has upstream, not on main, and no PR exists.
- Label: "Create PR", Icon: `Github`
- Click dispatches `OpenCreatePrDialog`
- Chevron **hidden**
6. **Disabled Commit** (fallback) — when nothing is actionable (e.g. on main with no changes, or empty unpublished branch).
- Label: "Commit", Icon: `GitCommit`, button disabled
- Chevron **hidden**, no adjoined side (fully rounded)
### Dropdown menu
When the chevron is clicked, a dropdown menu appears anchored to the bottom-right of the button group. Menu items depend on the current mode:
**Commit mode:**
- Commit (icon: GitCommit)
- Commit and push (icon: ArrowUp)
- Commit and create PR (icon: Github)
**Push mode:**
- Commit (icon: GitCommit) — **disabled** (nothing to commit)
- Push (icon: ArrowUp)
- Create PR (icon: Github)
**Create PR / View PR / Publish / Disabled Commit modes:**
- Chevron is hidden; dropdown is never shown.
The dropdown closes when an item is selected or when the user clicks away. The chevron button shows an active/pressed state while the dropdown is open.
### Overflow menu (three-dot button)
When the `GitOperationsInCodeReview` flag is enabled and there are no changes, the overflow menu is hidden entirely. All overflow menu items (Discard all, Add diff set as context, Add comment) are gated on having changes.
### State transitions
The button updates reactively:
- When new git diffs arrive (`update_diff_state`), the mode is recomputed and the button label/icon/handler are updated.
- When the diff stats change (`apply_diff_stats`), the mode is recomputed.
- Transitions between modes are instant — no animation or intermediate states.
### Unpushed commits detection
To support the Push mode on new local branches (no upstream tracking branch), `get_unpushed_commits` falls back to comparing against the detected main branch (`main` or `master`) when `@{u}..HEAD` fails. This ensures the button correctly shows "Push" on a fresh branch with local commits.
### Feature flag
All git operations button UI is gated behind `FeatureFlag::GitOperationsInCodeReview`. When the flag is off, the button is not rendered and no new actions are dispatched.
## Success Criteria
1. When the code review panel shows uncommitted changes, the header displays a "Commit" button with a chevron dropdown.
2. When there are no uncommitted changes but unpushed commits exist (with upstream), the button reads "Push" with a chevron dropdown.
3. On a new local branch with no upstream but with local commits, the button shows "Publish".
4. When everything is pushed and a PR exists, the button shows "PR #N".
5. When everything is pushed, no PR exists, and not on main, the button shows "Create PR".
6. On main with no changes, the button shows a disabled "Commit" with no chevron.
7. The button transitions between modes automatically as the user commits or pushes (once dialogs are wired by child branches).
8. The dropdown displays the correct items for each mode, with "Commit" disabled in Push mode.
9. The button does not appear when `FeatureFlag::GitOperationsInCodeReview` is off.
10. The overflow menu is hidden when there are no changes.
## Validation
- Manual verification: toggle the feature flag and confirm the button appears/disappears.
- Modify a file in a repo, open the code review panel, and verify "Commit" is shown.
- Commit the change (via terminal) and verify the button switches to "Push".
- Push the branch and verify the button switches to "Create PR" with no chevron.
- On a fresh local branch with commits, verify "Push" is shown (not "Create PR").
- Open the chevron dropdown in each mode and verify correct menu items and disabled states.
## Open Questions
- Should compound actions ("Commit and push", "Commit and create PR") show a confirmation or progress indicator? (Deferred to dialog branches.)
+140
View File
@@ -0,0 +1,140 @@
# APP-3918: Git Operations Button — Tech Spec
Product spec: `specs/APP-3918/PRODUCT.md`
Parent: APP-3632 (header UI refactor)
## Problem
The code review header refactor (APP-3632) cleared space for git operation buttons, but left them as a placeholder. This branch adds a context-aware split button that adapts its label, icon, and dropdown to the current repository state (uncommitted changes → commit, unpushed commits → push, otherwise → create PR).
## Relevant Code
- `app/src/code_review/code_review_view.rs:280-291``PrimaryGitActionMode` enum
- `app/src/code_review/code_review_view.rs:6702-6806``primary_git_action_mode`, `update_git_operations_ui`, `git_operations_menu_items`
- `app/src/code_review/code_review_view.rs:1308-1358` — button/menu construction in `new()`
- `app/src/code_review/code_review_header/header_revamp.rs:102-135``render_git_operations_button`
- `app/src/code_review/diff_state.rs:530-535``DiffStateModel::unpushed_commits()`
- `app/src/util/git.rs:247-290``get_unpushed_commits` with main-branch fallback
- `app/src/view_components/action_button.rs:296-321``AdjoinedSide`, `set_adjoined_side`, `clear_adjoined_side`
## Current State
The header refactor (APP-3632) restructured the code review header into two layers and moved contextual info to the right-panel header. The inner header now has space for action buttons on the right side. `DiffStateModel` already tracks `unpushed_commits` and provides `unpushed_commits()` accessor. Diff stats are available via the loaded state's `to_diff_stats()`.
However, `get_unpushed_commits` previously returned an empty vec for branches with no upstream tracking, which meant new local branches would incorrectly show "Create PR" instead of "Push".
## Changes
### 1. `PrimaryGitActionMode` enum
New private enum in `code_review_view.rs` with five variants: `Commit`, `Publish`, `Push`, `ViewPr`, `CreatePr`. Computed from:
- `LoadedState::to_diff_stats().has_no_changes()` — uncommitted changes check
- `DiffStateModel::has_upstream()` — upstream tracking branch check
- `DiffStateModel::unpushed_commits().is_empty()` — unpushed commits check
- `DiffStateModel::pr_info()` — existing PR check
- `DiffStateModel::is_on_main_branch()` — main branch check
### 2. Split button in `CodeReviewView`
Four new fields on `CodeReviewView`:
- `git_primary_action_button: ViewHandle<ActionButton>` — primary button (`SecondaryTheme`, `ButtonSize::Small`, `AdjoinedSide::Right`)
- `git_operations_chevron: ViewHandle<ActionButton>` — chevron button (`AdjoinedSide::Left`)
- `git_operations_menu: ViewHandle<Menu<CodeReviewAction>>` — dropdown menu
- `git_operations_menu_open: bool` — menu state
- `show_git_operations_chevron: bool` — controls chevron visibility (hidden in `CreatePr` mode)
Created once in `new()`. The menu subscribes to `MenuEvent::ItemSelected` / `MenuEvent::Close` to reset open state and chevron active state.
Passed to the header via six new fields on `CodeReviewHeaderFields`.
### 3. New `CodeReviewAction` variants
- `OpenCommitDialog`, `OpenPushDialog`, `OpenCreatePrDialog` — dispatch to dialogs (currently TODO stubs, wired in child branches)
- `OpenGitOperationsMenu` — toggles the dropdown
- `CommitAndPush`, `CommitAndCreatePr` — compound actions (stubs for now)
- `ViewPr(String)` — opens the PR URL in the browser
- `PublishBranch` — pushes and sets upstream (stub for now)
### 4. Reactive state updates
`update_git_operations_ui` is called from two sites:
- `update_aggregate_stats` (line 2777) — when diff stats change
- `invalidate_all` (line 2872) — when a full diff reload completes
It recomputes `primary_git_action_mode` and updates the button's label, icon, click handler, and adjoined-side. In `CreatePr` mode, the adjoined side is cleared via `clear_adjoined_side` and the chevron is hidden.
### 5. Header rendering
`render_git_operations_button` in `header_revamp.rs` builds a `Stack` with:
- A `Flex::row` of primary button + optional chevron
- A positioned overlay for the dropdown menu (anchored `BottomRight → TopRight`)
Gated on `CodeReviewHeaderFields::show_git_operations` (which is `FeatureFlag::GitOperationsInCodeReview.is_enabled()`).
### 6. `get_unpushed_commits` fallback
When `git log @{u}..HEAD` fails with "no upstream configured" or "unknown revision", now falls back to `detect_main_branch` and runs `git log {main_branch}..HEAD`. This ensures new local branches correctly report their commits as "unpushed". Extracted `parse_commit_log` helper to avoid duplication.
### 7. `has_upstream` detection
New `has_upstream: bool` field on `DiffMetadata`, computed during `load_metadata_for_repo` via `git rev-parse --abbrev-ref --symbolic-full-name @{u}`. Exposed as `DiffStateModel::has_upstream()`. Used by `primary_git_action_mode` to distinguish Publish (no upstream) from Push (has upstream) and to gate Create PR (only with upstream).
### 8. PR info refresh on metadata updates
Previously `refresh_pr_info` only ran on branch change. Now also runs during throttled metadata refreshes when `pr_info` is `None`, so the button updates to "PR #N" after an external push or PR creation without requiring a branch switch.
### 9. Overflow menu gated on changes
When the git operations flag is enabled, `has_header_menu_items` now requires actual changes to be present. The "Add comment" item is also gated on `has_changes`. This hides the three-dot overflow menu entirely when there are no changes.
### 10. New `UploadCloud` icon
Added `app/assets/bundled/svg/upload-cloud-01.svg` and registered as `Icon::UploadCloud` for the Publish button.
### 11. New `GitCommit` icon
Added `app/assets/bundled/svg/git-commit.svg` (a 24×24 SVG with `fill="#FF0000"` per the existing icon convention) and registered it as `Icon::GitCommit` in `crates/warp_core/src/ui/icons.rs`. This replaces `Icon::GitBranch` for the Commit action — a git-branch icon is semantically incorrect for a commit operation.
### 8. `ActionButton::clear_adjoined_side`
New public method to remove the adjoined-side styling at runtime, used when transitioning to `CreatePr` mode (standalone button, no chevron).
## End-to-End Flow
```mermaid
graph TD
A[Diff stats change or full reload] --> B[update_git_operations_ui]
B --> C{has uncommitted changes?}
C -->|yes| D["Commit mode: label=Commit, icon=GitCommit, chevron=visible"]
C -->|no| E{has upstream?}
E -->|no, has commits| F["Publish mode: label=Publish, icon=UploadCloud, chevron=hidden"]
E -->|yes| G{unpushed commits?}
G -->|yes| H["Push mode: label=Push, icon=ArrowUp, chevron=visible"]
G -->|no| I{PR exists?}
I -->|yes| J["ViewPr mode: label=PR #N, icon=Github, chevron=hidden"]
I -->|no, not main| K["CreatePr mode: label=Create PR, icon=Github, chevron=hidden"]
I -->|no, on main| L["Disabled Commit: label=Commit, disabled, no chevron"]
E -->|no, no commits| L
```
## Risks and Mitigations
- **Stale button state**: If diff stats and unpushed-commit data get out of sync, the button could show the wrong mode. Mitigated by recomputing on every diff reload and stats update.
- **Fallback branch detection**: `detect_main_branch` may fail in unusual repo setups (bare repos, orphan branches). Falls back to empty vec (shows "Create PR"), which is a reasonable degraded state.
- **Action stubs**: The dialog actions are TODO stubs. If child branches aren't merged alongside this one, clicking the buttons does nothing. Acceptable because the feature is behind a flag that's off by default.
## Testing and Validation
- Manual: enable feature flag, modify files, and step through Commit → Push → Create PR states by committing and pushing from the terminal.
- Verify dropdown items and disabled states in each mode.
- Test on a new local branch with no upstream to confirm "Push" is shown.
- Verify the flag-off path renders no git operations button.
## Follow-ups
- Wire `OpenCommitDialog`, `OpenPushDialog`, `OpenCreatePrDialog` to actual dialogs (child branches in stack).
- Wire `CommitAndPush` and `CommitAndCreatePr` compound actions.
- Wire `PublishBranch` to `git push --set-upstream origin <branch>`.
- Add integration tests for mode transitions once dialogs are functional.
- Consider progress/error UI for push and PR creation flows.
- Handle the "no remote at all" edge case (currently treated same as no upstream).
+81
View File
@@ -0,0 +1,81 @@
# APP-3919: Commit Dialog
Parent spec: `specs/APP-3918/PRODUCT.md`
Branch: `edward/commit-dialog`
## Summary
Add a commit dialog overlay to the code review panel that lets users compose and execute a git commit directly from the code review header, without switching to the terminal.
## Problem
The git operations button (from the parent branch) dispatches `OpenCommitDialog` but has no dialog to open. Users must switch to the terminal to run `git commit`. This breaks the inline workflow the button is designed to enable.
## User Experience
### Opening the dialog
The commit dialog opens when the user:
- Clicks the "Commit" primary button in the code review header
- Selects "Commit" from the chevron dropdown menu
The dialog renders as a centered modal overlay with a blurred background, consistent with the existing discard confirmation dialog.
### Dialog layout
The dialog ("Commit your changes") contains four sections:
1. **Branch** — displays the current branch name with a git-branch icon. Read-only.
2. **Changes** — shows a summary of changed files with aggregate stats (file count, +additions, -deletions).
- A collapsible file list shows per-file stats (filename, directory, +/-, -)
- "Include unstaged" toggle (on by default) controls whether unstaged + untracked changes are included or only staged changes
- Toggling the switch reloads the file list from git
3. **Commit message** — a multi-line text editor with placeholder text "Leave blank to autogenerate a commit message".
- Supports soft-wrap and autogrow
- ESC dismisses the dialog
- Editor is focused on open
4. **Intent selector** — currently only "Commit" button. Future: "Commit and push", "Commit and create PR".
### Footer
- Cancel button (left)
- Confirm "Commit" button (right) — disabled when no files or no commit message
### After committing
On success:
- Dialog closes
- A toast notification shows "Changes successfully committed."
- Diffs are reloaded to reflect the new state
- Diff metadata and PR info are refreshed
On failure:
- Dialog closes
- A toast shows the error message
- Diffs are reloaded (state may have partially changed)
### Closing the dialog
The dialog can be closed by:
- Clicking Cancel
- Clicking the X button
- Pressing ESC in the message editor
## Non-goals
- Staging individual files/hunks from the dialog (the "Include unstaged" toggle is all-or-nothing)
- Commit amend
- Compound actions (commit+push, commit+create PR) — stubs exist but are wired in child branches
## Success Criteria
1. Clicking "Commit" in the header opens the dialog overlay
2. The dialog shows the current branch, file changes with stats, and a message editor
3. Toggling "Include unstaged" refreshes the file list (staged-only vs all changes)
4. Confirming runs `git commit` with the entered message
5. Success/failure toasts appear after the commit
6. The code review panel refreshes to reflect post-commit state
7. ESC and Cancel close the dialog without committing
+100
View File
@@ -0,0 +1,100 @@
# APP-3919: Commit Dialog — Tech Spec
Product spec: `specs/APP-3919/PRODUCT.md`
Branch: `edward/commit-dialog`
## Relevant Code
- `app/src/code_review/commit_dialog.rs` — new file, entire dialog view
- `app/src/code_review/code_review_view.rs``open_commit_dialog`, dialog overlay rendering, action wiring
- `app/src/util/git.rs``get_file_change_entries`, `FileChangeEntry`
- `app/src/code_review/mod.rs` — module registration
## Changes
### 1. `CommitDialog` view (`commit_dialog.rs`)
New 718-line view implementing the dialog UI. Key types:
- `CommitIntent` enum — `CommitOnly` (future: `CommitAndPush`, `CommitAndCreatePr`)
- `CommitDialogAction` — internal actions: `Cancel`, `Confirm`, `SetIntent`, `ToggleIncludeUnstaged`, `ToggleChangesExpanded`
- `CommitDialogEvent` — events emitted to parent: `Confirmed { message, intent, include_unstaged }`, `Cancelled`
State:
- `repo_path`, `branch_name` — set at construction
- `intent: CommitIntent` — which action to take on confirm
- `include_unstaged: bool` — toggle for staged-only vs all changes (default: true)
- `file_changes: Vec<FileChangeEntry>` — loaded async on open and on toggle
- `changes_expanded: bool` — collapsible file list
- `message_editor: ViewHandle<EditorView>` — commit message input
The dialog uses the existing `Dialog` component with `dialog_styles`. The message editor uses `EditorView` with `soft_wrap`, `autogrow`, and `supports_vim_mode: false`.
File changes are loaded on construction and reloaded when `include_unstaged` is toggled. The confirm button is disabled when there are no files or no message.
### 2. `get_file_change_entries` (`git.rs`)
New async function that returns per-file change stats:
```rust
pub async fn get_file_change_entries(
repo_path: &Path,
include_unstaged: bool,
) -> Result<Vec<FileChangeEntry>>
```
- When `include_unstaged` is true: runs `git diff --numstat HEAD` + `git ls-files --others --exclude-standard` for untracked files
- When false: runs `git diff --cached --numstat` (staged only)
- Returns `FileChangeEntry { path, additions, deletions }` for each file
### 3. Dialog lifecycle in `CodeReviewView`
New field: `commit_dialog: Option<ViewHandle<CommitDialog>>`
`open_commit_dialog` method:
1. Guards against double-open
2. Gets repo path and branch name from `DiffStateModel`
3. Creates `CommitDialog` view
4. Subscribes to `CommitDialogEvent`:
- `Confirmed` → closes dialog, spawns async `run_commit`, shows success/error toast, reloads diffs + metadata + PR info
- `Cancelled` → closes dialog
5. Focuses the dialog
### 4. Overlay rendering
The dialog is rendered as a positioned overlay in `View::render`, using the same pattern as the discard confirmation dialog. Both dialogs are mutually exclusive — only one modal can be open at a time.
### 5. Action wiring
`CodeReviewAction::OpenCommitDialog` now calls `self.open_commit_dialog(ctx)` instead of being a TODO stub. `CommitAndPush` and `CommitAndCreatePr` remain stubs for child branches.
### 6. Post-commit refresh
After commit (success or failure):
- `load_diffs_for_active_repo(false, ctx)` — reloads diffs
- `refresh_diff_metadata_for_current_repo(PromptRefresh)` — updates stats
- `refresh_pr_info(ctx)` — updates PR button state
## End-to-End Flow
```mermaid
graph TD
A[User clicks Commit button] --> B[open_commit_dialog]
B --> C[CommitDialog created + focused]
C --> D{User action}
D -->|Cancel/ESC| E[Dialog closes]
D -->|Confirm| F[CommitDialogEvent::Confirmed emitted]
F --> G[run_commit async]
G -->|success| H[Toast: 'Changes successfully committed']
G -->|failure| I[Toast: error message]
H --> J[Reload diffs + metadata + PR info]
I --> J
```
## Follow-ups
- Wire `CommitAndPush` intent (push-dialog branch)
- Wire `CommitAndCreatePr` intent (pr-dialog branch)
- Auto-generate commit message when left blank (AI integration)
- Per-file staging/unstaging in the changes panel
- Commit amend support
+112
View File
@@ -0,0 +1,112 @@
# APP-3920: Push and Publish Dialogs
## Summary
Add push and publish dialog overlays to the code review panel, allowing users to review and confirm push/publish operations without leaving the diff view. The push dialog shows the branch and included commits before pushing. The publish dialog reuses the same UI with adjusted labels for first-time branch publication (setting upstream tracking).
## Problem
The git operations button (APP-3918) surfaces the correct primary action in the header, and the commit dialog (APP-3919) handles committing. However, pushing and publishing still have no dedicated confirmation flow — clicking "Push" or "Publish" needs to run the operation with a clear preview of what will be pushed and appropriate loading/error states.
## Goals
- Provide a confirmation dialog before pushing that shows the target branch and the list of commits that will be pushed.
- Allow expanding individual commits to see per-file change stats (files changed, additions, deletions).
- Show loading state during the push operation with disabled controls.
- Display success/error toasts after the operation completes.
- Reuse the same dialog for "Publish" (first push to set upstream) with appropriately different labels and icon.
- Wire the "Commit and push" intent from the commit dialog so that a push is automatically chained after a successful commit.
## Non-goals
- Selecting or deselecting individual commits to push (all unpushed commits are always included).
- Force push or other advanced push options.
- The Create PR dialog (handled separately).
## Figma
https://www.figma.com/design/T2CtyXgIdjtrLfC03K1n1H/Code-review-2.0?node-id=6138-21140&m=dev
## User Experience
### Opening the dialog
The push dialog opens when the user clicks:
- The "Push" primary action button (when in Push mode).
- "Push" from the git operations dropdown menu.
- The "Publish" primary action button (when in Publish mode, i.e. no upstream tracking branch).
Only one push/publish dialog can be open at a time. If one is already open, the action is ignored.
### Dialog layout
The dialog is a centered modal overlay (460px wide) with a blurred background. It contains:
1. **Header**: A title ("Push changes" or "Publish branch") and a close button (X, with "ESC" tooltip).
2. **Branch section**: Shows "Branch" label with a git branch icon and the current branch name.
3. **Commits section**: Shows "Included commits" label followed by a scrollable list (max 300px) of commit cards. Each card shows:
- Commit subject (single line, no wrap)
- Stats: file count, additions (green), deletions (red)
- A chevron to expand/collapse the commit's file list
4. **File list (expanded)**: When a commit is expanded, shows per-file rows with filename, directory path, and +/- stats. Files are loaded on demand when the commit is first expanded, with a "Loading…" placeholder.
5. **Footer**: Cancel button and the primary action button ("Push" or "Publish").
### Loading state
When the user clicks the primary action button:
- The button label changes to "Pushing…" or "Publishing…" and becomes disabled.
- The cancel button remains visible but clicking it is ignored while the operation is in progress.
- The push operation runs asynchronously.
### Success
On success:
- The dialog closes.
- A toast appears: "Changes successfully pushed." or "Branch successfully published."
- Diff metadata and PR info are refreshed, which updates the git operations button state.
### Error
On failure:
- The dialog stays open.
- The button reverts to its original label and becomes enabled again.
- The cancel button becomes functional again.
- A toast shows the error message.
### Cancellation
The user can cancel via the Cancel button, the X button, or pressing ESC. Cancellation closes the dialog with no side effects. Cancel is blocked while a push is in progress.
### Commit and push flow
When the user selects "Commit and push" from the commit dialog (APP-3919), the commit executes first. On success, a push is chained automatically — no separate push dialog is shown. The commit dialog shows "Committing and pushing…" during the operation. On success, a single "Changes committed and pushed." toast appears. On failure at either stage, an error toast is shown.
### Commit files loading
Per-commit file lists are fetched lazily via `git diff-tree --numstat`. Each file entry includes path, additions, and deletions. The data is cached per commit hash for the lifetime of the dialog.
## Success Criteria
1. Clicking "Push" in the header opens a dialog showing the branch name and all unpushed commits with stats.
2. Expanding a commit shows its changed files with per-file +/- stats.
3. Confirming the push shows a loading state, then closes the dialog and shows a success toast on completion.
4. If the push fails, the dialog remains open with an error toast and the button re-enables.
5. Clicking "Publish" opens the same dialog with "Publish branch" title and "Publish" button.
6. A successful publish shows "Branch successfully published." toast.
7. "Commit and push" from the commit dialog chains commit → push without opening the push dialog.
8. The dialog can be dismissed via Cancel, X, or ESC at any time (when not loading).
9. After a successful push or publish, the git operations button updates to reflect the new state (e.g. switches to "Create PR").
## Validation
- Open a repo with unpushed commits, click "Push", verify the dialog shows the correct branch and commits.
- Expand a commit and verify file list loads with correct stats.
- Confirm push, verify loading state, success toast, and dialog dismissal.
- Simulate a push failure (e.g. network issue) and verify the error toast and button recovery.
- On a branch with no upstream, verify "Publish" opens the dialog with publish-specific labels.
- Use "Commit and push" from the commit dialog and verify both operations succeed with a single toast.
- Cancel the dialog via each method (Cancel, X, ESC) and verify no operation is performed.
## Open Questions
- Should "Commit and push" show a separate push confirmation, or is the current chained behavior (no intermediate dialog) correct?
+135
View File
@@ -0,0 +1,135 @@
# APP-3920: Push and Publish Dialogs — Tech Spec
## Problem
APP-3918 added the git operations button with `OpenPushDialog` and `PublishBranch` actions, but both were stubbed as TODOs. This branch implements push/publish behavior, adds per-commit file stats to the `Commit` struct, and chains the push operation after "Commit and push" from the commit dialog.
During review of the initial per-dialog implementation, reviewer feedback observed that commit, push, and the (upcoming) create-PR dialogs share the bulk of their UI — same `Dialog` chrome, same branch/file helpers, same loading lifecycle — but had diverged on behavior, particularly around how each reacts to failure. This branch therefore also unifies the previously separate `CommitDialog` and `PushDialog` views into a single `GitDialog` view with per-mode submodules. The upcoming PR dialog work (`edward/pr-dialog`) adds `CreatePr` as a third mode rather than a new standalone file.
## Relevant Code
- `app/src/code_review/git_dialog/mod.rs``GitDialog` view, `GitDialogMode`, shared chrome (title, close/cancel/confirm buttons, overlay), unified action/event/outcome enums, ESC keybinding, dispatch, loading lifecycle
- `app/src/code_review/git_dialog/commit.rs``CommitState`, `CommitIntent`, body renderer, async `run_commit` (+ optional chained `run_push`), `on_focus` targets the message editor
- `app/src/code_review/git_dialog/push.rs``PushState`, body renderer with commit list, async `run_push` (shared by push and publish flows)
- `app/src/code_review/dialog_common.rs` — shared helpers: `render_branch_section`, `render_chevron_icon`, `render_file_list`, `render_dialog_overlay`
- `app/src/code_review/code_review_view.rs``open_commit_dialog()`, `open_push_dialog()`, the shared `prepare_git_dialog()` / `attach_git_dialog()` helpers, single `git_dialog: Option<ViewHandle<GitDialog>>` field, collapsed render arm, `PublishBranch`/`OpenPushDialog`/`CommitAndPush` action wiring
- `app/src/util/git.rs``run_push()` (uses `--set-upstream` for both push and publish), `get_commit_files()` (per-commit file stats), `get_unpushed_commits()` and `Commit` struct
## Current State
The git operations button (APP-3918) dispatches `OpenPushDialog` and `PublishBranch` actions. The commit dialog (APP-3919) originally shipped as its own `CommitDialog` view. Prior to this branch, the push dialog did not exist, the publish action was a TODO, and the commit-and-push intent was not wired.
`run_push` already uses `git push --set-upstream origin <branch>`, so it handles both regular push and first-time publish identically at the git level.
## Proposed Changes
### 1. Unified `GitDialog` view (`git_dialog/`)
A single view replaces `CommitDialog` / `PushDialog` (and, in the stacked `edward/pr-dialog` branch, the would-be `PrDialog`). `mod.rs` owns everything shared; each mode lives in its own submodule.
**Module layout:**
```
app/src/code_review/git_dialog/
mod.rs // GitDialog view + GitDialogMode + actions/events + dispatch + ESC binding
commit.rs // CommitState + body renderer + run_commit async (chains run_push on CommitAndPush)
push.rs // PushState + body renderer + run_push async (used by both push and publish)
```
**Outer struct:**
```rust
pub struct GitDialog {
repo_path: PathBuf,
branch_name: String,
mode: GitDialogMode,
loading: bool,
confirm_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
close_button: ViewHandle<ActionButton>,
}
enum GitDialogMode {
Commit(CommitState),
Push(PushState),
// CreatePr(PrState) is added on edward/pr-dialog.
}
```
**Actions:**
```rust
pub enum GitDialogAction {
Cancel,
Confirm,
Commit(CommitSubAction), // SetIntent, ToggleIncludeUnstaged, ToggleChangesExpanded
Push(PushSubAction), // ToggleCommit(String)
}
```
**Events and outcomes:**
```rust
pub enum GitDialogOutcome {
CommitOnly,
CommitAndPush,
Pushed { publish: bool },
}
pub enum GitDialogEvent {
Succeeded(GitDialogOutcome),
Failed(String),
Cancelled,
}
```
**Constructors:**
- `GitDialog::new_commit(repo, branch, intent, ctx)`
- `GitDialog::new_push(repo, branch, publish, commits, ctx)`
### 2. Unified behavior policies
Decisions that had drifted per-dialog are now consolidated in one place:
- **ESC**: one `FixedBinding` in `git_dialog::init()` under `ui_name = "GitDialog"` dispatches `GitDialogAction::Cancel`. No-op while `loading`.
- **Loading**: `set_loading(label)` disables confirm, cancel, and close, and swaps the confirm label (e.g. `"Committing…"`, `"Committing and pushing…"`, `"Pushing…"`, `"Publishing…"`).
- **On failure**: emit `GitDialogEvent::Failed(err)`. Parent closes the dialog and toasts — same policy for all modes. This is a behavior change from the initial push dialog implementation, which kept itself open on failure; reviewer feedback explicitly called for this alignment with commit's behavior.
- **On success**: emit `GitDialogEvent::Succeeded(outcome)`. Parent closes the dialog, toasts an outcome-specific message, and refreshes diffs/metadata/PR info.
- **Focus**: `GitDialog::on_focus` delegates to a per-mode `on_focus(state, ctx)` helper. `commit::on_focus` focuses the message editor; `push::on_focus` is a no-op. Keeps `mod.rs` mode-agnostic while preserving commit's auto-focus.
### 3. Commit struct enrichment (`git.rs`)
`Commit` gains `files_changed`, `additions`, and `deletions` fields, populated during `get_unpushed_commits()` by parsing `--numstat` output alongside the existing `--format` output.
New function `get_commit_files(repo_path, hash)` runs `git diff-tree --no-commit-id -r --numstat <hash>` and returns `Vec<FileChangeEntry>` for the expanded commit view.
### 4. Code review view integration (`code_review_view.rs`)
The view now owns a single `git_dialog: Option<ViewHandle<GitDialog>>` field (replacing the previous separate `commit_dialog` and `push_dialog` fields). Two shared helpers eliminate the duplicated open logic:
- `prepare_git_dialog(&self, ctx) -> Option<(PathBuf, String)>` — guards: no-op if a dialog is already open or git operations are blocked; early-returns if `repo_path` is `None`; returns `(repo_path, branch_name)` otherwise.
- `attach_git_dialog(dialog, ctx)` — subscribes to the unified event stream, stores the handle, focuses it. Success matches on `GitDialogOutcome` to pick the toast (`"Changes successfully committed."`, `"Changes committed and pushed."`, `"Branch successfully published."`, `"Changes successfully pushed."`). Failure and success both clear the dialog and call `refresh_after_git_operation`.
**Per-mode entrypoints:**
- `open_commit_dialog(intent, ctx)``GitDialog::new_commit(...)`
- `open_push_dialog(publish, ctx)``GitDialog::new_push(...)`
Action wiring unchanged:
- `OpenCommitDialog``open_commit_dialog(CommitIntent::CommitOnly, ctx)`
- `CommitAndPush``open_commit_dialog(CommitIntent::CommitAndPush, ctx)`
- `OpenPushDialog``open_push_dialog(false, ctx)`
- `PublishBranch``open_push_dialog(true, ctx)`
Render block: a single `Some(git_dialog)` arm replaces the previous `else if let Some(commit_dialog)` / `else if let Some(push_dialog)` branches.
**Extracted helpers** (unchanged from the original push dialog landing):
- `show_toast(msg, ctx)` — shows an ephemeral `DismissibleToast`
- `refresh_after_git_operation(ctx)` — reloads diffs, refreshes diff metadata with `PromptRefresh`, refreshes PR info, and calls `ctx.notify()`
### 5. Commit and push chaining
Commit mode's `Confirm` handler checks `intent == CommitIntent::CommitAndPush`. If so, it chains `run_push` after a successful `run_commit` in the same `ctx.spawn` block. Loading label is `"Committing and pushing…"`. On success the parent toasts `"Changes committed and pushed."` via `GitDialogOutcome::CommitAndPush`. No mid-flight mode transition — the dialog remains in `Commit` mode throughout.
## End-to-End Flow
### Push flow
1. User clicks "Push" button or dropdown item → `OpenPushDialog` action
2. `open_push_dialog(false, ctx)` reads branch name and unpushed commits from `DiffStateModel`
3. `GitDialog` opens in `Push` mode with commit list; user can expand commits to see files
4. User clicks "Push" → `GitDialogAction::Confirm``push::start_confirm` spawns `run_push`
5. Confirm/cancel/close disabled, confirm label reads "Pushing…"
6. On success → `GitDialogEvent::Succeeded(Pushed { publish: false })` → parent closes dialog, toasts, refreshes
7. On error → `GitDialogEvent::Failed(err)` → parent closes dialog, toasts error, refreshes
### Publish flow
Same as push, triggered by `PublishBranch``open_push_dialog(true, ctx)`. Title reads "Publish branch"; confirm button reads "Publish" with `UploadCloud` icon; loading label is "Publishing…"; success toast is "Branch successfully published." `run_push` handles `--set-upstream` identically.
### Commit and push flow
1. User selects "Commit and push" in commit dialog → `CommitIntent::CommitAndPush`
2. `Confirm` spawns `run_commit`, then `run_push` on success, in a single `ctx.spawn`
3. Emits `Succeeded(CommitAndPush)` on success or `Failed(err)` on any stage failing
4. Single toast on success; error toast on failure at either stage; dialog closes either way
## Risks and Mitigations
### Failure always closes the dialog (behavior change)
Under the unified policy, failures close the dialog and toast rather than keeping it open. The user can reopen and retry. This was an explicit outcome of reviewer feedback asking for consistency across commit/push/PR; it mirrors the original commit dialog behavior.
### Stale commit list
The commit list is read from `DiffStateModel` at dialog open time. If the user commits via terminal while the dialog is open, the list may be stale. This is acceptable — the dialog is short-lived and the user can close and reopen it.
### `run_push` used for both push and publish
`run_push` always passes `--set-upstream`. For branches that already have an upstream, this is a no-op flag. No risk of incorrect behavior.
### Single ESC binding under `GitDialog`
The unified `ui_name = "GitDialog"` replaces the per-dialog bindings (`CommitDialog`, `PushDialog`). No other code paths depend on the old ui_names.
## Testing and Validation
- Verify commit dialog opens with correct branch name, message editor, and file list.
- Verify commit and commit-and-push both succeed with the correct toast.
- Verify push dialog opens with correct branch name and commit list.
- Verify expanding a commit lazily loads and displays file stats.
- Verify loading state (all three chrome buttons disabled) during each mode's async op.
- Verify success closes dialog and shows mode-specific toast for commit / commit-and-push / push / publish.
- Verify error closes dialog and shows error toast for all modes.
- Verify cancel/close/ESC dismisses the dialog without side effects.
- Verify diff metadata and git operations button update after any successful git operation.
- Verify `on_focus` moves focus to the message editor in commit mode and is a no-op in push/publish.
## Follow-ups
- Create PR dialog: add `git_dialog/pr.rs` with `CreatePr` mode on `edward/pr-dialog`. Extend `GitDialogMode` and `GitDialogOutcome` accordingly. No new top-level file.
- `CommitAndCreatePr` intent: extend `CommitIntent` to include it, chaining `Commit → Push → CreatePr`.
- Add header icon badge matching Figma (`ArrowUp` for push, `UploadCloud` for publish).
- Align close button tooltip across modes (currently all modes show "ESC").
+118
View File
@@ -0,0 +1,118 @@
# APP-3922: Create PR Dialog
## Summary
Add a "Create PR" dialog and a "Commit and create PR" flow to the code review panel, allowing users to create GitHub pull requests directly from the diff view. The dialog shows the branch name and a summary of all changes that will be included in the PR. The commit dialog gains a third intent option that chains commit → push → PR creation in one action.
## Problem
After APP-3920 (push/publish dialogs), the git operations button can reach the "Create PR" state (everything committed and pushed, no existing PR), but clicking it was a no-op. Similarly, the "Commit and create PR" dropdown option was stubbed out. Users had to leave the editor to create a PR on GitHub.
## Goals
- Provide a confirmation dialog before creating a PR that shows the target branch and aggregate change stats (files, additions, deletions).
- Allow expanding the changes section to see per-file stats with additions/deletions.
- Show loading state during PR creation with disabled controls.
- On success, show a toast with an "Open PR" link and refresh the git operations button to show "PR #N".
- On failure, close the dialog and surface a friendly error toast so the user can retry via the git operations button.
- Add "Commit and create PR" as a third intent in the commit dialog, chaining commit → push → `gh pr create --fill` in one operation.
## Non-goals
- Editing PR title, body, reviewers, or labels (uses `gh pr create --fill` which derives title/body from commits).
- Draft PR support.
- Checking whether `gh` CLI is installed/authenticated before opening the dialog (TODO for follow-up).
## Figma
https://www.figma.com/design/T2CtyXgIdjtrLfC03K1n1H/Code-review-2.0?node-id=6138-21140&m=dev
## User Experience
### Opening the dialog
The PR dialog opens when the user clicks:
- The "Create PR" primary action button (when in CreatePr mode — everything pushed, no existing PR, not on main).
- "Create PR" from the git operations dropdown menu.
The "Commit and create PR" flow opens the commit dialog with the `CommitAndCreatePr` intent selected instead.
Only one dialog can be open at a time.
### Dialog layout
The dialog is a centered modal overlay (460px wide) with a blurred background. It contains:
1. **Header**: Title "Create pull request" and a close button (X, with "ESC" tooltip).
2. **Branch section**: "Branch" label with a git branch icon and the current branch name.
3. **Changes section**: A bordered card showing aggregate stats (file count, +additions in green, -deletions in red) with a chevron to expand/collapse the per-file list.
4. **File list (expanded)**: Scrollable list (max 130px) of per-file rows showing filename, directory, and +/- stats. Files are loaded asynchronously when the dialog opens.
5. **Footer**: Cancel button and "Create PR" primary button.
### Loading state
When the user clicks "Create PR":
- The button label changes to "Creating…" and becomes disabled.
- The cancel button becomes disabled.
- The PR creation runs asynchronously via `gh pr create --fill`.
### Success
On success:
- The dialog closes.
- A toast appears: "PR successfully created." with an "Open PR" link that opens the PR URL in the browser.
- PR info is refreshed, which updates the git operations button to show "PR #N".
### Error
On failure:
- The dialog closes.
- An ephemeral toast appears with a friendly error message mapped from the raw git / `gh` error (e.g. "GitHub CLI (gh) not installed. See https://cli.github.com/.", "Authentication failed. Check your Git credentials.", or the generic "Git operation failed." fallback).
- The git operations button stays in `CreatePr` mode (since nothing changed), so the user can retry by clicking it again.
### Cancellation
The user can cancel via the Cancel button, the X button, or pressing ESC. Cancellation closes the dialog with no side effects.
### Commit and create PR flow
The "Commit and create PR" intent is shown in the commit dialog only when creating a PR would be meaningful — i.e. the branch has no existing PR and the user is not on the repo's main branch. In either of those cases the intent is hidden entirely (not just disabled); only "Commit" and "Commit and push" remain.
When the user selects "Commit and create PR" from the commit dialog:
1. The commit executes first.
2. On successful commit, the branch is pushed.
3. On successful push, `gh pr create --fill` runs.
4. On success, the commit dialog closes and the same "PR successfully created." toast with "Open PR" link appears.
5. On failure at any stage, an error toast is shown.
The commit dialog shows "Committing and pushing…" during the operation. No separate PR dialog is shown for this flow.
### Changes section data
The changes section shows the diff between the base (main) branch and `origin/{current_branch}`. If the remote ref doesn't exist yet (branch not pushed), it falls back to diffing against HEAD. This represents what would actually be included in the PR.
## Success Criteria
1. Clicking "Create PR" in the header opens a dialog showing the branch name and aggregate change stats.
2. Expanding the changes section shows per-file +/- stats.
3. Confirming PR creation shows a loading state, then closes the dialog and shows a success toast with "Open PR" link.
4. If PR creation fails, the dialog closes and a friendly error toast is shown; the header button stays in `CreatePr` mode so the user can retry.
5. "Commit and create PR" from the commit dialog chains commit → push → PR creation without opening the PR dialog.
6. After a successful PR creation, the git operations button updates to show "PR #N".
7. The dialog can be dismissed via Cancel, X, or ESC at any time (when not loading).
## Validation
- On a branch with everything pushed and no PR, click "Create PR", verify the dialog shows the correct branch and change stats.
- Expand changes and verify per-file list loads with correct stats.
- Confirm PR creation, verify loading state, success toast with link, and dialog dismissal.
- Simulate a failure (e.g. `gh` not authenticated) and verify the dialog closes, an error toast appears with appropriate copy, and the header button still allows retrying.
- Use "Commit and create PR" from the commit dialog and verify all three operations succeed with a single toast.
- Cancel the dialog via each method (Cancel, X, ESC) and verify no operation is performed.
- After PR creation, verify the header button shows "PR #N" and clicking it opens the PR URL.
## Open Questions
- Should we check for `gh` CLI availability/auth before opening the dialog?
- Should we support editing the PR title/body instead of using `--fill`?
- Should we support draft PRs?
+110
View File
@@ -0,0 +1,110 @@
# APP-3922: Create PR Dialog — Tech Spec
## Problem
APP-3920 unified commit, push, and publish into a single `GitDialog` view and collapsed the event contract to `Completed | Cancelled` (each mode owns its own toasts and error messaging). The git operations button can reach `PrimaryGitActionMode::CreatePr` (everything pushed, no PR, not on main), but `OpenCreatePrDialog` and `CommitAndCreatePr` actions were stubbed as TODOs. This branch adds the PR dialog as a third mode alongside commit and push, wires the "Commit and create PR" chain in commit mode, and includes a few related fixes.
## Relevant Code
- `app/src/code_review/git_dialog/pr.rs` — new submodule for the `CreatePr` mode
- `app/src/code_review/git_dialog/mod.rs` — extended with `CreatePr(PrState)`, `Pr(PrSubAction)`, `new_for_pr()`, and mode dispatch
- `app/src/code_review/git_dialog/commit.rs` — extended with `CommitAndCreatePr` intent, `allow_create_pr: bool` parameter, third intent button (hidden when the intent isn't meaningful), a private `CommitOutcome` enum for confirm results, and a chained `run_commit → run_push → create_pr` async
- `app/src/code_review/code_review_view.rs``open_pr_dialog()`, `allow_create_pr` computed and passed through `open_commit_dialog()`, `OpenCreatePrDialog` / `CommitAndCreatePr` action handlers, `update_git_operations_ui` refresh on `NewDiffsComputed`
- `app/src/util/git.rs``get_branch_diff_entries()` (branch-level numstat diff). `create_pr()` and `PrInfo` already existed.
## Current State
`GitDialog` owns commit and push modes with a shared shape: per-mode state struct, body renderer, confirm async, and dispatch in `mod.rs`. Each mode calls `show_toast` / `user_facing_git_error` (declared in `git_dialog/mod.rs`) on success and failure, then emits `GitDialogEvent::Completed`. The parent closes the dialog and refreshes metadata; it no longer knows anything about outcomes. `gh` CLI helpers (`run_gh_command`, `get_pr_for_branch`, `PrInfo`, `create_pr`) already exist.
## Proposed Changes
### 1. PR mode for `GitDialog` (`git_dialog/pr.rs`)
A new submodule, following the same shape as `commit.rs` and `push.rs`.
**State:**
```rust
pub struct PrState {
file_changes: Vec<FileChangeEntry>,
changes_expanded: bool,
summary_mouse_state: MouseStateHandle,
changes_scroll_state: ClippedScrollStateHandle,
}
```
**Sub-action:**
```rust
pub enum PrSubAction {
ToggleChangesExpanded,
}
```
**Body:** branch header + "Changes" section with aggregate stats (file count, +additions, -deletions) and expandable per-file list (scrollable, max 130px). Uses the shared `render_branch_section` / `render_chevron_icon` / `render_file_list` helpers in `git_dialog/mod.rs`.
**Constructor:** `pr::new_state(repo_path, ctx)` spawns `get_branch_diff_entries` to populate `file_changes`.
**Confirm:** `pr::start_confirm` spawns `create_pr(&repo_path)`.
- On success: calls `show_pr_created_toast(&pr_info, ctx)` (see below).
- On failure: logs the raw error and calls `show_toast(user_facing_git_error(&err), ctx)`.
- Either way emits `GitDialogEvent::Completed`.
**Toast helper:** `pr::show_pr_created_toast(pr_info, ctx)` — ephemeral `DismissibleToast` with message `"PR successfully created."` and a clickable "Open PR" `ToastLink` pointing at `pr_info.url`. Declared `pub(super)` so `commit.rs` can reuse it for the `CommitAndCreatePr` chain.
**Labels/icon:** title = "Create pull request"; confirm button = "Create PR" / `Icon::Github`; loading = "Creating…".
### 2. `GitDialog` mode dispatch (`git_dialog/mod.rs`)
- New variant `GitDialogMode::CreatePr(PrState)`
- New variant `GitDialogAction::Pr(PrSubAction)`
- New constructor `GitDialog::new_for_pr(repo_path, branch_name, ctx)`
- Title / body / focus / confirm / sub-action dispatch extended for `CreatePr`
- `new_for_commit` signature grows an `allow_create_pr: bool` parameter so commit mode can hide its "Commit and create PR" button when the intent isn't meaningful (existing PR or main branch). The caller encodes both conditions into the single boolean so the dialog doesn't need to know the underlying reasons.
### 3. `CommitAndCreatePr` intent (`git_dialog/commit.rs`)
- Adds `CommitIntent::CommitAndCreatePr` variant
- `confirm_label_for` / `confirm_icon_for` / `loading_label_for` extended
- `CommitState` gains `commit_and_create_pr_button: Option<ViewHandle<ActionButton>>``None` when `allow_create_pr` is false (existing PR or main branch)
- `new_state` takes `allow_create_pr: bool`. When false:
- The "Commit and create PR" button is omitted entirely (not just disabled)
- A `debug_assert!` catches callers that dispatched `CommitAndCreatePr` anyway; in release the subsequent `create_pr` call surfaces the real `gh` error via the normal failure path rather than silently rewriting the intent
- `apply_intent_selector` and `render_intent_buttons` skip the third button when it's `None`
- A private `CommitOutcome { Committed | Pushed | PrCreated(PrInfo) }` enum represents what actually ran, keeping "which stages fired" decoupled from the user's selected intent so the callback can't drift out of sync with the async body.
- `start_confirm` chains `run_commit``run_push` (for `CommitAndPush` or `CommitAndCreatePr`) → `create_pr` (for `CommitAndCreatePr`) in a single `ctx.spawn`, returning a `CommitOutcome`
- Success toasts by outcome:
- `Committed``"Changes successfully committed."`
- `Pushed``"Changes committed and pushed."`
- `PrCreated(pr)``show_pr_created_toast(&pr, ctx)` (same link-bearing toast as standalone PR creation)
- Failure → `show_toast(user_facing_git_error(...), ctx)`
- Either way emits `GitDialogEvent::Completed`
### 4. Code review view integration (`code_review_view.rs`)
- **`open_pr_dialog(ctx)`** — uses the existing `prepare_git_dialog` / `attach_git_dialog` helpers; constructs `GitDialog::new_for_pr(...)`.
- **`open_commit_dialog(intent, ctx)`** — computes `allow_create_pr = pr_info.is_none() && !is_on_main_branch()` from `diff_state_model` and passes it through to `GitDialog::new_for_commit`, so the commit dialog can hide the "Create PR" intent when it isn't meaningful.
- **Action wiring:**
- `OpenCreatePrDialog``self.open_pr_dialog(ctx)` (was TODO)
- `CommitAndCreatePr``self.open_commit_dialog(CommitIntent::CommitAndCreatePr, ctx)` (was TODO)
- **Git operations button refresh fix:** `DiffStateModelEvent::NewDiffsComputed` handler now calls `update_git_operations_ui(ctx)` so after a commit the button transitions from "Commit" → "Push" (or "Create PR") without waiting for another event.
Parent still knows nothing about outcomes — its `attach_git_dialog` subscriber remains just `Completed → close + refresh` and `Cancelled → close`.
### 5. Git utility (`util/git.rs`)
**`get_branch_diff_entries(repo_path)`** — returns per-file change stats for the branch diff:
- Detects base branch via `detect_main_branch`, current branch via `detect_current_branch`.
- Diffs `{base}..origin/{current}`, falling back to `{base}..HEAD` if the remote ref doesn't exist (e.g. branch not yet pushed).
- Parses `git diff --numstat` into `Vec<FileChangeEntry>`.
## End-to-End Flows
### Standalone "Create PR" flow
1. User clicks "Create PR" button → `OpenCreatePrDialog` action
2. `open_pr_dialog(ctx)``GitDialog::new_for_pr(...)`; `pr::new_state` spawns `get_branch_diff_entries`
3. Dialog renders with branch info and change summary
4. User clicks "Create PR" → `GitDialogAction::Confirm``pr::start_confirm` spawns `create_pr`
5. Confirm/cancel/close disabled, confirm label reads "Creating…"
6. On success → `show_pr_created_toast` fires ("PR successfully created." with "Open PR" link) → emits `Completed` → parent closes dialog + refreshes metadata (header button becomes "PR #N")
7. On error → toast with friendly message → emits `Completed` → parent closes dialog + refreshes
### "Commit and create PR" flow
1. User selects "Commit and create PR" in the commit dialog → `CommitIntent::CommitAndCreatePr`
2. Confirm handler chains `run_commit``run_push``create_pr` in a single `ctx.spawn`
3. On success → `show_pr_created_toast(&pr, ctx)``Completed` → parent closes dialog + refreshes
4. On failure at any stage → friendly error toast → `Completed` → parent closes dialog + refreshes
### "Commit and create PR" when the intent isn't meaningful (existing PR or main branch)
1. `open_commit_dialog` computes `allow_create_pr` from the diff state model (PR info + `is_on_main_branch`) and passes it to `GitDialog::new_for_commit`
2. The third intent button is omitted entirely (no disabled dead button)
3. The caller is expected to not dispatch `CommitAndCreatePr` in this state; a `debug_assert!` in `commit::new_state` catches violations in dev builds, and in release the subsequent `create_pr` failure surfaces via the normal error toast path
## Risks and Mitigations
### `gh` CLI not installed or not authenticated
Currently no pre-check. `create_pr` fails with a descriptive error from `gh`. The error surfaces as a friendly toast via the unified `Failed` path. Follow-up: add a `gh` availability check before enabling the action.
### Git operations button not updating after commit
Fixed by calling `update_git_operations_ui` in the `NewDiffsComputed` handler, so after a commit triggers a diff reload, the button state is re-evaluated.
## Testing and Validation
- Verify PR dialog opens with correct branch name and file change stats.
- Verify expanding changes shows per-file list with correct +/- stats.
- Verify loading state (all three chrome buttons disabled) during PR creation.
- Verify success shows the "PR successfully created." toast with "Open PR" link and header updates to "PR #N".
- Verify error shows friendly error toast and dialog closes.
- Verify "Commit and create PR" chains all three operations and shows the same PR toast on success.
- Verify commit dialog omits the third button entirely when the branch already has a PR OR when on the repo's main branch.
- Verify cancel/close/ESC dismisses the dialog without side effects.
- Verify the git operations button transitions correctly across Commit → Push → Create PR → PR #N.
## Follow-ups
- Add `gh` CLI availability/auth check before enabling "Create PR" and "Commit and create PR" actions.
- Support editing PR title and body (currently uses `--fill`).
- Support draft PRs.
- Mid-flight mode transitions during `CommitAndCreatePr` so the dialog reflects "Committing…" → "Pushing…" → "Creating PR…" stages.
+85
View File
@@ -0,0 +1,85 @@
# APP-3923: AI-autogenerated commit messages, PR titles, and PR descriptions
## Summary
Pre-populate the code review git dialogs with AI-generated copy so users don't have to write commit messages, PR titles, or PR descriptions by hand. When the commit dialog opens, a draft commit message is generated from the current diff and dropped into the editor. When a PR is created (either standalone "Create PR" or the "Commit and create PR" chain), the PR title and body are generated from the branch's diff and commit history just before `gh pr create` runs.
## Problem
APP-3922 shipped the "Create PR" dialog and the `CommitAndCreatePr` chain, but both relied on the user to write a commit message and both passed `--fill` to `gh pr create`, which just copies the most recent commit subject and body into the PR. That produces mediocre PR titles/descriptions and still requires the user to write a good commit message manually. Writing these is low-value boilerplate users typically want to skip.
## Goals
- Generate a draft commit message automatically when the commit dialog opens so the user only has to review and optionally edit it.
- Generate a PR title and body automatically at PR creation time (both flows: standalone and "Commit and create PR").
- Fail safely: if any generation call fails, the user is told what happened and can recover (type a message manually; retry the PR).
- Keep the user in full control: the generated commit message is always editable; the user can also clear it and type their own.
- Make the commit confirm button's enabled state directly reflect whether a committable message exists — no silent second generation at confirm time.
## Non-goals
- Adding a new feature flag for this capability. `FeatureFlag::GitOperationsInCodeReview` already gates the entire git dialog surface, so autogen is only reachable inside that gate.
- Adding an `AISettings` opt-out or an enterprise / customer-type guard for sending diffs to AI. Both are noted as explicit follow-ups (see `app/src/ai/generate_code_review_content/mod.rs` TODO) and tracked separately.
- A preview/edit UI for the generated PR title and body — they are sent directly to `gh pr create`.
- Regenerate button for commit messages / PR fields.
- Draft PR support (inherited from APP-3922).
- Editing reviewers, labels, or milestones in the dialog (inherited from APP-3922).
## Figma
none provided — this feature is copy-only and reuses the existing dialog layouts from APP-3920 / APP-3922.
## User experience
### Commit message autogeneration (commit dialog)
When the commit dialog opens:
- Placeholder reads `Generating commit message…`.
- Editor buffer is empty.
- Confirm button is disabled (no message, file changes may also still be loading).
- An AI generation request fires immediately in the background using the current diff as input (staged + unstaged, with untracked files included as synthetic diff hunks when `include_unstaged` is true).
On generation success:
- If the editor is still empty (user hasn't typed anything), the generated message is inserted into the editor.
- If the user has already typed a non-empty message, the generated draft is silently discarded — user input is never clobbered.
- Placeholder changes to `Type a commit message` (visible only if the user later clears the buffer).
- Confirm button becomes enabled once file changes have also loaded.
On generation failure (network, server, or empty response):
- Placeholder changes to `Type a commit message`.
- No toast — autogen is best-effort background work, the user can't retry it, and the empty editor plus placeholder already communicate what happened.
- Editor buffer stays empty.
- Confirm button stays disabled until the user types a non-empty message.
### PR title and body autogeneration
Both flows generate PR title and body at confirm time, right before `gh pr create` runs:
**Standalone "Create PR" dialog:**
1. User clicks `Create PR` → dialog goes into loading state (`Creating…`).
2. Compute diff vs main branch and collect commit subjects on the current branch.
3. Generate PR title via AI.
4. Generate PR body via AI.
5. Run `gh pr create --title <generated> --body <generated>`.
6. On success: standard "PR successfully created." toast with `Open PR` link (unchanged from APP-3922).
7. On AI title/body failure: fall back to `gh pr create --fill` so the PR still gets created (with the latest commit's subject/body). On any other step's failure (diff fetch, `gh pr create` itself, etc.): dialog closes, friendly error toast (unchanged from APP-3922 — error mapping is per-call-site log + `user_facing_git_error`).
**"Commit and create PR" chain (commit dialog with `CommitAndCreatePr` intent):**
1. Commit runs.
2. Push runs.
3. Same PR-title / PR-body / `gh pr create` sequence as above.
4. Same success toast.
### Diff input and truncation
- Max diff length sent to AI: 16,000 characters. Beyond that, the diff is truncated on a UTF-8 char boundary with a trailing `... (diff truncated)` marker.
- For commit message generation with `include_unstaged = true`, untracked files are synthesised into diff hunks so the LLM has context for new-file-only commits. Per-untracked-file cap: 4,000 bytes. Binary files are detected from the first 1,024 bytes and skipped.
- For PR generation, the diff is `{base}..origin/{current}` when the remote ref exists, falling back to `{base}..HEAD` otherwise. Commit subjects on the branch are also sent alongside the diff.
### Editor interactions and state rules
1. Confirm is enabled iff there is at least one file change **and** the commit message editor holds a non-empty (trimmed) string.
2. While generation is in flight, the editor is empty, so confirm is implicitly disabled by rule (1); no separate "is autogenerating" flag is exposed.
3. The generated draft never overwrites user input. If the user has typed anything by the time generation resolves, the draft is discarded.
4. Clearing a successful draft after the fact leaves the placeholder `Type a commit message` visible and disables confirm until the user types.
5. There is no confirm-time fallback regeneration for commit messages: once the open-time generation has resolved (success or failure), the user is responsible for the message.
6. A generation failure during PR creation falls back to `gh pr create --fill` so the PR is still created (using the latest commit's subject/body). Only a failure in the PR-creation command itself aborts the flow; in that case, for the `CommitAndCreatePr` chain, the commit and push already succeeded but the PR was not created, and the user can retry via the standalone "Create PR" button.
## Success criteria
1. Opening the commit dialog on a branch with changes kicks off a background AI request within the same frame; the placeholder reads `Generating commit message…` until the request resolves.
2. On generation success with an untouched editor, the generated message appears in the editor within a short time (bounded by AI latency) and the confirm button becomes enabled once file changes are loaded.
3. On generation success with a user-typed message, the generated message is discarded and the user's text is preserved.
4. On generation failure, the placeholder changes to `Type a commit message` (no toast), and confirm stays disabled until the user types.
5. The confirm button is disabled whenever the editor's trimmed content is empty, regardless of whether an auto-generation is still in flight.
6. Clearing a previously-populated AI draft flips the confirm button back to disabled and shows the `Type a commit message` placeholder.
7. Confirming a PR (standalone or via `CommitAndCreatePr`) creates the PR with an AI-generated title and body; the user never types either.
8. A network outage that causes PR title or body generation to fail falls back to `gh pr create --fill`; the PR is still created, using the latest commit's subject/body as title/body.
9. No user-facing AI-related UI appears outside the commit dialog and the two PR-creation confirm paths.
## Validation
- Open the commit dialog on a branch with a non-trivial diff and verify the `Generating commit message…` placeholder, followed by a populated editor with a reasonable draft.
- Type into the editor before the AI responds; verify the typed text is preserved and the draft is discarded.
- Disable networking, open the commit dialog, verify the fallback placeholder (no toast) and that confirm stays disabled until the user types.
- Populate the editor via AI, clear it, verify confirm disables and the `Type a commit message` placeholder re-appears.
- On a pushed branch with no existing PR, click `Create PR` and verify the created PR has a generated title and body (not `--fill`-derived).
- On a branch with pending changes, select `Commit and create PR` in the commit dialog, let it run, and verify commit + push + PR creation, with the PR body and title generated.
- Simulate a failure in PR body generation (e.g. mid-flight network drop) for the `CommitAndCreatePr` flow; verify the PR still gets created via `gh pr create --fill` and the usual success toast appears.
## Open questions
- Should we add a regenerate button for the commit message draft? Currently the user can clear the field and type their own, but they cannot re-trigger AI generation without re-opening the dialog.
- Should PR title and body be editable before `gh pr create` fires? Current design sends them blind.
- AI settings opt-out and enterprise customer-type guard are explicit follow-ups; see `app/src/ai/generate_code_review_content/mod.rs`.
+184
View File
@@ -0,0 +1,184 @@
# APP-3923: AI-autogenerated commit messages and PR metadata — Tech Spec
Product spec: `specs/APP-3923/PRODUCT.md`
Parent stack: APP-3918 (header button) → APP-3920 (commit/push dialog) → APP-3922 (create-PR dialog) → **APP-3923** (this branch).
## Problem
APP-3922 landed the `GitDialog::CreatePr` mode and the `CommitIntent::CommitAndCreatePr` chain, both of which called `gh pr create --fill`. `--fill` just copies the latest commit subject/body into the PR, and commit messages themselves had to be typed manually. We want AI-generated copy for all three: commit message (at dialog open time), PR title and PR body (at confirm time).
The work has three logical layers that needed new plumbing:
1. A server endpoint for AI generation of review-adjacent content.
2. A client-side block-service method and request/response types.
3. Git helpers that produce the LLM input (diff + branch commit messages).
Plus editor-state changes in `commit.rs` so that an autogenerated draft is discoverable, overridable, and failure-visible.
## Relevant code
- `app/src/ai/generate_code_review_content/api.rs` — new `GenerateCodeReviewContentRequest` / `Response` + `OutputType` enum
- `app/src/ai/generate_code_review_content/mod.rs` — module root (plus the follow-up TODO)
- `app/src/ai/mod.rs:44` — registers the new module
- `app/src/server/server_api/block.rs:54,175-196` — new `BlockClient::generate_code_review_content` trait method and `ServerApi` impl, following the pattern of `generate_shared_block_title`
- `app/src/util/git.rs:445-527``MAX_DIFF_CHARS_FOR_AI`, `MAX_UNTRACKED_FILE_BYTES`, `BINARY_CHECK_BYTES`, `MAX_PR_TITLE_BYTES`, `truncate_on_char_boundary`, `get_diff_for_commit_message`
- `app/src/util/git.rs:677-721``get_diff_for_pr`, `get_branch_commit_messages`
- `app/src/util/git.rs:723-798``create_pr(repo_path, Option<&str>, Option<&str>)` with `--fill` fallback when title/body are `None`; `sanitize_pr_title` helper
- `app/src/code_review/git_dialog/commit.rs:66-70` — placeholder constants
- `app/src/code_review/git_dialog/commit.rs:239-305``generate_commit_message` (open-time)
- `app/src/code_review/git_dialog/commit.rs:313-319``is_ready_to_confirm`
- `app/src/code_review/git_dialog/commit.rs:362-473``start_confirm` (PR title/body gen for `CommitAndCreatePr`)
- `app/src/code_review/git_dialog/pr.rs:101-174``start_confirm` + `create_pr_with_ai_content` (shared helper used by both standalone PR and `CommitAndCreatePr`; parallelizes title/body with `futures::try_join!`, falls back to `--fill` on AI failure)
- `app/src/code_review/git_dialog/mod.rs:484-496``refresh_confirm_enabled` call site updated for the new `is_ready_to_confirm` signature
- Server side: `warp-server/router/handlers/generate_code_review_content.go` (already deployed)
## Current state
Before this branch:
- `commit.rs` required a typed commit message; placeholder was `"Leave blank to autogenerate a commit message"` but there was no autogeneration wired — the confirm was disabled on empty.
- `pr.rs::start_confirm` called `create_pr(&repo_path)``gh pr create --fill`.
- `commit.rs::start_confirm` `CommitAndCreatePr` branch likewise called `create_pr(&repo_path)`.
- `BlockClient` only had `generate_shared_block_title` as an AI-adjacent method.
- `util/git.rs` had no diff-for-AI helpers.
The dialog parent (`GitDialog`) owns chrome (title, buttons, loading state) and each mode owns its own state, body renderer, and confirm async; events collapse to `Completed | Cancelled`. That contract is preserved by this branch — all new work lives inside the existing per-mode submodules.
## Proposed changes
### 1. `generate_code_review_content` module (`app/src/ai/generate_code_review_content/`)
Mirrors the shape of `generate_block_title/`: a `mod.rs` that declares `pub(crate) mod api;` and an `api.rs` with request/response types.
```rust path=null start=null
pub enum OutputType {
CommitMessage,
PrTitle,
PrDescription,
}
pub struct GenerateCodeReviewContentRequest {
pub output_type: OutputType,
pub diff: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub branch_name: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub commit_messages: Vec<String>,
}
pub struct GenerateCodeReviewContentResponse {
pub content: String,
}
```
A single endpoint + request type is enough because all three output types share the same inputs (diff, optional branch name, optional commit subjects). The server dispatches on `output_type`.
### 2. `BlockClient::generate_code_review_content`
Added alongside `generate_shared_block_title` in `app/src/server/server_api/block.rs`. The `ServerApi` implementation POSTs to `{server_root_url}/ai/generate_code_review_content` with bearer auth, JSON body, and JSON response decoding — same skeleton as `generate_shared_block_title`. Reusing `BlockClient` keeps this off the GraphQL path (which would require a new mutation and cynic codegen) and matches where block-title gen already lives.
### 3. Diff helpers in `app/src/util/git.rs`
Four module-scope consts (`MAX_DIFF_CHARS_FOR_AI = 16_000`, `MAX_UNTRACKED_FILE_BYTES = 4_000`, `BINARY_CHECK_BYTES = 1_024`, `MAX_PR_TITLE_BYTES = 200`) plus a `truncate_on_char_boundary` helper and three git-diff helpers, all `#[cfg(feature = "local_fs")]` with wasm stubs to match existing conventions in the file. All byte-length truncation uses `truncate_on_char_boundary` to avoid UTF-8 panics on diffs/source files containing non-ASCII text.
- `get_diff_for_commit_message(repo_path, include_unstaged) -> Result<String>`
- `git diff HEAD` when `include_unstaged`, else `git diff --cached`.
- When `include_unstaged`, iterates `git ls-files --others --exclude-standard -z` (NUL-separated to survive paths with spaces/non-ASCII), skips binaries via `warp_util::file_type::is_buffer_binary(&bytes[..BINARY_CHECK_BYTES])`, and appends synthetic unified-diff hunks for each new file (capped at `MAX_UNTRACKED_FILE_BYTES`) so the LLM sees new-file-only commits.
- Final output truncated at `MAX_DIFF_CHARS_FOR_AI` with `\n... (diff truncated)` marker.
- `get_diff_for_pr(repo_path) -> Result<String>`
- Diffs `{base}..origin/{current}` when `git rev-parse --verify origin/{current}` succeeds, else `{base}..HEAD`.
- Same truncation rule as above.
- `get_branch_commit_messages(repo_path) -> Result<Vec<String>>`
- `git log {base}..HEAD --format=%s`, one subject per vec element.
### 4. `create_pr` signature change
`create_pr(repo_path, title: Option<&str>, body: Option<&str>) -> Result<PrInfo>` replaces `create_pr(repo_path)`. When both fields are `Some`, invokes `gh pr create --title <t> --body <b>` (title passes through `sanitize_pr_title` to first-line and cap at `MAX_PR_TITLE_BYTES` — GitHub silently collapses newlines in titles otherwise). When either is `None`, falls back to `gh pr create --fill`, used as a last-resort source when AI title/body generation fails so the PR is still created. Both wasm stub and both call sites (`pr.rs` and `commit.rs`) updated.
### 5. Commit-dialog open-time autogen (`commit.rs`)
Two placeholder constants in `commit.rs`:
- `GENERATING_PLACEHOLDER_TEXT = "Generating commit message…"` (shown while gen is in flight; was `"Leave blank to autogenerate a commit message"`).
- `FALLBACK_PLACEHOLDER_TEXT = "Type a commit message"` (shown after gen resolves, success or failure).
A new private `generate_commit_message(repo_path, branch_name, include_unstaged, ctx)` fires from `new_state` at dialog construction. It:
1. Awaits `get_diff_for_commit_message` and `block_client.generate_code_review_content(CommitMessage, ...)`.
2. On success: if the editor is still empty (`!buffer_text.trim().is_empty()` is false), `editor.system_reset_buffer_text(generated.trim(), ctx)`; otherwise discards. Placeholder swaps to `FALLBACK_PLACEHOLDER_TEXT`. `refresh_confirm_enabled`.
3. On failure: placeholder swaps to `FALLBACK_PLACEHOLDER_TEXT`, `refresh_confirm_enabled`. No toast — the empty editor plus placeholder already communicate that no draft arrived, and the failure isn't retryable. `log::warn!` for the underlying error.
No `is_autogenerating` field on `CommitState`. Confirm enablement is purely `!file_changes.is_empty() && commit_message(state, app).is_some()`; while gen is in flight the buffer is empty, so this is false naturally.
### 6. Confirm-time PR gen: shared `create_pr_with_ai_content` helper
Both flows (standalone `pr::start_confirm` and the `CommitAndCreatePr` branch of `commit::start_confirm`) delegate to `pr::create_pr_with_ai_content(repo_path, branch_name, block_client)`:
1. `get_diff_for_pr(repo_path)`.
2. `get_branch_commit_messages(repo_path)` (wrapped in `.unwrap_or_default()` — commit subjects are advisory).
3. Parallel `block_client.generate_code_review_content` calls (`PrTitle` + `PrDescription`) via `futures::try_join!`, both sharing the same `diff`, `branch_name`, and `commit_messages`.
4. On AI success: `create_pr(&repo_path, Some(&pr_title), Some(&pr_body))`.
5. On AI failure (either call): `log::warn!` and fall back to `create_pr(&repo_path, None, None)` so the PR still gets created via `gh pr create --fill`.
Non-AI errors (diff fetch, `gh pr create` itself) bubble via `?` into the existing `Err` handler, which logs and calls `show_toast(user_facing_git_error(&err.to_string()), ctx)`. AI errors no longer reach that path since they're converted to the `--fill` fallback.
### 7. `is_ready_to_confirm` simplification
Before: `(state, app)` → required non-empty file changes AND non-empty commit message.
Interim (mid-branch): dropped `app`, added `is_autogenerating` flag, made message optional.
Final: `(state, app)` → required non-empty file changes AND non-empty commit message again. The `is_autogenerating` flag is gone; the empty-buffer state during gen is what gates confirm.
`start_confirm` is correspondingly simplified: `let Some(message) = commit_message(state, ctx) else { return; };` as a defensive guard (handles keyboard-shortcut dispatch that bypasses the button's disabled state), then straight into `run_commit`. The previously-added confirm-time AI fallback branch is deleted.
## End-to-end flows
### Commit message autogeneration
```mermaid
sequenceDiagram
participant User
participant CommitDialog as commit.rs
participant Git as util/git.rs
participant AI as BlockClient
User->>CommitDialog: Open dialog
CommitDialog->>CommitDialog: placeholder = "Generating…"; confirm disabled
CommitDialog->>Git: get_diff_for_commit_message
Git-->>CommitDialog: diff (≤ 16k chars, + synthesised untracked files)
CommitDialog->>AI: generate_code_review_content(CommitMessage)
alt success
AI-->>CommitDialog: draft
CommitDialog->>CommitDialog: if editor empty, insert draft<br/>placeholder = "Type a commit message"
CommitDialog->>User: editor populated → confirm enabled
else failure
AI-->>CommitDialog: error
CommitDialog->>CommitDialog: placeholder = "Type a commit message"<br/>log::warn + refresh_confirm_enabled
CommitDialog->>User: blank editor → confirm still disabled
end
```
### PR title/body autogeneration (both flows)
```mermaid
sequenceDiagram
participant User
participant Dialog as pr.rs / commit.rs
participant Git as util/git.rs
participant AI as BlockClient
participant GH as gh CLI
User->>Dialog: Click Create PR (or Commit and create PR)
Dialog->>Dialog: set_loading("Creating…" or intent loading label)
note over Dialog,Git: CommitAndCreatePr also runs run_commit + run_push first
Dialog->>Git: get_diff_for_pr
Git-->>Dialog: diff
Dialog->>Git: get_branch_commit_messages
Git-->>Dialog: commit subjects
Dialog->>AI: generate_code_review_content(PrTitle)
AI-->>Dialog: title
Dialog->>AI: generate_code_review_content(PrDescription)
AI-->>Dialog: body
Dialog->>GH: gh pr create --title --body
GH-->>Dialog: PrInfo | error
Dialog->>User: success toast with Open PR link | friendly error toast
```
### State machine for the commit message editor
```mermaid
stateDiagram-v2
[*] --> Generating
Generating --> Populated: gen success && editor empty
Generating --> Failed: gen error or empty response
Generating --> UserTyped: user types during gen
Populated --> UserTyped: user edits
Populated --> Empty: user clears
Failed --> UserTyped: user types
Empty --> UserTyped: user types
UserTyped --> Empty: user clears
Populated --> [*]: confirm
UserTyped --> [*]: confirm
Failed --> [*]: cancel
Empty --> [*]: cancel
note right of Failed
placeholder = "Type a commit message"
no toast (silent)
confirm disabled
end note
note left of Empty
placeholder = "Type a commit message"
confirm disabled
end note
```
## Risks and mitigations
**AI latency blocks the user.** Commit message gen runs at open time in the background, so the user can start typing immediately; gen results are discarded if the user has typed anything. PR title/body gen runs at confirm time, which does extend the `Creating…` loading phase by two sequential AI calls. Mitigation is bounded by server SLA; a failure simply surfaces the existing friendly error toast.
**AI errors don't surface to the user.** AI-generation errors are caught inside `create_pr_with_ai_content` and transparently fall back to `gh pr create --fill`, so the user sees a successfully-created PR with latest-commit-derived copy rather than an error toast. Only non-AI errors (diff fetch, `gh pr create` itself) still flow through `user_facing_git_error`, which doesn't know about them specifically and maps them to the generic git fallback. Improving that mapping is an explicit follow-up.
**Sequential PR gen calls double the latency.** Addressed in-branch: `create_pr_with_ai_content` now runs `PrTitle` + `PrDescription` concurrently via `futures::try_join!`. The diff payload is cloned across both requests but latency is bounded by the slower of the two.
**`CommitAndCreatePr` chain can leave the branch pushed but PR not created.** If PR creation fails after `run_commit` + `run_push` (non-AI failure — AI failures now fall back to `--fill`), the commit and push are real but no PR exists. The header button transitions to the `CreatePr` state on the next diff-metadata refresh, so the user can retry via the standalone dialog. Documented in the product spec; no code-level mitigation in-branch.
**Duplicate PR title/body gen code.** The ~25-line PR title + PR body generation block appears verbatim in both `commit.rs::start_confirm` (`CommitAndCreatePr` branch) and `pr.rs::start_confirm`. An extracted helper would sit naturally in `git_dialog/mod.rs`. Deferred to follow-up.
**Privacy / opt-out.** `GitOperationsInCodeReview` gates the UI, but does not address AI-specific privacy concerns (sending diffs to an LLM). See the TODO at the top of `app/src/ai/generate_code_review_content/mod.rs` — follow-up work needs to add an `AISettings` toggle (mirroring `is_shared_block_title_generation_enabled`) and a customer-type guard that excludes Enterprise unless on Warp plan or dogfood, matching the pattern in `terminal/share_block_modal.rs::should_send_title_gen_request`.
## Testing and validation
No automated tests added — the parent branches (APP-3920, APP-3922) also ship without tests and the `git_dialog` module has no test harness yet. Manual validation covers each path in the product spec's **Validation** section.
When we add a test harness, the highest-value targets are:
- `is_ready_to_confirm` transitions through the editor states (empty → typed → cleared).
- `generate_commit_message`'s "user typed before response landed → discard" path.
- `get_diff_for_commit_message` truncation and untracked-file synthesis.
## Follow-ups
- Add `AISettings::is_commit_message_generation_enabled` (or similar) and wire it through `generate_commit_message` and `create_pr_with_ai_content`. Mirror `share_block_modal.rs::should_send_title_gen_request`.
- Add customer-type guard for Enterprise users (allow Warp plan + dogfood, deny otherwise).
- Route AI errors to dedicated toast copy instead of the git-error mapper. Options explored: typed-error marker + `downcast_ref` (clean but heavier) vs. fall-through-on-unknown-error (simpler but changes behavior for unknown git errors). Pick one and implement.
- Extract the `origin/{current}`-or-HEAD resolution helper — duplicated between `get_branch_diff_entries` (APP-3922) and `get_diff_for_pr` (this branch).
- Consider a regenerate button for the commit message draft, and a preview/edit UI for PR title/body before `gh pr create` fires.
- Update PR #23945 title and description to cover all three generated fields (currently title only mentions commit messages).

Some files were not shown because too many files have changed in this diff Show More