first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+100
View File
@@ -0,0 +1,100 @@
# MCP Tool Call JSON Tree Rendering — Product Spec
Linear: APP-2527
Figma: none provided (reference screenshot supplied in the originating request showing a generic collapsible JSON tree with chevron expanders and typed value colors; the visual treatment must follow Warp UI conventions, not the screenshot's exact styling)
## Summary
Render the JSON request (arguments) and JSON response of an MCP tool call in the agent UI as an interactive, collapsible tree with chevron expanders and theme-driven colors for keys and values, instead of the current flat pretty-printed JSON blob. Long string values are elided by default and can be expanded in place via a chevron. The underlying JSON tree widget is designed as a generic, reusable UI component.
## Problem
When the agent calls an MCP tool, expanding the tool-call detail currently shows the request arguments and the response as a single unformatted pretty-printed JSON string. For anything beyond a couple of fields this is hard to scan: there is no way to collapse uninteresting sub-objects, no visual distinction between keys and values, and long string values (file contents, logs, base64, stack traces) blow out the height of the view and bury the rest of the structure. Users need to quickly understand what was sent to a tool and what came back.
## Example
The following mock illustrates the intended rendering style for an expanded MCP tool call detail. `▶` is the right-pointing (collapsed) chevron; `▼` is the down-pointing (expanded) chevron. Colors represent value types — keys in cyan/blue, strings in green, numbers in yellow, booleans in magenta, null muted, type annotations in secondary text. Exact palette is determined by Warp theme tokens, not by this mock.
```
Request
▼ {} 3 keys
path: "/home/user/project"
depth: 2
▼ filters: [] 2 items
0: "*.rs"
1: "*.toml"
Response
▼ {} 2 keys
count: 42
▶ files: [] 3 items ← collapsed; click to expand
```
Long string elision (collapsed → expanded):
```
summary: "This is a very long descri…" ▶
↓ click ▶
summary: ▼ "This is a very long description that spans many
characters and would dominate the view if always shown."
```
## Behavior
### Where this applies
1. The tree rendering applies wherever the expanded detail of an MCP tool call is shown in the agent block list — both the request arguments and the response body. It replaces the current single selectable pretty-printed JSON text for MCP tool calls. It does not change the collapsed header row (the one-line `MCP Tool: <name>` summary), the accept/reject affordances, or any non-MCP action rendering (shell commands, file edits, etc.).
2. The tree is shown only when the MCP tool-call detail is expanded, matching today's behavior where MCP content appears only when the action header is expanded. Collapsing the header hides the tree.
### Request and response sections
3. When expanded, the detail shows the request arguments as a tree. The root of the request tree is the tool's argument object (the JSON passed to the tool).
4. Once a response is available, the detail additionally shows the response as a tree below the request, under a clear visual/label separation between request and response (e.g. "Request" and "Response" labels with a divider). Before a response exists, only the request is shown.
5. If the tool call is still pending (blocked awaiting approval, or running), the request tree renders as soon as the arguments are known; the response section is absent until a result arrives.
### Tree structure and expansion
6. Each JSON value renders as one of: object (`{}`), array (`[]`), string, number, boolean, or null.
7. Objects and arrays are collapsible nodes. Each collapsible node renders a chevron expander at its left edge: pointing right when collapsed, pointing down when expanded. Scalar values (string, number, boolean, null) have no chevron and are not collapsible (except long strings — see Long string elision).
8. A collapsible node's row shows, in order: the chevron, the key (when the node is a member of an object) or the index (when it is an element of an array), and a type/size annotation. The annotation conveys the container type and item count, e.g. `{} 4 keys`, `{} 1 key`, `[] 3 items`, `[] 1 item`, `[] 0 items`, `{} 0 keys`. The annotation count is the sole mechanism for conveying that a node is non-empty; no inline preview of child keys or values is shown on a collapsed row.
9. Clicking anywhere on a collapsible node's row (chevron or label) toggles its expanded/collapsed state. Toggling one node does not change the state of any sibling, ancestor, or descendant node.
10. When a node is expanded, its children render indented one level deeper than the node, vertically stacked, each on its own row. Indentation depth increases by a consistent amount per nesting level so structure is visually obvious.
11. Child rows of an object show `key: value` where the key is the object member name. Child rows of an array show `index: value` where the index is the 0-based position. Scalar children render their value inline on the same row as the key/index; object/array children render as nested collapsible nodes.
12. An empty object renders as `{} 0 keys` and an empty array as `[] 0 items`; they have no chevron and do not respond to click, and never expand to an empty body.
### Default expansion state
13. All nodes in the tree default to expanded on first render. The MCP tool call detail block is scrollable and height-capped, so a fully-open tree does not push content off-screen. Users can collapse individual nodes by clicking their chevrons.
14. No auto-collapse threshold is applied. The tree renders fully open regardless of depth or node count.
15. Expansion state is per tool-call-detail view state. It persists while the conversation stays open (collapsing and re-expanding the action header restores the user's last per-node expansion state for that tool call rather than resetting to defaults). It does not need to persist across app restarts or conversation reloads.
16. If a tool call response arrives while the action header is collapsed, the response data is retained; expanding the header shows both the request and response trees. No data is lost due to the header being collapsed at the time of response arrival.
17. The tree body scrolls vertically when the expanded tree exceeds the height of the action detail container. A maximum height cap is applied to the tree body (consistent with the existing max-height cap used for command editor bodies) so that a fully expanded tree does not push subsequent blocks off-screen. Scrolling the tree does not interfere with scrolling the outer block list.
### Typed colors
18. Keys, and each scalar value type, render in visually distinct colors sourced from the active Warp theme (no hard-coded colors). At minimum these categories are visually distinguishable from each other and from plain body text: object/array keys (and array indices), string values, number values, boolean values, and null values. Container type/size annotations (`{} 4 keys`) render in a muted/secondary text color.
19. The colors adapt to the active theme and remain legible against the detail's background in both light and dark themes; they derive from theme tokens so a theme switch updates them without restart.
20. Punctuation/structural glyphs (braces, brackets, colons, quotes around strings) follow a consistent, readable treatment and must not be mistaken for values.
### Long string elision
21. A string value whose length exceeds a threshold (single-line display length) is elided by default: it shows a truncated preview ending in an ellipsis affordance, with a chevron (or equivalent expander) indicating it can be expanded.
22. Activating a long string's expander reveals the full string value in place (wrapped across lines as needed) without collapsing or disturbing surrounding nodes; activating it again re-collapses to the elided preview. Toggling a long string is independent of object/array node expansion state and follows the same persistence rule as node expansion (invariant 15).
23. Strings at or below the threshold render in full inline with no expander.
24. Multi-line strings (containing newlines) are treated as long for elision purposes: the collapsed preview shows the first line (or a truncated portion) with the expander; expanding shows the full multi-line content.
### Selection, copy, and context menu
25. The user can select text within the rendered tree (keys and values) and copy it with the standard copy shortcut. Copying a selection yields the visible text of the selected region. Copy with no selection is a no-op.
26. Right-clicking a tree node row or a Request/Response section label shows a context menu with at minimum:
- **Copy** — copies the current text selection. Disabled (greyed out) when nothing is selected.
- **Copy JSON** — copies the complete raw JSON of the subtree rooted at the right-clicked node (or the full section JSON when the label is right-clicked), formatted as pretty-printed JSON. For a scalar node this copies the scalar value as its JSON representation.
27. "Copy JSON" always copies the complete underlying JSON, regardless of whether the node is collapsed or expanded. This allows extracting a subtree without having to fully expand it first.
### Malformed / edge-case data
28. The response of an MCP tool call may not be a single JSON object — it can be structured content, one or more text content items, or an error. Rendering handles each:
- Structured/JSON content renders as the tree described above.
- Plain text content that is not valid JSON renders as a string value (subject to long-string elision), not as a failed/empty tree.
- An error result renders as a clearly labeled error message (e.g. `Error: <message>`) rather than an empty or misleading tree.
- A cancelled tool call renders a clear "cancelled" indication rather than an empty tree.
29. If the request arguments are absent or null (a tool called with no arguments), the request tree renders an empty/`null` indication rather than a broken node.
30. Values that are valid JSON but unusual — empty string, very large numbers, numbers that are whole-valued floats, unicode, nested arrays of objects — all render without panicking and without losing data. Whole-number integer arguments display as integers (e.g. `5`, not `5.0`), consistent with how the tool call is actually dispatched.
31. **Known limitation — duplicate object keys:** `serde_json::Value::Object` normalizes duplicate keys before rendering, retaining only the last value for any given key. Truly duplicate-keyed JSON objects cannot be represented in the implementation's chosen data structure. In practice MCP tool results do not produce duplicate keys.
### Streaming
32. While the tool-call request arguments are still streaming in, the request tree may update as more of the structure arrives; partial/in-progress structure renders without flicker that resets the user's expansion state for already-rendered nodes.
### Consistency and non-regression
33. The expanded MCP detail remains inside the same bordered action container it uses today, with the same surrounding spacing, header, and footer behavior; only the body content (formerly a JSON blob) changes to the tree.
34. Keyboard accept/reject/expand behavior of the action header is unchanged.
35. Non-MCP action details (commands, edits, web fetch, etc.) are visually unaffected by this change.
+267
View File
@@ -0,0 +1,267 @@
# MCP Tool Call JSON Tree Rendering — Tech Spec
Linear: APP-2527
Companion product spec: [`specs/APP-2527/PRODUCT.md`](./PRODUCT.md)
## Context
Today an expanded MCP tool-call detail is rendered as a single selectable, monospace, pretty-printed JSON string. Both the request arguments and the response are concatenated into one `String` and shown in one `Text` element. We are replacing that body with an interactive, collapsible, theme-colored JSON tree — built as a generic, reusable `warpui`-style component so it can serve other surfaces (MCP resource results, structured agent outputs, etc.) without re-implementation. The collapsed header row, accept/reject flow, and all non-MCP action rendering are unchanged.
All references pinned to commit `46265f499a3a32a488f640c0fce7565bb763496f`.
Key existing code:
- [`app/src/ai/blocklist/inline_action/requested_command.rs` (1393-1484) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L1393-L1484) — `RequestedCommandView::render`'s `should_render_mcp_content` branch. This is the exact block being replaced.
- [`app/src/ai/blocklist/inline_action/requested_command.rs` (1438-1445) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L1438-L1445) — where `CallMCPToolResult::{Success,Error,Cancelled}` is turned into `result_text`.
- [`app/src/ai/blocklist/inline_action/requested_command.rs` (475-481) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/requested_command.rs#L475-L481) — `RequestedCommandView` fields including `mcp_content_selection_handle` and `mcp_content_selected_text`. Tree expansion state will be added here.
- [`app/src/ai/blocklist/block.rs` (2043-2080) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/block.rs#L2043-L2080) — where `command_text` is built as `MCP Tool: {name} ({display_input})` including integer-coercion via `coerce_integer_args`. The raw `input: serde_json::Value` and `name` are available here.
- [`app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs` (105-126, 169-286) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/action_model/execute/call_mcp_tool.rs#L105-L126) — `coerce_integer_args` (`pub(crate)`), already reused by `block.rs`.
- [`crates/ai/src/agent/action_result/mod.rs` (1056-1077) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/crates/ai/src/agent/action_result/mod.rs#L1056-L1077) — `CallMCPToolResult::Success { result: rmcp::model::CallToolResult }`, `Error(String)`, `Cancelled`. `CallToolResult` carries `structured_content: Option<serde_json::Value>` and `content: Vec<Content>` (text items).
- [`crates/warp_core/src/ui/theme/color.rs` (361-424) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/crates/warp_core/src/ui/theme/color.rs#L361-L424) — `WarpTheme` ANSI accessors (`ansi_fg_green/yellow/blue/cyan/magenta`) and `internal_colors::{text_main, text_sub, text_disabled}`.
- [`app/src/ai/blocklist/inline_action/inline_action_header.rs` (96-107) @ 46265f4](https://github.com/warpdotdev/warp/blob/46265f499a3a32a488f640c0fce7565bb763496f/app/src/ai/blocklist/inline_action/inline_action_header.rs#L96-L107) — existing chevron/expansion plumbing for reference; the generic component uses `Icon::ChevronRight`/`Icon::ChevronDown` from `warpui` directly rather than importing `render_expansion_icon`, to avoid a wrong-direction module dependency.
See `PRODUCT.md` for user-visible behavior; this spec does not restate it.
## Design alternatives
### A. Widget architecture: where does the tree component live?
**Option A1 — Generic `warpui`-level component (recommended)**
Add a standalone `JsonTreeView` as a `warpui`-level element in `app/src/ui_components/json_tree.rs` (same layer as other reusable view utilities, avoiding a `serde_json` dependency in the `warpui` crate itself). The component takes a `&serde_json::Value`, a `JsonTreeState` (expansion map), a `JsonTreeColors` (pre-resolved theme colors), and callbacks for toggle/copy, and returns a `Box<dyn Element>`. It has no dependency on agent-specific types.
The `JsonTreeColors` mapping (resolved from `WarpTheme` at render time, no hard-coded values):
- key / index → `theme.ansi_fg_cyan()`
- string value → `theme.ansi_fg_green()`
- number value → `theme.ansi_fg_yellow()`
- bool value → `theme.ansi_fg_magenta()`
- null value → `internal_colors::text_disabled(theme, background)`
- type/size annotation (`{} 4 keys`) and punctuation → `internal_colors::text_sub(theme, background)`
Pros:
- Directly reusable for `ReadMCPResourceResult`, structured agent outputs, settings inspectors, or any future surface showing JSON.
- Clear ownership boundary; agent code calls the component but does not contain rendering logic.
- Testable in isolation without agent scaffolding.
Cons:
- Requires deciding the right crate layer (app-level component vs. warpui crate) before starting — small upfront decision.
- Slightly more initial setup than embedding inline.
**Option A2 — Inline in `requested_command.rs`**
Put the tree rendering functions directly inside `requested_command.rs` or a sibling `mcp_json_tree.rs` in the `inline_action` module.
Pros:
- Zero new crate surface; minimal change to module organization.
- Faster to write initially.
Cons:
- Code is not reusable without copy-paste or moving it later.
- Conflates MCP-specific logic (result parsing, integer coercion) with generic tree rendering.
**Recommendation: A1.** The minimal extra setup pays off immediately — `ReadMCPResourceResult` is the obvious next user, and the generic component is the right level of abstraction.
---
### B. Element construction: recursive build vs. flattened virtualized list
**Option B1 — Recursive element build (recommended)**
Build a `Flex::column` of rows recursively, traversing only the expanded portion of the tree. Collapsed nodes contribute one row; their children are skipped entirely.
Pros:
- Simple implementation: natural match to the JSON recursive structure.
- Zero per-node overhead for collapsed subtrees — large payloads stay fast as long as users don't expand everything.
- Straightforward to add per-row click handlers, indentation spacers, and formatted-text spans.
Cons:
- If a user expands a very deep/wide tree, all rows are materialized at once. In pathological cases (e.g. 10,000-element flat array fully expanded) this could be slow.
**Option B2 — Flattened virtualized list**
Pre-walk the visible tree into a flat `Vec<TreeRow>`, then render only the rows in the viewport using a virtualized scroll container.
Pros:
- Handles arbitrarily large fully-expanded trees efficiently.
Cons:
- Substantially more complex: requires a virtualization primitive that doesn't exist in `warpui` today.
- MCP payloads are rarely large enough to need this.
**Recommendation: B1**, with a follow-up cap (e.g. "show first N items then a '…show more' row") if real-world payloads prove problematic. The cap can be added entirely inside the `JsonTreeView` component without changing the caller.
---
### C. Expansion state storage: path-keyed vs. node-identity-keyed
**Option C1 — Path-keyed `HashMap<JsonPath, bool>` (recommended)**
A `JsonPath` is a stable sequence of key/index segments (e.g. `["response", "files", 2]`) derived by traversing the tree. State is looked up by path on each render.
Pros:
- Robust to streaming re-parses: the same logical node keeps its expansion state as bytes arrive, because the path is deterministic for a given position in the JSON structure.
- No need to assign stable IDs to tree nodes.
Cons:
- Path derivation adds a small cost per render; negligible for MCP payload sizes.
- Two structurally identical sibling objects share the same path — but in practice this is harmless (toggling either sibling restores the same state for both, which is acceptable).
**Option C2 — Node-identity-keyed (e.g. pointer or arena index)**
Assign each node a stable integer ID at parse time.
Pros:
- O(1) lookup by ID; truly independent state for structurally identical siblings.
Cons:
- Requires an arena allocator or pre-walk step to assign IDs.
- IDs are invalidated on re-parse (streaming), requiring a reconciliation step to preserve expansion state.
**Recommendation: C1.** The streaming-stability advantage is decisive; the structural-sibling limitation is not meaningful in practice.
---
### D. Request data flow: structured value vs. re-parsing the string
**Option D1 — Store coerced `serde_json::Value` on `RequestedCommandView` (recommended)**
Extend `handle_mcp_tool_stream_update` in `block.rs` (lines 2059-2079) to pass the coerced `display_input: serde_json::Value` and `name: String` alongside `command_text`. Add a `mcp_request: Option<McpRequest { name, args }>` field to `RequestedCommandView`.
Pros:
- Clean: no lossy string round-trip; integer coercion is inherited from the existing `coerce_integer_args` path.
- The structured value is already available at the call site.
Cons:
- Requires touching the `handle_mcp_tool_stream_update` call signature.
**Option D2 — Re-parse `command_text` in the view**
Extract the JSON from the `"MCP Tool: name (<value>)"` string at render time.
Pros:
- No changes to call sites.
Cons:
- Fragile: the format string is not stable and the outer wrapper makes clean JSON extraction unreliable.
- Integer coercion would need to be re-applied.
**Recommendation: D1.**
---
### E. Context menu / Copy JSON implementation
**Option E1 — Custom right-click handler with `warpui` Menu (recommended)**
Use `Hoverable::with_on_right_click` (already used in other inline actions) to show a `Menu` element containing "Copy" and "Copy JSON" items. Each row in the tree registers its own right-click handler, capturing the `JsonPath` of that row.
**Note on toggle disambiguation:** The expansion-state API uses two separate `HashMap<Vec<PathSegment>, bool>` maps — one for container node toggle state and one for long-string toggle state — along with two corresponding action variants (`ToggleJsonNode` and `ToggleJsonString`). This provides the independent persistence required between object/array expansion and long-string expansion. Implemented in Phase 2.
Pros:
- Consistent with existing right-click menus elsewhere in the app.
- Per-row context (the path captured in the handler) allows "Copy JSON" to copy exactly the subtree at that row.
Cons:
- Each rendered row needs a right-click handler, adding a small amount of per-row boilerplate.
**Option E2 — Single root right-click handler + hit-test**
Attach one right-click handler to the whole tree container and determine which row was clicked by hit-testing the mouse position.
Pros:
- Fewer closures.
Cons:
- Hit-testing is non-trivial with the existing element model and would require storing row bounding boxes.
**Recommendation: E1.** Per-row handlers are simpler and follow existing patterns.
## Proposed changes and phasing
The implementation naturally divides into three phases. Each phase is independently reviewable and shippable.
---
### Phase 1 — Generic `JsonTreeView` component and unit tests
**Goal:** A standalone, tested component that renders a `serde_json::Value` as an interactive tree. No changes to any agent or MCP code in this phase.
**Files:**
- `app/src/ui_components/json_tree.rs` (new) — the `JsonTreeView` component. Public surface:
- `pub struct JsonTreeColors` — resolved `ColorU`s per value type, built from `WarpTheme` per the mapping in Design §A1.
- `pub struct JsonTreeState` — two `HashMap<Vec<PathSegment>, bool>` maps: one for node expansion, one for long-string expansion. `PathSegment = Key(String) | Index(usize)`. `Vec<PathSegment>` derives `Hash + Eq` and is used directly as the key (no `Rc` indirection needed). Methods: `is_expanded(path, depth) -> bool` (default: `true` at depth 0, `false` deeper), `toggle(path)`.
- `const LONG_STRING_THRESHOLD: usize = 120` — strings longer than this character count, or containing a `\n`, are elided by default.
- `pub fn render_json_tree(root: &serde_json::Value, root_label: Option<&str>, state: &JsonTreeState, colors: &JsonTreeColors, on_toggle: impl Fn(Vec<PathSegment>), on_copy_json: impl Fn(Vec<PathSegment>, &serde_json::Value), appearance: &Appearance) -> Box<dyn Element>` — builds a `Flex::column` of rows (Design §B1). Each row is a `Flex::row` of: indent spacer (depth × `INDENT_PX = 12.`), chevron (`Icon::ChevronRight` when collapsed, `Icon::ChevronDown` when expanded — standard `warpui` icons, no import from `inline_action`), `FormattedTextElement` of colored key/value spans, and a right-click `Hoverable` (Design §E1) that opens a `Menu` with Copy and Copy JSON items.
- `app/src/ui_components/mod.rs` — declare `json_tree`.
- `app/src/ui_components/json_tree_tests.rs` (new, `#[cfg(test)]`) — pure logic tests covering only Phase 1 functionality:
- Annotation formatting: `{} 0/1/N keys`, `[] 0/1/N items` (Behavior 8, 12).
- Long-string detection at/over `LONG_STRING_THRESHOLD` and multi-line strings (Behavior 21-24).
- Integer rendering: whole-float → integer (Behavior 30); duplicate keys retained (Behavior 31).
- `JsonTreeState::toggle` independence: toggling one path leaves other paths unchanged (Behavior 9, 15).
- Empty container: no expansion possible (Behavior 12).
**No changes to agent or MCP code. Reviewable alone.**
---
### Phase 2 — MCP data pipeline: structured value and result normalization
**Goal:** Thread the structured `serde_json::Value` request through to `RequestedCommandView` and normalize `CallMCPToolResult` into a renderable form. Still no visible UI change (the old `Text` render path remains active).
**Files:**
- `app/src/ai/blocklist/inline_action/requested_command.rs`:
- New fields on `RequestedCommandView`: `mcp_request: Option<McpRequest>` where `McpRequest { name: String, args: serde_json::Value }`.
- New fields: `mcp_tree_state: JsonTreeState` — covers both request and response trees; paths namespaced by a synthetic root segment (`PathSegment::Key("__request__")` / `PathSegment::Key("__response__")`) so the two trees do not collide.
- New `RequestedCommandViewAction` variants: `ToggleJsonNode { path: JsonPath }`, `ToggleJsonString { path: JsonPath }`. Handled in `handle_action` by calling `mcp_tree_state.toggle(...)` + `ctx.notify()`.
- `app/src/ai/blocklist/block.rs` (2059-2079) — extend `handle_mcp_tool_stream_update` to also pass `display_input: serde_json::Value` and `name: String` through to the view, populating `mcp_request` (Design §D1). Keep building `command_text` for the collapsed header.
- New helper `fn mcp_result_to_renderable(result: &CallMCPToolResult) -> McpRenderable` where:
```
enum McpRenderable { Tree(serde_json::Value), Error(String), Cancelled }
```
Logic: `Success { result }` → prefer `result.structured_content`; else try `serde_json::from_str` on joined text content; else wrap in a JSON `String` value. `Error(e)` → `McpRenderable::Error(e)`. `Cancelled` → `McpRenderable::Cancelled`.
**Unit tests for `mcp_result_to_renderable` added to `json_tree_tests.rs`** (Behavior 28, 29).
**No user-visible UI change; the old `Text` render path remains active. New action enum variants and fields are code changes but produce no visible difference. Reviewable alone.**
---
### Phase 3 — Replace the render body + context menu
**Goal:** Wire up the `JsonTreeView` component in place of the old `Text` + `serde_json::to_string_pretty`, add the context menu, and ship.
**Files:**
- `app/src/ai/blocklist/inline_action/requested_command.rs` (`should_render_mcp_content` block, lines 1430-1483):
- Replace `content_text`/single-`Text` with two labeled sections (Request + Response divider, Behavior 4) each calling `render_json_tree(...)`.
- Request section: `render_json_tree(&self.mcp_request.args, "Request", &self.mcp_tree_state, &colors, ...)` (or `null` indicator when `mcp_request` is absent, Behavior 29).
- Response section: present only when `action_status.finished_result()` exists; dispatches to tree, error label (`Text` with `ui_error_color`), or cancelled label (Behavior 28).
- Tree body is wrapped in a `ConstrainedBox::with_max_height(MAX_EDITOR_HEIGHT)` and a vertical `NewScrollable` so it scrolls rather than growing unbounded (Behavior 17).
- The `SelectableArea` + `mcp_content_selection_handle` wraps the scrollable tree so text selection/copy still works (Behavior 25). See Risks re: `Hoverable` interaction.
- Right-click "Copy JSON" in `on_copy_json` callback: walk the `serde_json::Value` at the received path, serialize with `serde_json::to_string_pretty`, write to clipboard (Behavior 27).
- Remove the now-dead `.bak` intermediates (cleanup).
**Manual validation checklist** (attached to the PR, to be checked before merging):
- Configure a local MCP server (e.g. filesystem) and expand a tool call: root expanded, nested collapsed (Behavior 13), chevrons toggle independently (Behavior 9), indentation per level (Behavior 10).
- Large/nested response: Request/Response labels + divider visible (Behavior 4), typed colors for all value types (Behavior 18-20), light↔dark theme switch recolors without restart (Behavior 19).
- Long string (file contents): elision preview + chevron, expands/collapses in place without disturbing siblings (Behavior 21-22).
- Very tall expanded tree: tree scrolls, does not push subsequent blocks off-screen (Behavior 17).
- Response arrives while header is collapsed: expand header to confirm both request and response trees are shown (Behavior 16).
- Error and cancelled tool calls show labeled messages (Behavior 28).
- Right-click → Copy JSON on a collapsed container copies complete JSON (Behavior 27).
- Right-click → Copy JSON on the Request label copies the full request JSON (Behavior 26).
- Copy with no selection is a no-op; Copy menu item is greyed out (Behavior 25).
- Text selection and copy across key/value rows works (Behavior 25).
- Collapsed header, accept/reject, and a non-MCP action (shell command) are visually unchanged (Behavior 1, 33-35).
- Screenshots of expanded tree in dark and light themes attached to the PR.
## Testing and validation summary
| Invariant(s) | Test type | Where |
|---|---|---|
| Annotation labels (8, 12) | Unit | `json_tree_tests.rs` (Phase 1) |
| Toggle independence (9, 15) | Unit | `json_tree_tests.rs` (Phase 1) |
| Long string detection (21-24) | Unit | `json_tree_tests.rs` (Phase 1) |
| Integer/unusual values (30-31) | Unit | `json_tree_tests.rs` (Phase 1) |
| `mcp_result_to_renderable` (28) | Unit | `json_tree_tests.rs` (Phase 2) |
| Null/absent request (29) | Unit | `json_tree_tests.rs` (Phase 2) |
| Streaming expansion stability (32) | Unit | `json_tree_tests.rs` (Phase 2) |
| All visual/interaction behaviors | Manual | PR checklist (Phase 3) |
## Risks and mitigations
- **Performance on very large payloads.** Mitigated by rendering only expanded nodes (Design §B1); a "show first N / show more" cap can be added inside `JsonTreeView` as a follow-up without changing callers.
- **`SelectableArea` + per-row `Hoverable` interaction.** Each tree row uses `Hoverable` for right-click. Wrapping those rows in the existing `SelectableArea` may cause mouse event conflicts (the `Hoverable` right-click handler consuming events before `SelectableArea` sees them, or vice versa). The implementor should verify event propagation and may need to use `DispatchEventResult::Consumed` appropriately on the right-click path to prevent double-handling. This is the highest-risk interaction in Phase 3 and should be tested explicitly with the context menu open over a text selection.
- **Selection regression.** The current single-`Text` selection is well-understood; wrapping the tree in the same `SelectableArea`/`SelectionHandle` with `FormattedTextElement` keeps the selection model intact — called out explicitly in the Phase 3 PR for reviewer attention.
- **Streaming flicker.** Path-keyed state (Design §C1) prevents losing expansion when request args stream in; covered by unit tests.
- **Copy JSON clipboard access.** Clipboard writes already work in other right-click menus in the app; same mechanism applies here.
## Follow-ups
- Auto-collapse of very large roots (Behavior 14 open question).
- Reuse `JsonTreeView` for `ReadMCPResourceResult` and other JSON-bearing surfaces (natural next consumer after Phase 3 ships).
- Potential virtualization for pathologically large expanded trees (Design §B2), if needed.
- Confirm or update `LONG_STRING_THRESHOLD = 120` based on real-world MCP payloads seen in dogfooding.
+111
View File
@@ -0,0 +1,111 @@
# APP-3792: Remote Codebase Indexing
Linear: [APP-3792](https://linear.app/warpdotdev/issue/APP-3792)
## Summary
Remote codebase indexing lets Warp agents in SSH-backed remote sessions use semantic codebase search against repositories that live on the remote host. Users should be able to enable indexing, understand whether it is enabled and healthy, and receive the same `SearchCodebase` quality they get for local repositories without extra setup.
## Figma
Figma: none provided. The user-visible surface is the existing codebase-indexing speedbump and settings page, extended to distinguish remote repositories and expose remote indexing status.
## Problem
Today, codebase indexing is local-only: the filesystem walk, tree build, chunking, sync, persistence, watcher, and retrieval state all assume files are on the client machine. In remote sessions, agents can read files and apply file edits after the remote-file-tooling work, but semantic codebase search is not available for the remote repository the user is actually working in.
## Goals
- Make `SearchCodebase` available in remote sessions once the remote repository has a ready index.
- Show users whether remote codebase indexing is enabled, in progress, ready, stale, failed, disabled, or unavailable.
- Reuse the local codebase-indexing product model where possible so local and remote repositories feel like one feature.
- Scope user decisions, status, and backend retrieval authorization per Warp user, remote host, and repository while allowing the machine-local serialized index cache to be reused when it contains no user-specific data.
## Non-goals
- Sharing user-specific enablement, status, decline/drop decisions, or backend retrieval authorization across different Warp users on the same host.
- Making remote indexing work without any daemon-to-Warp-backend egress. If that network path is blocked, the product should fail visibly and recoverably.
- Changing local codebase-indexing behavior.
- Exposing implementation identifiers such as root hashes in the UI.
## Behavior
### Enablement and discovery
1. When a user is in a connected remote session and navigates to a git repository on the remote host, Warp determines whether codebase indexing has been enabled for that `(Warp user, host, repo)` tuple and whether a reusable machine-local serialized index cache exists for the repo.
2. If the user has already enabled indexing for that tuple and a ready cached index exists, Warp treats the repo as index-enabled immediately. The user does not see a first-run speedbump and the agent can use `SearchCodebase` as soon as the client has received the ready status.
3. If no cached index exists and remote automatic indexing is enabled, Warp starts indexing the repo without interrupting the user, matching local automatic indexing behavior.
4. If no cached index exists and remote automatic indexing is not enabled, Warp shows the existing codebase-indexing speedbump in the remote session. The speedbump clearly indicates that the repository is remote, for example with a `Remote` tag, host label, or equivalent visual treatment.
5. Accepting the speedbump starts indexing for that remote repo. Declining dismisses indexing for that repo only. A global decline disables automatic remote indexing but does not change local automatic indexing.
6. Declining or dropping one remote repo does not affect other repos on the same host, the same repo path on a different host, or local repos.
7. If the remote-server connection is unavailable, not authenticated, or not running a build that supports remote indexing, Warp does not offer remote indexing for that session. Other remote agent tools continue to work normally.
### Status visibility
8. The codebase-indexing settings page lists remote repositories alongside local repositories. Each remote entry includes enough context to identify it: at minimum repo path and host; if multiple remote identities can point at the same host, the UI must still make the entries distinguishable.
9. Remote entries use the same overall visual language as local indexing entries, with an additional remote indicator. The minimum acceptable indicator is a visible `Remote` tag; showing host information is preferred when space allows.
10. Each remote repo exposes one current status:
- **Not enabled** — indexing has not been accepted or started for this repo.
- **Queued** — Warp accepted the indexing request but the daemon has not started the repo build yet.
- **Indexing** — the daemon is building the tree, chunking files, embedding fragments, or syncing with the backend. Progress is shown when known.
- **Ready** — indexing has completed and `SearchCodebase` can retrieve results for this repo.
- **Stale** — a previous index is ready, but the remote filesystem has changed and a newer index is being synced. Search remains available against the last ready index.
- **Failed** — indexing or sync failed. The UI shows a user-readable reason and a retry affordance.
- **Disabled** — the user disabled indexing for this repo.
- **Unavailable** — the repo has known status, but the remote host or daemon is currently disconnected.
11. In-progress states should communicate what Warp is doing when that is known, such as discovering files, syncing changed files, embedding fragments, or waiting to retry after a recoverable backend error.
12. Status updates should appear without requiring the user to refresh settings or reopen the tab. A user watching settings while indexing runs should see transitions from queued/indexing to ready or failed.
13. Failed states include retry. Retrying starts the remote indexing flow again for the same repo and updates the status as new progress arrives.
14. Dropping a remote repo from settings removes that user's cached indexing state for the repo and stops future syncing for that user until they re-enable indexing. The machine-local serialized index cache may remain available for other users or future reuse.
### Agent retrieval
15. In a remote session, `SearchCodebase` is advertised to the agent only when remote codebase indexing is enabled for the active repo and Warp has a ready searchable index.
16. When `SearchCodebase` runs for a ready remote repo, results refer to files and ranges on the remote host. The agent receives the same high-level result shape it receives for local search, including file paths and relevant fragments.
17. If the index is queued or indexing, `SearchCodebase` returns a clear "indexing is still in progress" failure rather than partial or silently empty results.
18. If the index failed, `SearchCodebase` returns the failure reason so the agent can explain the issue or fall back to tools like `Grep`, `FileGlob`, and `ReadFiles`.
19. If the repo is stale because a sync is in progress after filesystem changes, `SearchCodebase` continues using the last ready index until the new one becomes ready.
20. Remote `SearchCodebase` should feel comparable to local search. The remote architecture should avoid adding an SSH round trip to the main retrieval query when the client already has enough status to query the backend directly.
### Persistence, startup, and incremental changes
21. Once a remote repo has been indexed, per-user status metadata and the machine-local serialized index cache persist across SSH disconnects, tab closes, daemon grace-period survival, and daemon restarts when the daemon's on-disk cache remains available.
22. On startup or reconnect, Warp bootstraps known remote repo statuses from the remote side. Repos that the user already enabled and that have a valid machine-local cached index should become usable without rebuilding from scratch.
23. If the remote filesystem changed while disconnected, Warp detects that after reconnect and syncs incrementally. The status becomes stale or indexing while the sync runs, then ready when the new index is available.
24. If the daemon's on-disk cache is missing or corrupted, Warp rebuilds the index from scratch the next time indexing is enabled for that repo. The UI should make that look like a normal indexing run, not a permanent failure.
25. Remote indexing respects server-backed codebase-indexing configuration such as sync cadence, batch sizes, and embedding configuration. Users do not need to configure those values locally on the remote host.
### Per-user and security invariants
26. Remote indexing enablement, status, decline/drop decisions, and backend retrieval authorization are scoped to the authenticated Warp user that owns the daemon. Two Warp users connecting to the same OS account and repo path may reuse the same machine-local serialized Merkle/snapshot cache when OS permissions allow, but one user's choices or backend access do not enable search for another user.
27. Indexing respects the filesystem permissions of the OS user running the remote daemon. If the daemon cannot read a file, that file is not indexed.
28. The remote daemon uses its authenticated Warp credential only to call Warp services needed for indexing and sync. The credential is never displayed to the user, sent to the agent, or included in agent conversation context.
29. Any remote client <> remote server proto message that can cause the daemon to make auth-required outbound Warp service requests must include the client's current auth token or request-scoped bearer credential. The daemon must reject those requests when the token is missing or invalid instead of treating the daemon's stored token as sufficient, so a process writing directly to the proxy socket cannot bypass authentication.
30. Remote indexing does not change `ReadFiles`, `ApplyFileDiffs`, shell execution, or other remote agent tools. Those tools remain available regardless of whether remote indexing is enabled.
### Backend reachability and firewall behavior
31. The v1 product assumes the remote daemon can reach `app.warp.dev`; that assumption has been checked with the initial target enterprise environments.
32. If the remote daemon cannot reach `app.warp.dev`, remote indexing fails with a user-readable error such as "Warp could not reach the backend from this remote host." The user can retry after fixing network access.
33. A backend-unreachable repo is not searchable. Warp should not pretend the feature is enabled if sync cannot complete.
### Local behavior must not regress
34. Existing local codebase-indexing speedbumps, settings, indexing status, and retrieval behavior are unchanged.
35. Existing local settings continue to apply to local repos. Remote auto-indexing may have its own setting, but changing it does not unexpectedly toggle local indexing.
36. If the remote-indexing feature flag is disabled, remote sessions behave as they do today: no remote `SearchCodebase`, no remote indexing speedbump, and no user-visible errors from the disabled feature.
+369
View File
@@ -0,0 +1,369 @@
# APP-3792: Remote Codebase Indexing — TECH.md
Linear: [APP-3792](https://linear.app/warpdotdev/issue/APP-3792)
Behavior is specified in `specs/APP-3792/PRODUCT.md`. This document updates the branch spec against current `origin/master` and the latest design notes: the daemon owns embedding/sync/cache work using its authenticated token, the machine-local serialized Merkle/snapshot cache can be shared when it contains no user-specific data, and the client owns UI state and direct retrieval calls using the daemon-supplied root hash.
## 1. Context
### Current local indexing architecture on master
- `app/src/lib.rs:1825` registers the local `CodebaseIndexManager` singleton.
- `crates/ai/src/index/full_source_code_embedding/manager.rs:167` defines `CodebaseIndexManager`; `manager.rs:186` constructs it from persisted metadata, limits, a `StoreClient`, and a `BulkFilesystemWatcher`.
- `manager.rs:452` handles watcher events, `manager.rs:564` starts indexing a directory, and `manager.rs:850` retrieves relevant files.
- `crates/ai/src/index/full_source_code_embedding/codebase_index.rs:147` defines `CodebaseIndex`, the per-repo owner of the Merkle tree, sync state, snapshot, and retrieval state.
- `crates/ai/src/index/full_source_code_embedding/store_client.rs:15` defines the authenticated backend seam. Its methods are `update_intermediate_nodes`, `generate_embeddings`, `populate_merkle_tree_cache`, `sync_merkle_tree`, `rerank_fragments`, `get_relevant_fragments`, and `codebase_context_config` (`store_client.rs:17-62`).
- `app/src/server/server_api/ai.rs` implements that trait for the client-side `ServerApi`; current master includes the codebase calls around `generate_code_embeddings`, `sync_merkle_tree`, `populate_merkle_tree_cache`, `get_relevant_fragments`, `rerank_fragments`, and `codebase_context_config`.
- `crates/ai/src/index/full_source_code_embedding/snapshot.rs` owns serialized snapshot persistence. The daemon path should reuse the format while changing the base directory.
- `app/src/ai/blocklist/action_model/execute/search_codebase.rs:28` defines `SearchCodebaseExecutor`; the current hydration path uses local file reads after `GetRelevantFilesController`.
- `app/src/ai/agent/api/impl.rs:189-194` explicitly disables `SearchCodebase` for `WarpifiedRemote { host_id: Some(_) }`.
- The existing local UI strings and flows live in `app/src/ai/blocklist/codebase_index_speedbump_banner.rs:20-30` and `app/src/settings_view/code_page.rs:84-98`.
### Current remote-server architecture on master
- `crates/remote_server/proto/remote_server.proto` defines the client/server envelopes. Current messages include `Initialize`, `NavigatedToDirectory`, `ReadFileContext`, and `Authenticate`.
- `app/src/remote_server/server_model.rs:173` stores the daemon-wide `auth_token`; `server_model.rs:514` writes it from `Initialize`, `server_model.rs:532` writes it from `Authenticate`, and `server_model.rs:540` exposes `auth_token()`.
- `app/src/remote_server/server_model.rs:379` dispatches incoming remote-server messages. `server_model.rs:696` handles `NavigatedToDirectory`; `server_model.rs:995` handles `ReadFileContext`.
- `crates/remote_server/src/manager.rs` owns connection setup, initialize, and token rotation from the client side.
### Dependency assumptions
- APP-3801's per-user authenticated daemon model is assumed to land as designed in `specs/APP-3801`: the client sends the current bearer token on `Initialize`, refreshes with `Authenticate`, the daemon stores the credential in memory only, and daemon sockets are partitioned by Warp identity. Remote codebase indexing is the first feature that materially depends on daemon-side upstream calls.
- APP-3790's remote file read path is assumed available for hydrating full file context after retrieval.
- The v1 design assumes daemon-to-`app.warp.dev` egress is available. That was checked with the initial target enterprise environments. If this assumption fails later, the fallback is a client-proxied `StoreClient`, not part of v1.
Daemon responsibilities:
- Check its persisted cache when building the startup snapshot, learning about a repo through navigation, or handling index/drop requests.
- Build the Merkle tree and fragment metadata from the remote filesystem.
- Read remote file bytes for chunking and fragment hydration.
- Run full and incremental sync with the backend through a daemon-side `StoreClient` authenticated by the APP-3801 token.
- Fetch and respect server-backed codebase-indexing config such as embedding config, batch sizes, and sync cadence.
- Persist the serialized Merkle/snapshot cache on the remote host in a machine-local repo cache, while keeping user decisions/status metadata identity-scoped.
- Watch the remote filesystem and push status/root-hash updates to the client.
Client responsibilities:
- Decide whether to offer remote indexing, based on feature flags, user settings, active repo, and remote-server capability.
- Render speedbump/settings/status UI for local and remote repos.
- Cache the latest remote index status per `(remote_identity_key, host_id, repo_path)`, including the current ready root hash and embedding config.
- Expose `SearchCodebase` to the agent only when the active remote repo has a ready index.
- Call the app server directly for retrieval using the current root hash, then call the daemon only to map content hashes back to remote fragment metadata and use the remote file-read path for bytes.
Backend responsibilities:
- Store and retrieve Merkle-tree/index data and embeddings keyed by hashes.
- Authorize every root-hash retrieval against the authenticated Warp user and repo association that created or owns the remote index.
- Answer `get_relevant_fragments(root_hash, query, repo_metadata, embedding_config)`.
- Rerank candidate fragments.
- Provide codebase context config to both local client indexing and daemon-side remote indexing.
### Why the client needs the root hash
The client needs the current ready root hash so retrieval can be client → app server instead of client → daemon → app server. The root hash is the backend lookup key for the synced index; it is enough for retrieval, while avoiding a full tree sync to the client. The client should not need fragment bytes or the complete Merkle tree to decide search candidates.
Root hashes are not treated as standalone bearer capabilities. Backend retrieval must verify that the authenticated caller is allowed to use the root for the associated remote repo before returning candidate fragments.
### Rejected alternative: daemon keeps only tree/bytes, client StoreClient syncs
Alternative shape: the daemon builds or maintains the remote Merkle tree and fragment bytes, while the client's existing `StoreClient` talks to the backend. The daemon sends the tree/root state back to the client, and the client drives backend sync.
Why rejected for v1:
- New repos would require syncing the entire tree and enough fragment data over SSH before backend sync can complete. That adds heavy startup traffic on the least reliable leg of the system.
- APP-3801 exists specifically to let daemon handlers call Warp services with the user's token; not using it here loses the main benefit.
- The only strong argument is resilience when daemon → `app.warp.dev` egress is blocked. The initial customer check says that egress is acceptable, and if it is unavailable, the product should fail visibly rather than silently route a much heavier protocol through SSH.
### Rejected alternative: daemon handles all retrieval
Alternative shape: the daemon receives `SearchCodebase`, calls `get_relevant_fragments`, hydrates fragments, reranks, and returns final locations.
Why rejected for v1:
- Adds an SSH hop to every retrieval query even though the client already has a valid app-server auth path.
- Makes retrieval unavailable when the daemon's backend connection is flaky even if the client can reach the backend.
- Couples agent retrieval latency to the remote link more than necessary.
## 3. Proposed changes
### 3.1 Reuse daemon-compatible indexing code
For v1, wire the daemon path to the existing `crates/ai/src/index/full_source_code_embedding/` implementation instead of creating a new crate up front. The remote-server daemon lives in `app`, and `app` already depends on `ai`, so the simplest implementation can reuse `CodebaseIndexManager`, `CodebaseIndex`, `sync_client`, `store_client`, `snapshot`, `merkle_tree`, `chunker`, `fragment_metadata`, `changed_files`, and their existing tests directly.
The daemon wiring still needs daemon-specific adapters:
- daemon-local SQLite-backed metadata instead of ad-hoc JSON/file metadata,
- daemon-side snapshot base directory,
- remote-compatible filesystem/repo metadata dependencies,
- daemon-compatible `StoreClient` auth plumbing.
Keep daemon entrypoints narrow so the remote-server path depends only on indexing, syntax/chunking, remote filesystem/repo metadata, and backend GraphQL types. Avoid introducing daemon dependencies on unrelated `crates/ai` agent, MCP, terminal, or UI modules. Extracting the indexing implementation into a smaller crate such as `crates/codebase_index` remains a follow-up if v1 shows unacceptable daemon binary size or dependency coupling.
### 3.2 Add daemon-compatible `StoreClient`
`app/src/server/server_api/ai.rs` already implements `StoreClient` for the client-side `ServerApi`; reuse that codebase GraphQL operation and conversion logic. The preferred shape is to make the relevant `ServerApi` request path configurable for whether it is allowed to refresh auth tokens, instead of adding a separate wrapper solely to avoid refresh behavior.
Introduce a small token-refresh policy seam, for example a trait or provider with `allowed_to_refresh_token() -> bool`:
- The normal client `ServerApi` path returns `true`, preserving today's `get_or_refresh_access_token()` behavior and existing `ServerApiEvent::NeedsReauth`/`AccessTokenRefreshed` flow.
- The daemon remote-indexing path returns `false`, uses the request-scoped token from the proto message for request-triggered calls, and uses the in-memory APP-3801 daemon token cache for daemon-initiated background sync. If that token is missing, expired, or rejected, the call returns an unauthenticated/error status instead of trying to refresh through client `AuthState`.
Do not instantiate the full client `ServerApiProvider` inside the daemon unless the constructor can accept the daemon token source and refresh policy without registering client-only UI/auth lifecycle dependencies. `ServerApiProvider` setup currently assumes client app singletons and event handlers such as `AuthManager`, network logging, and auth-token rotation subscriptions. `run_daemon_app` currently registers only headless remote-server, repo metadata, filesystem, and telemetry no-op models, so pulling in the full provider unchanged would add client UI/auth lifecycle coupling to the daemon.
Once the token source and `allowed_to_refresh_token` policy are injectable, the daemon can reuse the same `ServerApi` implementation directly for codebase-indexing backend calls, with refresh disabled. Until then, share the GraphQL operation construction, result conversion, error mapping, and `http_client::Client` usage; do not fork the GraphQL operations.
Required behavior:
- Reads the request-scoped token supplied by the remote client/server proto message for operations triggered by that message. The daemon may keep `ServerModel::auth_token()` or an injected token provider as the initialized token cache for daemon-initiated background sync, but request-triggered auth-required outbound Warp service requests must not be authorized solely by the cached daemon token.
- Disables token refresh for daemon calls by using `allowed_to_refresh_token() == false`. The daemon path must surface missing/expired/revoked credentials to the client instead of invoking the client's token refresh path.
- Sends the same backend operations the local client sends today: config fetch, Merkle tree sync, embedding generation, intermediate-node update, cache population, relevant-fragment retrieval only if a future daemon-retrieval path needs it, and reranking only if a future daemon-retrieval path needs it.
- Classifies errors into at least unauthenticated, backend unreachable, backend rejected, and internal/unknown so status UI can distinguish actionable failures.
- Redacts tokens from logs and never persists them.
For v1 sync, daemon-side retrieval methods may still be implemented because the trait requires them, but the normal remote retrieval path should use the client's `ServerApi` for `get_relevant_fragments` and `rerank_fragments`.
### 3.3 Add daemon-side index cache and startup bootstrap
The daemon keeps two persistence layers under the remote-server cache root:
- Shared machine-local snapshot files, keyed by repo identity/path and content, containing serialized Merkle trees, fragment metadata, snapshots, and other data that is derived only from files readable by the OS user running the daemon. These snapshot files intentionally contain no Warp-user-specific choices, credentials, or authorization state and can be reused by multiple Warp identities that connect to the same OS account and repo.
- Daemon-local SQLite metadata, using the existing `persistence`/Diesel infrastructure from the app/oz binary rather than ad-hoc JSON. Add remote-indexing migrations for shared cache records and identity-scoped user state. The remote daemon should initialize the SQLite persistence subsystem in `run_daemon_app` or an equivalent daemon bootstrap path before constructing the indexing manager.
Example layout:
- `~/.warp/remote-server/codebase-indexes/shared/snapshots/{repo_key}/...`
- SQLite database under the daemon's state directory, with tables such as `remote_codebase_index_cache` and `remote_codebase_index_user_state`.
Sharing the serialized Merkle/snapshot cache is acceptable because it is just a representation of the local codebase for the remote OS account. Sharing user metadata is not acceptable: enablement/decline/drop choices, status, and backend root authorization remain scoped per Warp identity. Backend storage may also deduplicate content-addressed Merkle nodes, fragments, or embeddings internally, but retrieval authorization must bind usable roots to the authenticated Warp user and repo.
Shared cache metadata in SQLite should record at least repo path, repo identity key, snapshot/schema version, snapshot file key/path, root hash, embedding config, last indexed time, and enough timestamps to rebuild the local `WorkspaceMetadata` inputs that currently populate the local build queue. Identity-scoped SQLite metadata should record at least `identity_key`, repo path, enabled/disabled/declined state, current status, last user-visible error, last status update, backend association state, and the last ready root hash associated with that Warp identity.
Daemon SQLite wiring:
- Do not call the full `persistence::initialize(ctx)` path from `run_daemon_app` unchanged. That initializer is app/CLI-shaped: it reads full app state, expects `AuthStateProvider`, creates the general `PersistenceWriter`, and restores UI/session/cloud-object data the daemon does not need.
- Instead, factor the reusable SQLite pieces behind a daemon-scoped initializer, for example `persistence::initialize_remote_codebase_indexing(ctx)` or a lower-level `sqlite::initialize_with_scope(scope, path)`. It should reuse the existing Diesel migrations, schema generation, `establish_connection` pragmas, error reporting pattern, and writer-thread/event pattern, but only read/write remote-codebase-indexing tables.
- Store the daemon codebase-indexing database under the remote-server cache root, separate from the normal app/Oz `warp.sqlite`, for example `~/.warp/remote-server/codebase-indexes/index.sqlite`. Keeping it remote-server-scoped avoids mixing long-lived daemon cache rows with a user's normal app/CLI session-restore database while still reusing the same SQLite infrastructure.
- Create the parent directory, shared snapshot files, and SQLite file with owner-only access, matching the remote-server socket/cache privacy model. The shared snapshot files and shared metadata tables may be machine-local for the remote OS account; identity decisions still remain keyed by `identity_key`.
- Add migrations under `crates/persistence/migrations/` for remote indexing tables and regenerate `persistence::schema`/`persistence::model` in the normal way. Tables should live in the shared schema so app/CLI and daemon code can use the same typed Diesel models, but daemon reads should be limited to the remote-indexing tables.
- Add daemon-specific `ModelEvent` variants or a separate daemon persistence event enum for `UpsertRemoteCodebaseIndexCache`, `UpsertRemoteCodebaseIndexUserState`, `DeleteRemoteCodebaseIndexUserState`, and `DeleteRemoteCodebaseIndexCache`. Prefer a separate enum if adding these events to the app-wide `ModelEvent` would make the general writer handle daemon-only concepts.
- Register a daemon persistence writer singleton in `run_daemon_app` before constructing the remote indexing manager. Pass its sender/handle into the daemon indexing manager so manager events can persist status/root changes without blocking the remote-server message handler.
- On startup, the daemon initializer should synchronously read only the remote-indexing rows needed to build initial shared cache metadata and identity-scoped user state. Those values feed the daemon indexing manager before it accepts `IndexCodebase` or status requests.
- On shutdown, rely on the `PersistenceWriter`-style drop/terminate behavior so the SQLite writer thread drains or terminates cleanly when the daemon exits after its grace period.
Suggested implementation sequence:
1. Extract SQLite open/migrate/start-writer helpers so they can accept an explicit database path and a narrowed read function.
2. Add remote-indexing Diesel models and writer events.
3. Add `remote_server::run_daemon_app` bootstrap that initializes the daemon-scoped SQLite database and registers the writer singleton.
4. Construct the daemon indexing manager from the synchronously read remote-indexing rows plus the writer sender.
5. Wire indexing manager status/cache events to the writer and verify reconnect/status responses read from the in-memory state populated from SQLite.
Startup/reconnect behavior:
1. Load shared cache metadata/snapshots and identity-scoped user metadata before accepting indexing requests.
2. Build an identity-scoped status snapshot containing every repo the daemon knows about for that identity.
3. For repos enabled by the connected identity with a valid shared ready snapshot, include `Ready` status with that identity's authorized root hash.
4. For repos with a valid shared snapshot but no enablement record for the connected identity, include `Not enabled` so the user still controls whether that repo is searchable for them.
5. For known enabled repos without a valid shared snapshot, include `Failed` or queue rebuild depending on whether recovery can start immediately.
6. Push the full status snapshot to connected clients after daemon initialization and after reconnect, before relying on incremental status updates.
7. After the snapshot is applied, keep the client and daemon synchronized with `CodebaseIndexStatusUpdated` deltas for every status/root change and every newly known repo. When the daemon learns about a git repo through navigation or repo detection and that repo is not already in the synchronized set, it should immediately push an explicit status such as `Not enabled`, `Ready`, `Failed`, or `Unavailable`.
Snapshot parsing should follow local behavior: if a snapshot is incompatible or corrupt, delete it and rebuild from scratch rather than leaving the repo permanently failed.
Cache invalidation behavior:
1. Snapshot schema/version mismatch, corrupt snapshot data, or missing snapshot files invalidate the shared local snapshot and trigger a rebuild the next time any identity indexes the repo.
2. If the repo path no longer exists or is no longer a git repo, return a failed or not-enabled status with a user-readable reason rather than reusing stale root hashes indefinitely.
3. Filesystem watcher changes mark the repo stale for all identities that have enabled it when a last-ready root hash exists, keep search available against each identity's last authorized root, and run incremental sync toward a new ready root.
4. Backend config or embedding-config changes mark affected shared snapshots stale and re-run the necessary embedding/sync work with the new config.
5. Auth identity changes clear the client-side `RemoteCodebaseIndexModel` cache and reconnect through the identity-scoped daemon path, but they do not delete the shared machine-local index cache.
6. If the backend rejects, cannot find, or no longer authorizes a previously ready root hash for a specific identity, mark that identity's repo status failed with an actionable reason and require `IndexCodebase` to rebuild, resync, or re-associate the shared cache for that identity.
### 3.4 Add remote-server protocol messages
Extend `crates/remote_server/proto/remote_server.proto` with request/response and push messages for remote indexing. Names can be adjusted during implementation, but the protocol needs these concepts:
- `IndexCodebase { repo_path }`
- `DropCodebaseIndex { repo_path }`
- `GetFragmentMetadataFromHash { repo_path, content_hashes }`
- `CodebaseIndexStatusesSnapshot { statuses }`
- `CodebaseIndexStatusUpdated { repo_path, status }`
`IndexCodebase` is the only client-triggered indexing command in v1. The client owns all product decisions about feature flags, speedbump acceptance, automatic indexing settings, and retry affordances before it sends this message. The daemon treats the message as an explicit request to index, retry, or rebuild the repo path.
`CodebaseIndexStatusesSnapshot` is the status bootstrap and full-resync path. After daemon initialization and after a client reconnects, the daemon must push the complete set of identity-scoped repo statuses it loaded from SQLite. The client uses this snapshot to populate settings and initial tool-advertisement state without asking for every repo one-by-one.
There is intentionally no per-repo status fetch or client-initiated bulk status fetch in v1. The daemon and client should always converge through the pushed `CodebaseIndexStatusesSnapshot` after initialize/reconnect plus `CodebaseIndexStatusUpdated` deltas. When a user navigates to a repo that is not already in the synchronized status set, the daemon should push `CodebaseIndexStatusUpdated` for that repo as soon as it recognizes the repo, usually `Not enabled` for a first-seen repo. The client should not ask the daemon for just that repo.
`GetFragmentMetadataFromHash` is used after client-side backend retrieval. The backend returns content hashes for candidate fragments, but only the daemon has the remote snapshot metadata needed to map those hashes back to remote file paths, ranges, symbols, and other fragment metadata. The daemon must verify every requested content hash belongs to the enabled repo's current or last-ready snapshot before returning metadata. Content bytes should be read through the APP-3790 remote `ReadFileContext` path rather than this RPC.
All new remote-indexing RPCs are scoped to the identity-partitioned remote-server daemon socket. Authorization requirements by message:
- `DropCodebaseIndex` mutates only identity-scoped user metadata for the connected identity and must carry a request-scoped bearer credential, either in the proto payload or authenticated request envelope, before it calls the backend to revoke or delete that user's repo/root association.
- `GetFragmentMetadataFromHash` requires that the connected identity has enabled the repo and that every requested content hash belongs to that repo's current or last-ready snapshot. It must not read cross-repo metadata from the shared cache.
- `IndexCodebase` must carry a request-scoped bearer credential, either in the proto payload or authenticated request envelope, because it can trigger config fetches, embedding generation, and index sync.
Any request message that can lead to auth-required outbound Warp service calls must carry the current client auth token or an equivalent request-scoped bearer credential. Handlers must reject missing or invalid request-scoped tokens instead of falling back to the daemon's stored `auth_token`; the stored token is only a cache/initialization aid and must not make the proxy socket an ambient-authority boundary. If future versions let `GetFragmentMetadataFromHash` or daemon-side retrieval call Warp services, those messages must also carry the token before those outbound calls are added.
`IndexStatus` should include:
- `state`: not enabled, queued, indexing, ready, stale, failed, disabled, unavailable.
- `progress`: optional current phase and counts.
- `failure_reason`: optional user-readable string plus machine-readable category.
- `root_hash`: present for ready and stale states when a last-ready index exists.
- `embedding_config`: present whenever `root_hash` is present.
- `last_updated_at`: useful for settings and debugging.
The client should receive root hashes only through status responses/pushes. It should never receive the whole Merkle tree.
### 3.5 Add daemon indexing manager wiring
In `app/src/remote_server/mod.rs`, register the indexing manager as a daemon singleton with:
- SQLite-backed shared cache metadata,
- SQLite-backed identity-scoped user metadata,
- daemon-side shared snapshot base directory,
- daemon-side `StoreClient`,
- `BulkFilesystemWatcher`,
- remote-compatible repo metadata / detected-repo dependencies already used by the daemon.
In `app/src/remote_server/server_model.rs`, add handler arms for the new RPCs:
- `IndexCodebase`: check cache first; if miss, failed, stale, or invalid, enqueue/build index and immediately push queued/indexing status. Retrying a failed repo is the same message after the client chooses retry.
- `DropCodebaseIndex`: remove or update the connected identity's user metadata for that repo, stop watcher registration if no enabled identities still need it, push disabled/not-enabled status, and call the backend to revoke or delete that user/repo/root association for synced remote index data. The shared machine-local Merkle/snapshot cache may remain for other identities or future reuse, and content-addressed backend blobs may remain subject to backend retention or deduplication policy, but dropped roots must become inaccessible for retrieval by that user/repo.
- `GetFragmentMetadataFromHash`: verify each content hash belongs to the enabled repo's current or last-ready snapshot, map hashes to fragment metadata, and return remote file paths/ranges plus metadata needed by retrieval. Do not read file bytes or make backend calls in this handler.
Update the existing `NavigatedToDirectory` handling so that when the daemon recognizes a git repo that is not in the current identity-scoped status set, it computes the repo's cached status and pushes `CodebaseIndexStatusUpdated` immediately. First-seen repos should become explicit `Not enabled` entries rather than remaining absent from client state.
Subscribe once to indexing manager events and fan out `CodebaseIndexStatusUpdated` deltas to connected clients after the initial snapshot. On disconnect/reconnect, push `CodebaseIndexStatusesSnapshot` again; push messages are the primary steady-state path, and reconnect is the full-resync boundary.
### 3.6 Fetch and respect server-backed config on the daemon
The daemon should call `codebase_context_config` through its `StoreClient` before sync work and at the cadence expected by the local implementation. Server-backed values such as embedding config, embedding cadence, generation batch size, and sync batching should be owned by the backend and respected on the remote host.
The client should evaluate user/client-controlled gates before sending `IndexCodebase`, such as whether the remote-indexing feature flag is enabled, whether the user accepted indexing, and whether persistence is allowed. Do not send client-owned feature or preference values for the daemon to reinterpret, and do not use client-sent values for server-owned tuning knobs when the daemon can fetch them directly.
### 3.7 Client-side state and UI model
Add a client singleton such as `RemoteCodebaseIndexModel` that subscribes to `RemoteServerManager` events and tracks:
- `(remote_identity_key, host_id, repo_path) -> RemoteIndexState`
- remote server capability per `host_id`, including unsupported old-daemon builds and disconnected/unavailable hosts
- the last known active repo per remote session/host so speedbump and agent-tool code can ask about the current remote repo without re-deriving it
`RemoteIndexState` should carry:
- `lifecycle`: not enabled, queued, indexing, ready, stale, failed, disabled, unavailable, or unsupported.
- `progress`: optional phase/counts for queued/indexing/stale.
- `failure_reason`: optional user-readable string plus machine-readable category for failed/unavailable/unsupported states.
- `root_hash`: present only for ready/stale states with a last usable index.
- `embedding_config`: present whenever `root_hash` is present.
- `last_updated_at`: daemon-supplied or client-observed timestamp for settings/debugging.
- `source`: whether the value came from daemon startup bootstrap, direct status response, push update, or local disconnect/capability handling.
Public APIs should cover the upstream callers explicitly:
- `state_for_repo(remote_identity_key, host_id, repo_path) -> Option<RemoteIndexState>` for settings rows and low-level callers.
- `state_for_active_remote_repo(session_id) -> Option<RemoteIndexState>` for speedbump and agent-tool advertisement.
- `entries_for_settings() -> Vec<RemoteIndexSettingsEntry>` returning stable display rows with host label, repo path, lifecycle, progress/failure, and supported actions.
- `can_search(session_id, repo_path) -> RemoteSearchAvailability`, returning ready/stale plus root hash and embedding config, or a typed unavailable reason for agent/tool plumbing.
- `request_index(session_id, repo_path, auth_token)` to send `IndexCodebase` after client-side feature/preference/speedbump decisions.
- `drop_index(session_id, repo_path, auth_token)` to send `DropCodebaseIndex` and optimistically move the entry to disabled/not-enabled only after daemon acknowledgement.
- `apply_status_snapshot(host_id, statuses)` to replace/reconcile the initial daemon-provided status set for settings and tool-advertisement bootstrap.
Event handling:
- On `RemoteServerManagerEvent::SessionConnected`/`SessionReconnected`, record host capability, enter an awaiting-snapshot state, and clear any local unavailable marker only after the daemon's `CodebaseIndexStatusesSnapshot` arrives. If the snapshot does not arrive within the expected protocol window, treat the session as out of sync and reconnect or mark the host unavailable/unsupported rather than issuing a separate status request.
- On `CodebaseIndexStatusesSnapshot`, replace or reconcile all identity-scoped entries for that host and notify settings/speedbump/tool subscribers.
- On `NavigatedToDirectory`, update the session's active repo and wait for/apply the daemon-pushed `CodebaseIndexStatusUpdated` if this is a newly known repo. The speedbump or auto-indexing flow should act on the explicit status, such as `Not enabled`, rather than inferring a missing state locally. Do not issue a per-repo status request on navigation.
- On `CodebaseIndexStatusUpdated`, upsert the keyed `RemoteIndexState`, notify settings/speedbump/tool subscribers, and preserve a ready root hash when the daemon reports stale with a last-ready root.
- On `SessionDisconnected` or `HostDisconnected`, mark affected entries unavailable without deleting their last ready/stale root hash. Search should not be advertised while unavailable, but settings should still show the last known status and host disconnect reason.
- On identity changes/logout, clear the client cache and rely on the identity-scoped daemon socket/status bootstrap after reconnect; do not reuse root hashes across identities.
- On unsupported old daemon/protocol errors, store `unsupported` per host so UI does not keep offering the speedbump for that session.
Model invariants:
- Never persist auth tokens, request-scoped credentials, or fragment bytes in the client model.
- Do not expose `SearchCodebase` unless `can_search` returns ready/stale with a root hash, embedding config, connected host, and matching active repo.
- Keep local and remote indexing state separate. Local `CodebaseIndexManager` remains the source of truth for local repos; `RemoteCodebaseIndexModel` only owns remote host/repo state.
- Avoid wildcard host-only keys: every cached remote repo entry must include `remote_identity_key`, `host_id`, and repo path so same-host or same-path collisions do not leak status across users or identities.
Use this model from:
- `app/src/settings_view/code_page.rs` to render remote entries alongside local entries with a remote tag/host label and the states from PRODUCT §8-14.
- `app/src/ai/blocklist/codebase_index_speedbump_banner.rs` to show the remote-aware speedbump and dispatch `IndexCodebase`.
- agent/tool plumbing to decide whether `SearchCodebase` is advertised for remote sessions.
Settings should distinguish local auto-indexing from remote auto-indexing. If implementation chooses to reuse one preference, the product spec must be updated before shipping; the current product expectation is independent control.
### 3.8 Remote retrieval path
When `SearchCodebaseExecutor` runs in `SessionType::WarpifiedRemote { host_id: Some(_) }`:
1. Resolve the active remote repo path.
2. Read `RemoteCodebaseIndexModel` for `(remote_identity_key, host_id, repo_path)`.
3. If the state is not ready/stale with a root hash, return a typed `SearchCodebaseResult::Failed` reason for indexing-in-progress, failed, disabled, unavailable, or not indexed.
4. Use the client's `ServerApi` to call `get_relevant_fragments(root_hash, query, repo_metadata, embedding_config)`.
5. Call `GetFragmentMetadataFromHash` on the daemon with the returned content hashes.
6. Use the APP-3790 remote `ReadFileContext` path to read the fragment ranges from the returned metadata.
7. Use the client's `ServerApi` to call `rerank_fragments(query, hydrated_fragments)`.
8. Convert reranked fragments into `CodeContextLocation`s and hydrate any remaining full file context through the APP-3790 remote `ReadFileContext` path.
9. Return the normal `SearchCodebaseResult::Success { files }`.
The local path remains unchanged.
### 3.9 Feature flag and rollout
Add `FeatureFlag::RemoteCodebaseIndexing` and gate only client-visible behavior:
- speedbump offer,
- settings controls,
- remote tool advertisement,
- remote dispatch branch in `SearchCodebaseExecutor`.
The daemon should not independently check the feature flag. If it receives a valid `IndexCodebase` request from an authenticated client build, it should perform the requested work. This avoids requiring daemon/client flag state to be perfectly synchronized.
## 4. End-to-end flows
### New repo
1. Client observes remote navigation into repo, and the daemon receives the existing navigation signal.
2. Daemon recognizes the git repo, checks identity-scoped metadata/shared cache, and pushes `CodebaseIndexStatusUpdated { repo_path, status: Not enabled }` if this is a first-seen repo for the connected identity.
3. Client applies the explicit `Not enabled` state in `RemoteCodebaseIndexModel`.
4. Client offers speedbump or auto-enables based on settings.
5. Client sends `IndexCodebase`.
6. Daemon builds the tree on the remote host.
7. Daemon fetches backend config and syncs missing tree nodes/fragments/embeddings using daemon auth.
8. Daemon saves metadata/snapshot and pushes `Ready { root_hash, embedding_config }`.
9. Client caches status and enables `SearchCodebase`.
### Previously seen repo
1. Daemon loads metadata and snapshots during startup.
2. Daemon pushes `CodebaseIndexStatusesSnapshot` containing the ready cached repo.
3. Client caches `Ready { root_hash, embedding_config }`.
4. Client enables retrieval without full rebuild when the user navigates to that repo.
### Startup with known repos
1. Daemon loads metadata and snapshots during startup.
2. Daemon builds the full identity-scoped status set for known remote repos.
3. Daemon pushes `CodebaseIndexStatusesSnapshot { statuses }` to connected clients.
4. Client populates remote settings entries from the snapshot.
5. Watcher registration resumes for enabled repos.
### Incremental changes
1. Daemon filesystem watcher fires for a watched repo.
2. Daemon marks the repo stale if a previous root hash exists.
3. Daemon refreshes backend config if due, computes the incremental tree diff, asks backend what is missing, and syncs only missing nodes/fragments/embeddings.
4. Daemon authorizes background sync with the in-memory APP-3801 token cache for the same connected identity that enabled the repo. If the token is missing, expired, revoked, or the identity has no authenticated client connection allowed to refresh it, the daemon pauses sync, keeps the last ready root usable as stale, and pushes a failed or unavailable status that asks the client to reauthenticate/retry.
5. Daemon saves the new snapshot/root hash and pushes ready status to the client.
6. Client replaces its cached root hash; new retrievals use the new index.
### Retrieval
1. Client already knows the current ready root hash.
2. Client calls app server for candidate fragment hashes.
3. Client asks daemon to map those hashes into fragment metadata and remote file ranges.
4. Client reads the fragment ranges from the remote host and calls app server for reranking.
5. Client hydrates full file context from the remote host and returns the standard result shape to the agent.
## 5. Incremental PR plan
Break the implementation into small PRs that keep behavior behind `FeatureFlag::RemoteCodebaseIndexing` until the end-to-end path is ready.
### PR 1: Basic daemon/client handshake
Add the remote-server protocol capability and no-op status synchronization path first. The daemon should advertise remote-indexing support, push an empty or SQLite-backed `CodebaseIndexStatusesSnapshot` after initialization/reconnect, and push `CodebaseIndexStatusUpdated { status: Not enabled }` when navigation reveals a first-seen git repo. The client should add `RemoteCodebaseIndexModel` enough to apply snapshots and pushed repo-status updates, track unsupported/unavailable hosts, and prove settings/tool callers can observe the synchronized status set without exposing `SearchCodebase` yet.
### PR 2: ServerApi token-refresh policy
Make `ServerApi` configurable with an injectable token source and `allowed_to_refresh_token` policy. Keep the existing client path on refresh-enabled behavior, add refresh-disabled daemon tests, and verify missing/expired/revoked daemon credentials return actionable auth errors instead of entering the client refresh flow.
### PR 3: Daemon SQLite persistence bootstrap
Add remote-indexing SQLite migrations/models/writer events and daemon-scoped persistence initialization under the remote-server cache root. Load shared cache metadata and identity-scoped user state before the daemon sends its snapshot, and persist status/root changes from daemon events.
### PR 4: IndexCodebase daemon indexing path
Wire `IndexCodebase` to the reused codebase-indexing manager, daemon snapshot directory, filesystem watcher, server-backed config fetch, embedding/sync calls, and pushed `CodebaseIndexStatusUpdated` transitions. Keep retrieval disabled until a ready root and embedding config are reliably synchronized to the client.
### PR 5: Remote retrieval path
Add `GetFragmentMetadataFromHash`, connect `SearchCodebaseExecutor` for remote sessions, call client-side `get_relevant_fragments`, map hashes through the daemon, read bytes via APP-3790 `ReadFileContext`, rerank with client `ServerApi`, and return the standard `SearchCodebaseResult` shape.
### PR 6: Settings, speedbump, and rollout polish
Expose remote entries in settings, add the remote-aware speedbump/auto-indexing controls, add manual validation for open-egress and blocked-egress hosts, and keep the feature flag off until local non-regression and remote end-to-end tests pass.
## 6. Testing and validation
- Keep existing codebase-index unit tests running against the reused indexing implementation. This covers PRODUCT §21-24 and local non-regression in §34-36.
- Add daemon-side `StoreClient` tests for token-present, missing-token, backend-unreachable, backend-rejected, and config-fetch behavior. This covers PRODUCT §25 and §31-33.
- Add remote-server protocol/handler tests for index, status, drop, fragment-metadata lookup, retry-via-index, pushed status transitions, and rejection of auth-required requests that omit the request-scoped token. This covers PRODUCT §8-14, §21-24, and §29.
- Add client `RemoteCodebaseIndexModel` tests for queued → indexing → ready, ready → stale → ready, failed → retry → ready, disabled, and unavailable transitions. This covers PRODUCT §10-14 and §17-19.
- Add `SearchCodebaseExecutor` tests for remote ready, indexing, failed, unavailable, not indexed, and local fallback paths. Verify the remote ready path calls client `get_relevant_fragments`, daemon `GetFragmentMetadataFromHash`, remote `ReadFileContext`, client rerank, then final remote `ReadFileContext` in order. This covers PRODUCT §15-20 and §29.
- Add settings/speedbump UI tests or snapshots for local entries, remote entries, remote tag/host labeling, retry, drop, and independent local/remote automatic indexing settings. This covers PRODUCT §4-14 and §34.
- Add per-user isolation tests proving shared machine-local snapshots do not share enablement/status/backend authorization, and that daemon auth token usage remains identity-scoped. This covers PRODUCT §26-28.
- Add manual verification on an open-egress remote host: enable indexing for a new repo, observe progress in settings, run `SearchCodebase`, edit a file, observe stale/ready transition, and verify subsequent retrieval uses the updated repo.
- Add manual verification on a blocked-egress remote host: enable indexing, verify failed status and retry behavior, and verify other remote tools remain usable.
## 7. Risks and mitigations
- **Daemon egress blocked.** Mitigation: product shows failed/unreachable with retry. Keep client-proxied `StoreClient` as a follow-up only if real deployments require it.
- **Binary size increase.** Reusing indexing code brings tree-sitter/chunking/GraphQL dependencies into the daemon. Mitigation: measure daemon binary size before landing, keep daemon entrypoints narrow, and extract a smaller indexing crate later only if needed.
- **Config drift between local and remote indexing.** Mitigation: daemon fetches server-backed config via `codebase_context_config`; client owns user/feature gate decisions before sending `IndexCodebase`.
- **Status push loss during disconnect.** Mitigation: identity-scoped status is cached on the daemon; reconnect pushes a fresh `CodebaseIndexStatusesSnapshot`, and clients treat reconnect as the full-resync boundary before trusting incremental deltas.
- **Snapshot corruption/version skew.** Mitigation: match local snapshot behavior by deleting bad snapshots and rebuilding.
- **Credential exposure.** Mitigation: use APP-3801 token provider, never persist tokens, redact protocol logs, and ensure agent context never includes auth material.
- **Proxy-socket auth bypass.** Mitigation: require request-scoped auth tokens on remote client/server proto messages before handlers make auth-required outbound Warp service requests; reject missing or invalid tokens instead of relying on daemon-stored credentials as ambient authority.
- **Root hash staleness.** Mitigation: stale state keeps last ready root hash usable until a new ready hash arrives; failed sync does not overwrite the last ready hash.
## 8. Follow-ups
- Client-proxied `StoreClient` fallback for hosts that cannot reach `app.warp.dev`.
- Garbage collection for shared machine-local snapshots when no identity metadata references them.
- Daemon-direct telemetry for indexing metrics instead of client-forwarded status-only events.
- Cross-repo remote context across multiple repos on one host.
- Retrieval caching for repeated queries within one remote session.
@@ -0,0 +1,121 @@
# APP-3792 codebase indexing persistence PR tech spec
## Problem statement
This PR makes remote codebase indexing survive daemon restarts and reconnects by restoring daemon-owned codebase index metadata and snapshots at startup, keeping the client synchronized with daemon status snapshots and updates, and exposing remote indexed codebases to the agent context in the same broad shape as local indexed codebases.
The changes are intentionally scoped to the persistence/bootstrap and protocol plumbing needed for APP-3792. They do not redesign the local indexing product flow, move retrieval fully into the daemon, or remove the current remote `ResyncCodebase` protocol path.
## Current state
Local codebase indexing is owned in-process by `CodebaseIndexManager` in `crates/ai/src/index/full_source_code_embedding/manager.rs`. The normal app path constructs it with app-scoped persisted metadata, app-default snapshot storage, a `BulkFilesystemWatcher`, and a client-side `StoreClient`. Local settings can trigger a manual resync through `CodeSettingsPageAction::ManualResync`, which calls `CodebaseIndexManager::try_manual_resync_codebase` directly because the settings UI and index manager live in the same process.
Remote codebase indexing splits those responsibilities across the Warp client and the remote-server daemon. The daemon owns remote filesystem walking, snapshot files, indexing work, and backend sync. The client owns session context, settings/speedbump decisions, agent tool advertisement, and retrieval orchestration. The client and daemon communicate through `crates/remote_server/proto/remote_server.proto`, so operations that are direct method calls locally become client-to-daemon messages remotely.
Before this PR, remote daemon indexing state was too transient: a reconnect or daemon restart did not have a narrow restore path for known remote codebase index metadata and daemon-scoped snapshots. The client also needed a bootstrap status snapshot from the daemon so the active remote repo and agent context could reflect already-indexed remote codebases without waiting for a new indexing run.
## Goals
Restore remote codebase index metadata for the remote-server daemon while keeping a clear startup boundary between full persistence reads and the subset of restored data the daemon is allowed to consume.
Give the daemon an identity-scoped persistence root and snapshot directory so long-lived remote indexing data does not mix with normal app `warp.sqlite` state.
Reuse the existing `CodebaseIndexManager` implementation for both app and daemon paths by injecting snapshot storage rather than forking indexing logic.
Push a full remote codebase index status snapshot after daemon initialize/reconnect, then keep the client current with incremental status updates.
Avoid automatic reindex requests when navigating to a repo that is already ready, stale with a last ready index, queued, or indexing.
Expose ready remote codebases to agent context as stable `(name, path)` entries.
Keep the current `ResyncCodebase` protocol in this PR while documenting why it exists and how it compares with local resync.
## Non-goals
Do not remove or fold `ResyncCodebase` into `IndexCodebase` in this PR.
Do not change local codebase indexing behavior, local persistence schema semantics beyond the shared metadata reuse, or local settings UI behavior.
Do not build a daemon-only indexing implementation separate from `CodebaseIndexManager`.
Do not make the daemon consume or initialize app-only state such as panes, cloud objects, command history, user profiles, MCP servers, or projects.
Do not introduce a client-initiated per-repo status fetch path; daemon-pushed snapshots and deltas remain the synchronization mechanism.
## Proposed design
### Daemon-scoped persistence restore
`persistence::initialize` accepts a `PersistenceScope` so startup can choose between the normal app scope and the remote-server daemon scope. `PersistenceScope::App` uses the normal app database path. `PersistenceScope::RemoteServerDaemon { identity_key }` uses a daemon-specific database path derived from the remote-server identity. Both scopes read the same `PersistedData` shape through the existing SQLite restore helper and both scopes receive writer handles for subsequent updates.
The boundary between app and daemon restore lives at startup initialization rather than in the SQLite reader. `initialize_app` maps the full restored `PersistedData` payload directly into the startup variables used by singleton registration, matching normal app startup. Immediately after that mapping, `initialize_app` applies the launch-mode boundary. Normal app startup consumes the full app restore payload. `LaunchMode::RemoteServerDaemon` preserves `persisted_workspaces` from `codebase_indices` for indexing restore and defaults the app-only startup fields. This keeps a single persistence read path and makes daemon consumption explicit and auditable at initialization.
This split is intentional. The daemon needs the same Diesel/SQLite open, migrate, writer, and full-schema read path as the app so the persistence layer does not fork into app-shaped and daemon-shaped restore contracts. But the daemon should not retain unrelated app-scoped state after initialization. Keeping the direct `PersistedData` startup mapping and filtering in `initialize_app` makes the daemon boundary explicit where startup models are registered while still reusing the existing persistence infrastructure.
The full app restore payload includes state that is meaningful only inside the interactive app process: window/session restoration, cloud object caches, command history, user profiles, workspace language-server settings, MCP server installations, project rules, ignored suggestions, and other UI or app-lifecycle state. Synchronizing all of that into the remote daemon would create two problems. First, the daemon would spend startup time reading and allocating data it will never use. Second, the daemon would become another consumer of app-owned invariants and migrations, so future app persistence changes could accidentally affect a headless remote process.
The daemon only consumes enough persisted data to reconstruct codebase-indexing state:
- repo metadata used to seed `CodebaseIndexManager`,
- the daemon snapshot root used to validate or discard serialized snapshots,
- identity-scoped status/enablement decisions represented by restored codebase metadata,
- writer handles for subsequent codebase-index metadata updates.
That narrow startup consumption is still “syncing app data” in the sense that it reuses the same `codebase_indices` model and full persisted data shape that local app startup uses, but it is not letting the daemon initialize the entire app object graph. This is the intended boundary: share the data model and persistence infrastructure for codebase indexing, then select launch-mode-appropriate fields before registering startup models.
The daemon database and snapshot directories should remain owner-only, matching the remote-server socket/cache privacy model. The database is identity-scoped because enablement/status/backend authorization decisions are Warp-user-specific. Snapshot files are injected separately so the daemon can use its remote-server data root instead of the app default.
### Shared index manager with injected snapshot storage
`CodebaseIndexManager::new` remains the default app constructor. In `local_fs` builds it migrates old app snapshots if needed and passes `SnapshotStorage::app_default()` into `new_with_snapshot_storage`.
`CodebaseIndexManager::new_with_snapshot_storage` is the daemon-compatible seam. It accepts the same persisted `WorkspaceMetadata` and indexing configuration as the app constructor, but lets startup choose the snapshot root. The remote-server daemon passes daemon-scoped storage; the app passes app-default storage.
This keeps indexing behavior shared:
- snapshot validity checks use the same code,
- invalid metadata emits the same `RemoveExpiredIndexMetadata` event,
- valid metadata feeds the same persisted build queue,
- rebuild/resync/drop paths continue to use `CodebaseIndexManager` and `CodebaseIndex`.
The only difference is where metadata and serialized snapshots are restored from and written to.
### Startup and status synchronization
On app startup, `initialize_app` selects a persistence scope from `LaunchMode`. The normal app, CLI, proxy, and tests use `PersistenceScope::App`; `LaunchMode::RemoteServerDaemon { identity_key }` uses `PersistenceScope::RemoteServerDaemon`.
`initialize_app` normalizes the full restored `PersistedData` payload into the startup fields consumed later by singleton registration. It then clears app-only restored fields for daemon launch while preserving `persisted_workspaces` from `codebase_indices`. That lets the existing `CodebaseIndexManager` constructor receive daemon-restored index metadata without pretending the daemon has a full app session restore.
When a client initializes with the daemon, `RemoteServerModel` pushes `CodebaseIndexStatusesSnapshot`. The client-side `RemoteCodebaseIndexModel` applies that snapshot by replacing statuses for the connected host, then applies subsequent `CodebaseIndexStatusUpdated` deltas. This makes reconnect a full-resync boundary and keeps steady-state updates lightweight.
### Navigation and automatic indexing
`RemoteCodebaseIndexModel` records the active repo for a host when it receives `NavigatedToDirectory`. If the navigated directory is a git repo and remote auto-indexing is enabled, it calls `should_request_auto_index_for_navigated_git_repo` before sending an indexing request.
That guard mirrors the local product expectation: navigating into an already-known repo should not immediately trigger another indexing run. It returns false when the current status is ready, stale with a usable last root, queued, or indexing. It returns true when the repo is missing from the status map or has an unusable state such as failed/unavailable/missing root hash.
This preserves automatic indexing for first discovery and recovery while avoiding repeated reindex requests on every `cd` into a repo.
### Agent context and search availability
`RemoteCodebaseIndexModel::codebases_for_agent_context` projects ready searchable remote repos into stable entries with a display name and path. The model only includes statuses that resolve to `RemoteCodebaseSearchAvailability::Ready`, so unindexed, indexing, failed, or otherwise unavailable repos do not appear as searchable codebase context.
For active-session search, `active_repo_availability` resolves an explicit repo path first when it matches known status, otherwise falls back to the active repo for the host or current working directory. Ready availability carries the remote path, root hash, and embedding config needed by downstream search plumbing.
## Protocol shape and local resync contrast
Local resync has two distinct product shapes but no wire protocol. First-time indexing goes through the same manager that owns the local file watcher, persisted metadata, and snapshot storage. Manual resync is an in-process settings action: the settings page dispatches `ManualResync(PathBuf)` and directly calls `CodebaseIndexManager::try_manual_resync_codebase`. Drop/delete similarly calls the manager directly through settings UI actions. Local code can distinguish “index this repo for the first time,” “retry or manually resync this already-indexed repo,” and “drop this repo” by calling different Rust methods because the UI and index manager share memory.
The local pattern also keeps resync conservative. A manual resync only applies when a codebase is already known to the manager. Navigation or repo discovery does not imply a full resync if a ready or stale index already exists; local indexing keeps search available against the last ready root while watcher-driven or manual sync work catches up.
Remote resync crosses the client/daemon boundary. This PR currently models that distinction explicitly with two proto messages:
- `IndexCodebase { repo_path, auth_token }` requests indexing for a repo that may not have an index yet.
- `ResyncCodebase { repo_path, auth_token }` requests a manual full resync of a repo that is already indexed.
That explicit remote protocol mirrors the local product distinction between initial indexing and retry/resync affordances, while making the daemon-side behavior readable in logs, telemetry operation names, and request dispatch. It also lets the daemon return a clear unavailable status when asked to resync a repo it does not know about.
There is a reasonable simplification to consider later: make remote `IndexCodebase` mean “ensure indexed, and if already indexed, perform the manual full resync requested by the client.” That would remove `ResyncCodebase` from the proto and make remote indexing idempotent through one request type. This PR does not implement that simplification so the persistence/status bootstrap work remains isolated from protocol churn.
If a follow-up removes `ResyncCodebase`, it should update all of these seams together:
- proto oneof and message definition,
- `RemoteServerClient::resync_codebase`,
- `RemoteServerOperation::ResyncCodebase`,
- `RemoteCodebaseIndexMutation::Resync`,
- `RemoteServerModel::handle_resync_codebase`,
- client round-trip tests.
The daemon `handle_index_codebase` behavior would then need to explicitly call `try_manual_resync_codebase` for already-indexed repos.
## Mirroring local patterns in remote indexing
Remote indexing should feel like the local feature even though the implementation is split across processes. The strongest local patterns to preserve are:
- A single indexing manager owns per-repo index lifecycle, watcher integration, snapshot validation, sync state, retrieval state, and drop/resync behavior.
- Startup restores persisted codebase metadata first, then queues valid persisted indices through the same manager path as fresh indices.
- Snapshot corruption or incompatibility invalidates the snapshot and falls back to rebuild rather than leaving a repo permanently broken.
- Ready and stale states keep search available through the last ready root; queued and indexing states suppress duplicate indexing requests.
- Settings and speedbump UI decide when to index, retry, resync, or drop; the index manager executes those decisions.
- Manual retry/resync is separate from passive navigation. Navigating into a known repo should update active context, not force a rebuild.
This PR mirrors those patterns by reusing `CodebaseIndexManager`, injecting daemon snapshot storage instead of adding a remote-only manager, feeding daemon-restored metadata into the same persisted build queue, and using `RemoteCodebaseIndexModel` as the client-side analog of local availability state. The remote model records active repo context, applies daemon status snapshots, filters agent context to ready searchable repos, and avoids duplicate auto-index requests when a repo is already ready, stale, queued, or indexing.
The places where remote intentionally differs from local are the process boundary and persistence scope:
- Local can call manager methods directly; remote must encode user actions as proto messages.
- Local can consume full app state because it is the app; the daemon reads the same persisted shape but consumes only codebase-index data because it is a headless worker.
- Local snapshot storage uses the app default; the daemon injects a remote-server data root.
- Local UI state and daemon indexing state synchronize through explicit status snapshots and deltas rather than shared memory.
Future remote improvements should continue to ask “what is the closest local pattern?” before adding remote-specific behavior. Examples:
- If local treats retry as a manual resync of a known repo, remote should either keep a clearly named `ResyncCodebase` message or make `IndexCodebase` explicitly perform that same known-repo resync behavior.
- If local keeps stale search available, remote status pushes should include the last ready root hash for stale states so agent search can continue.
- If local snapshot validation removes invalid persisted metadata, daemon startup should remove invalid daemon metadata and push disabled/failed status deltas rather than silently dropping client state.
- If local settings are the source of truth for user intent, remote daemon handlers should execute explicit client decisions and avoid inventing new daemon-only enablement policy.
## Error handling and security
The daemon should report persistence startup failures through the existing SQLite error reporting and telemetry path, then degrade by starting without restored metadata rather than crashing the app path.
Remote indexing requests that can cause daemon-to-Warp-service calls carry `auth_token` in the protocol payload. The token must never be logged or persisted. The daemon uses it for request-scoped outbound auth and should reject missing or invalid credentials rather than treating the daemon's cached token as ambient authority for proxy-socket writers.
Status values sent to the client should avoid exposing implementation details beyond what the client needs: repo path, lifecycle state, progress, failure message, root hash when search is ready/stale, and embedding config when root hash is present.
## Testing strategy
Unit coverage should focus on the client model and protocol seams:
- snapshot application replaces host-scoped statuses and leaves other hosts untouched,
- incremental status updates update one repo,
- ready/stale statuses with usable root hashes are searchable,
- queued/indexing statuses suppress duplicate automatic indexing,
- failed/unavailable/missing-root statuses allow recovery indexing,
- agent context includes only ready searchable remote repos,
- remote client round-trip tests cover the current indexing/resync/drop request messages.
Persistence coverage should verify that the SQLite reader restores both app state and codebase index metadata through the full `PersistedData` payload. Startup-level coverage should verify that daemon launch consumes only codebase index metadata from that payload and passes daemon-scoped snapshot storage into `CodebaseIndexManager::new_with_snapshot_storage`.
Targeted validation for this PR should include the remote-server client tests, `RemoteCodebaseIndexModel` tests, and a compile check for the app/remote-server crates touched by the persistence and proto changes. Full PR validation should still use the repository presubmit expectations before pushing.
## Risks and mitigations
### Daemon accidentally consumes app state
Consuming full app data in the daemon would couple daemon startup to UI/session state and app-only singleton assumptions. Mitigate by reading the shared `PersistedData` shape, then making the `LaunchMode::RemoteServerDaemon` branch in `initialize_app` preserve only `persisted_workspaces` and default app-only restored fields before model initialization.
### Duplicate indexing on navigation
Remote navigation events can fire often. Without the status guard, entering a known repo could repeatedly enqueue indexing. Mitigate with `should_request_auto_index_for_navigated_git_repo` and tests for ready, stale, queued, indexing, failed, and missing states.
### Client and daemon status drift
If the client misses daemon state during reconnect, tool availability and settings rows can be stale. Mitigate by pushing `CodebaseIndexStatusesSnapshot` after initialize/reconnect and using incremental updates only after that bootstrap.
### Protocol complexity
Keeping both `IndexCodebase` and `ResyncCodebase` makes the remote protocol larger than the minimal idempotent shape. Mitigate by documenting the local-resync parity rationale now and treating removal/folding as a follow-up protocol cleanup rather than mixing it into this persistence PR.
## Definition of done
Remote daemon startup reads the shared persisted data shape but restores only known codebase index metadata into daemon startup state.
The daemon uses daemon-scoped snapshot storage through `CodebaseIndexManager::new_with_snapshot_storage`.
The client receives a bootstrap status snapshot and applies subsequent status deltas.
Remote auto-indexing does not re-request indexing for ready/stale/queued/indexing repos on navigation.
Ready remote repos can appear in agent codebase context.
The PR tech spec documents the persistence architecture and the `IndexCodebase` versus `ResyncCodebase` protocol tradeoff without removing `ResyncCodebase`.
+158
View File
@@ -0,0 +1,158 @@
# APP-3802: Remote-Backed GlobalBufferModel
## Context
Warp's code editor uses `GlobalBufferModel` as a singleton that manages shared `Buffer` instances keyed by `FileId`. Before this work, it only supported local files—backed by `FileModel` for disk I/O and file-watching, with LSP integration for language services.
SSH-remote editing needs the same `Buffer` infrastructure to work over the remote-server protocol: a daemon process on the remote host owns the file on disk, and the client-side Warp app operates on a proxy buffer that syncs bidirectionally with the daemon. Rather than build a parallel buffer system, we extend `GlobalBufferModel` with two new source variants so the same `Buffer`, selection model, and editor view can be reused regardless of where the file lives.
### Relevant files
- `app/src/code/global_buffer_model.rs``BufferSource` enum, `SyncClock`-based version tracking, sync entry points (`apply_client_edit`, `handle_buffer_updated_push`, `apply_diff_result`).
- `app/src/code/buffer_location.rs``BufferLocation` enum (`Local` / `Remote`), `SyncClock` definition and conflict-detection helpers.
- `app/src/code/buffer_location_tests.rs` — unit tests exercising each sync flow without network I/O.
- `crates/remote_server/proto/remote_server.proto (334407)` — wire format: `OpenBuffer`, `BufferEdit`, `TextEdit`, `BufferUpdatedPush`, `CloseBuffer`, `SaveBuffer`, `ResolveConflict`.
- `app/src/remote_server/server_model.rs` — daemon-side `ServerModel` that routes proto messages to `GlobalBufferModel` and pushes file-watcher diffs to clients.
- `app/src/remote_server/server_buffer_tracker.rs``ServerBufferTracker`: path↔FileId mappings, per-buffer connection sets, pending-request correlation.
## Proposed Changes
### 1. Data model: three `BufferSource` variants
`GlobalBufferModel` tracks each buffer's backing store via the `BufferSource` enum. The three variants share the same `Buffer` model but differ in who owns the file and how versions are tracked:
```
BufferSource
├── Local { base_content_version, initial_content_version }
├── ServerLocal { sync_clock, base_content_version, initial_content_version }
└── Remote { remote_path, sync_clock: Option<SyncClock> }
```
**Local** — existing behavior. File I/O through `FileModel`, version tracking via `ContentVersion` from the file-watcher, LSP sync via `didChange`.
**ServerLocal** — daemon-side variant. Created when `ServerModel` calls `open_server_local`. Extends `Local` with a `SyncClock` for version-vector tracking so the daemon can detect conflicts with connected clients. File-watcher changes produce a background diff (`apply_diff_result`) which emits a `ServerLocalBufferUpdated` event containing 1-indexed `CharOffsetEdit`s. `ServerModel` subscribes to this event and pushes `BufferUpdatedPush` proto messages to all connections that have the buffer open.
**Remote** — client-side proxy. Created by `open_remote_buffer` when a Warp tab opens a file on an SSH host. The `sync_clock` starts as `None` (unloaded) and becomes `Some` once the `OpenBufferResponse` arrives with the initial content and server version. Edits made locally fire `BufferEvent::ContentChanged`, which the subscription handler converts into `BufferEdit` proto messages using the delta's `PreciseDelta` ranges. Incoming `BufferUpdatedPush` events from the daemon are applied via `handle_buffer_updated_push`.
The `is_loaded()` check differs by variant: `Local`/`ServerLocal` use `base_content_version.is_some()`, while `Remote` uses `sync_clock.is_some()` to distinguish the "waiting for OpenBufferResponse" state from a loaded buffer.
### 2. Syncing protocol and conflict handling
The protocol uses a two-component version vector (`SyncClock`) where each side owns one counter:
- **server_version** (S) — bumped by the daemon when the file changes on disk.
- **client_version** (C) — bumped by the client when the user edits the buffer.
All edits on the wire use 1-indexed character offsets (`TextEdit { start_offset, end_offset, text }`), matching the buffer's internal `CharOffset` representation. Since both sides of the syncing protocol (client and daemon) are our own `GlobalBufferModel`, there is no need for an intermediate 0-based format — using `CharOffset` values directly avoids conversion code and the off-by-one risks that come with it.
#### Client → Server (user edit)
```
Client sends: BufferEdit { S_expected, C_new, edits }
Server checks: S_expected == local S?
yes → apply edits to buffer, update C to C_new
no → silently drop (stale edit)
```
The server never pushes a rejection response. The client's optimistic edit remains applied locally; a subsequent `BufferUpdatedPush` from the server with a mismatched `C_expected` will trigger a `RemoteBufferConflict` event on the client side.
The client constructs `BufferEdit` messages from `PreciseDelta`s in the `ContentChanged` event, using `replaced_range` for the old offsets and reading replacement text from `resolved_range` in the post-edit buffer.
#### Server → Client (file-watcher change)
```
Server sends: BufferUpdatedPush { S_new, C_expected, edits }
Client checks: C_expected == local C?
yes → apply edits, update S to S_new
no → emit RemoteBufferConflict event
```
On the daemon side, file-watcher events flow through the existing `FileModel``populate_buffer_with_read_content``start_background_diff_parse``apply_diff_result` pipeline. For `ServerLocal` buffers, `apply_diff_result` converts the byte-range diff edits to 1-indexed `CharOffsetEdit`s (using the buffer's native `ByteOffset::to_buffer_char_offset`) before applying the diff, then emits `ServerLocalBufferUpdated`. The `ServerModel`'s subscription converts these to proto `TextEdit`s and broadcasts to all connections.
A race guard exists: if a client edit arrives during the background diff parse, the buffer's `ContentVersion` will have changed, causing `apply_diff_result` to detect the mismatch via `version_match` and discard the stale diff.
#### Conflict resolution
When a `RemoteBufferConflict` is detected on the client, the UI presents a resolution dialog. "Accept client" sends a `ResolveConflict` message that replaces the server buffer and saves to disk. "Accept server" re-sends `OpenBuffer` to reload from the server's state.
#### Connection lifecycle
`ServerBufferTracker` manages per-buffer connection sets. When a connection disconnects, orphaned buffers (no remaining connections) are automatically deallocated via `GlobalBufferModel::remove`. `CloseBuffer` removes a single connection; if it was the last one, the buffer is torn down.
### 3. Diagram: edit flow across client and daemon
```mermaid
sequenceDiagram
participant U as User (Editor)
participant C as Client GBM (Remote)
participant W as Wire (Proto)
participant S as Daemon GBM (ServerLocal)
participant D as Disk (FileModel)
Note over C,S: Open flow
C->>W: OpenBuffer { path }
W->>S: open_server_local(path)
S->>D: FileModel::open → FileLoaded
S->>W: OpenBufferResponse { content, S_v }
W->>C: populate buffer, set sync_clock
Note over C,S: Client edit flow
U->>C: type in editor → ContentChanged
C->>W: BufferEdit { S_expected, C_new, edits }
W->>S: apply_client_edit(edits, S_expected, C_new)
Note right of S: S_expected == S? accept : drop
Note over C,S: Server push flow (file changed on disk)
D->>S: FileUpdated → diff parse → apply_diff_result
S->>W: BufferUpdatedPush { S_new, C_expected, edits }
W->>C: handle_buffer_updated_push
Note right of C: C_expected == C? accept : conflict
```
## Testing and Validation
Tests live in `app/src/code/buffer_location_tests.rs` (15 tests). They exercise the sync protocol end-to-end at the `GlobalBufferModel` level without requiring network I/O or a running daemon process.
### Test design
All tests use `App::test((), |mut app| async move { ... })` with a minimal singleton setup (`init_app`) that registers `FileModel`, `LspManagerModel`, etc. Two seeding strategies bypass async I/O:
1. **Server-local path**: `open_server_local` + `populate_buffer_with_read_content(is_initial_load: true)` to synchronously populate content, simulating `FileModel::FileLoaded`.
2. **Remote path**: `seed_remote_buffer_for_test` to insert a `BufferSource::Remote` with a pre-set `SyncClock`, bypassing `RemoteServerManager`.
Helper functions (`text_edit`, `char_edit`) construct proto and internal edit types concisely. Content assertions use `content_for_file`; clock assertions read from `sync_clock_for_server_local` / `sync_clock_for_remote_test`.
### Coverage matrix
**Flow 1 — Open**: `open_server_local_creates_buffer_and_is_server_local` — verifies `BufferSource::ServerLocal` is created with a `SyncClock`.
**Flow 2 — Client edits** (`apply_client_edit`):
- Accepted when server version matches (insert, replace, cross-line, batched).
- Rejected when server version is stale (content unchanged).
- Clock update: `client_version` advances, `server_version` stays.
- Sequential edits: two successive edits accepted with the same `server_version`.
**Flow 3 — Server pushes** (`handle_buffer_updated_push`):
- Accepted when client version matches (single edit, batched).
- Conflict when client version mismatches (`RemoteBufferConflict` event emitted, content unchanged).
- Clock update: `server_version` advances, `client_version` stays.
- Sequential pushes: two successive pushes accepted.
**Flow 4 — Lifecycle**: `remove` deallocates the buffer and `content_for_file` returns `None`.
**Conflict resolution**: `resolve_conflict` replaces content and updates `server_version` to the acknowledged value.
### What is not covered by unit tests
- The async `open_remote_buffer``OpenBufferResponse` round-trip (requires `RemoteServerManager`).
- `ServerModel` proto routing and `ServerBufferTracker` connection management (would need `ServerModel` test harness).
- The background diff parse → `ServerLocalBufferUpdated` push pipeline (requires `FileModel` watcher events).
- Actual SSH transport and reconnection. These are covered by manual testing against a local session via `script/wasm/bundle` with the `remote_tty` feature.
## Risks and Mitigations
**Race between diff parse and client edit**: If a `BufferEdit` arrives while `apply_diff_result` is pending, the buffer version will have changed and the diff is safely discarded. The `version_match` guard at `global_buffer_model.rs:532` handles this.
**Offset coordinate mismatch**: Both the buffer and the wire protocol use 1-indexed `CharOffset` values, so no offset conversion is needed. Values are clamped to `max_charoffset` at the boundary to handle stale or out-of-range offsets.
**Multi-connection buffer sharing**: `ServerBufferTracker` tracks per-buffer connection sets. File-watcher pushes go to all connections; `CloseBuffer` only removes one. Orphaned buffers are auto-deallocated.
+155
View File
@@ -0,0 +1,155 @@
# TECH.md — Client/Server Version Skew for Remote Server
Linear: [APP-3805](https://linear.app/warpdotdev/issue/APP-3805/client-server-version-skew)
## 1. Problem
The Warp remote server binary is installed at a single, unversioned path per channel (e.g. `~/.warp/remote-server/oz`). The existence check is `test -x {bin}` and we never inspect the binary's version before talking to it. When the client auto-updates to a new version, it happily reuses the old remote server binary, which can drift arbitrarily far from the protocol/behaviour the client expects. The `InitializeResponse` already carries `server_version`, but the client ignores it.
We need a version-gated install flow: connecting from a client at version *V* always ends up talking to a server binary also at *V*. Any local `cargo run` workflow (where the client has no `GIT_RELEASE_TAG`) keeps working with `script/deploy_remote_server`: a deployed binary at the unversioned path always wins, and when one is missing the client falls back to installing latest-for-channel at the same unversioned path so the dev loop self-heals.
## 2. Requirements
R1. **Exact version match.** A connected client at version *V* must only communicate with a remote daemon spawned from a binary at the same version *V*. No silent skew.
R2. **Automatic reinstall on drift.** When the installed binary is the wrong version (or missing), the client reinstalls the correct version as part of the connect flow. No manual `rm -rf` step.
R3. **Cheap happy path.** When the correct binary is already installed, connecting should require no extra downloads and no extra SSH round-trips beyond today's single `test -x` check.
R4. **Local dev workflow preserved.** `Channel::Local` clients (the default `cargo run`) must keep working with `script/deploy_remote_server`. When a deployed binary exists at the unversioned path, the client uses it without re-downloading from the CDN. When no binary exists, the client auto-installs latest-for-channel at the same unversioned path so a stale install directory self-heals. The unversioned filename is reserved for `Channel::Local` and `Channel::Oss` (which has no release-pinned CDN artifact and is treated identically); every other channel always uses a versioned filename, so versioned-channel builds can never silently overwrite a `script/deploy_remote_server`-owned slot.
R5. **Clear failure surface.** If install or version validation fails, the user sees an actionable setup-failed state rather than a confusing protocol error.
R6. **Defense-in-depth.** A hand-placed or half-written binary at the expected path shouldn't be trusted blindly — the version reported at the handshake must also be verified.
R7. **No regression in storage behaviour.** We don't introduce unbounded disk growth, and we don't break existing installs at the current unversioned path during rollout.
## 3. Current connect flow
The install + handshake path this spec mutates spans four files. The sequence below shows today's behaviour; §4 slots version-aware steps into the marked decision points.
```mermaid
sequenceDiagram
participant UI as Client UI
participant Mgr as RemoteServerManager
participant Tx as SshTransport
participant Remote as Remote host
participant Daemon as oz daemon
UI->>Mgr: connect_session
Mgr->>Tx: setup(session_id)
Note over Tx: today: path = {dir}/{binary_name}<br/>(no version in filename)
Tx->>Remote: ssh: test -x {path}
alt binary missing
Tx->>Remote: ssh: install_remote_server.sh<br/>(curl → tar → mv → {path})
end
Tx-->>Mgr: SetupReady
Mgr->>Tx: connect()
Tx->>Remote: ssh: `{path} remote-server-proxy`
Remote->>Daemon: spawn (if not already running)
Mgr->>Daemon: Initialize
Daemon-->>Mgr: InitializeResponse { server_version, host_id }
Note over Mgr: today: server_version is ignored
Mgr->>Mgr: mark_session_connected
```
Key pieces of today's behaviour the diagram elides:
- `setup::remote_server_binary()` resolves the path purely from the channel; there is no version in the filename.
- `install_remote_server.sh` pulls from `{server_root_url}/download/cli?package=tar&os=...&arch=...&channel={channel}` with no version pin. The `warp-server` `/download/cli` endpoint already accepts a `version=` query parameter that pins the artifact to an exact release (and falls back to latest-for-channel when omitted), so this spec only needs client-side changes.
- `ChannelState::app_version()` returns `option_env!("GIT_RELEASE_TAG")``Some("v0.…")` on release builds, `None` on `cargo run`. This is the signal we'll thread through in §4.
- `script/deploy_remote_server` is the developer escape hatch: it `rsync`s a locally-built binary into `~/.warp-local/remote-server/oz-local` and assumes the client won't try to download on top of it.
## 4. Proposed solution
Encode the client's expected version into the installed binary's filename, so version drift turns into a missing-file miss that naturally re-triggers the existing install flow. Layer a handshake-level version check on top as a safety net, and reserve the unversioned filename for `Channel::Local` and `Channel::Oss`.
### 4.1 Channel-keyed binary paths
In `crates/remote_server/src/setup.rs`, the path resolution is keyed strictly off [`Channel`]:
- `remote_server_binary()`:
- When `ChannelState::channel()` is `Channel::Local` or `Channel::Oss``{dir}/{binary_name}` (the unversioned `deploy_remote_server` slot; `Oss` has no release-pinned CDN artifact and follows the same convention).
- For every other channel (`Stable`, `Preview`, `Dev`, `Integration`) → `{dir}/{binary_name}-{v}`, where `v = ChannelState::app_version().unwrap_or(env!("CARGO_PKG_VERSION"))`.
- `binary_check_command()` keeps the single `test -x {remote_server_binary()}` contract. Because the version is part of the filename on every versioned channel, any drift resolves to a miss on the existing code path and re-runs `install_script`.
The `CARGO_PKG_VERSION` fallback is intentionally not expected to point at a real release artifact; see §4.4 for the failure shape on versioned-channel + no-tag builds.
### 4.2 Install script pins the exact version (or latest, for Local/Oss)
`warp-server`'s `/download/cli` already honours a `version=` query parameter (pins the redirect to the exact versioned artifact when present; falls back to latest-for-channel when absent), so no server-side change is needed.
In `install_remote_server.sh`, add `{version_query}` and `{version_suffix}` placeholders used in two places:
- Download URL query string: `...&channel={channel}{version_query}` (e.g. `&version=v0.…`, or empty).
- Final install path: `mv "$bin" "$install_dir/{binary_name}{version_suffix}"` (e.g. `-v0.…`, or empty).
In `setup.rs::install_script()`, substitute based on `ChannelState::channel()`:
- `Channel::Local` and `Channel::Oss` → both substitutions are empty strings. These channels download latest-for-channel and install at the unversioned `{dir}/{binary_name}` path that `script/deploy_remote_server` also uses.
- Every other channel → `version_query = "&version={v}"`, `version_suffix = "-{v}"`, where `v` resolves via `app_version().unwrap_or(CARGO_PKG_VERSION)`. Release-tagged clients pin the exact published version; versioned-channel builds without a release tag pin `CARGO_PKG_VERSION`, which deliberately doesn't map to a real `/download/cli` artifact.
Unknown versions (rolled-back releases, or the `CARGO_PKG_VERSION` versioned-channel fallback) surface as a GCS 404 on the redirected URL, which `curl -fSL` turns into a non-zero exit and `SetupFailed` for the user.
### 4.3 Handshake version validation
In `crates/remote_server/src/manager.rs::connect_session`, immediately after `client.initialize().await` returns `Ok(resp)`:
- Let `client_v = ChannelState::app_version()` and `server_v = resp.server_version`.
- If both are `Some` (or server returned a non-empty string) and they differ → log the skew, **delete the versioned binary on the remote host** (see below), then emit `RemoteServerManagerEvent::SessionDisconnected` and call `mark_session_disconnected`.
- If both sides are unknown (client `None` and server reports an empty string) → accept and log a warning. This is the `cargo run` (Local/Oss) + `script/deploy_remote_server` dev loop where neither side carries a release tag.
- Any other shape (one side has a version, the other doesn't) → treated as incompatible. This is defense-in-depth: in normal operation the path-based check (§4.1) would already have triggered a reinstall to bring both sides into agreement, so a mixed shape at the handshake means something unusual (hand-placed binary, partial download) and we prefer to tear the session down and reinstall.
**Why delete the binary on mismatch?** The handshake check only fires when filename-based detection (§4.1) said we had the right version but the running daemon disagrees. That means the file at the versioned path is wrong — partial download, hand-placed, or corrupted. If we only disconnect, the next reconnect will re-run `ensure_binary_installed`, see `test -x {path}` succeed (the file still exists), skip install, respawn the daemon, and hit the same mismatch — a reconnect loop. Deleting the binary forces the next `ensure_binary_installed` to miss and reinstall, breaking the loop in a single extra SSH command (`ssh rm -f {path}`).
Factor the comparison into a pure helper (`fn version_is_compatible(client: Option<&str>, server: &str) -> bool`) so it's unit-testable without wiring up a client.
### 4.4 Unversioned channels (Local / Oss) and versioned-channel-without-tag
`Channel::Local` (the default `cargo run`) and `Channel::Oss` take the unversioned branch of `remote_server_binary()` / `install_script()` (see §4.1, §4.2). Concretely:
- If `script/deploy_remote_server` has put a binary at the unversioned path, `binary_check_command` succeeds and we connect without touching the CDN.
- If the unversioned binary is missing but the install directory exists (e.g. a previous install ran here), the controller's auto-update branch fires and `install_script` runs with empty `version_query`/`version_suffix` — i.e. it pulls latest-for-channel and installs at the same unversioned path. Future `deploy_remote_server` runs simply overwrite this in place.
- If the install directory is also missing (truly fresh host), we fall through to the normal install-mode path (`AlwaysAsk` → user modal, etc.).
`script/deploy_remote_server` is not touched; its target path (`~/.warp-local/remote-server/oz-local` on the local channel) is exactly what the `Channel::Local` branch of `remote_server_binary()` returns.
Versioned-channel builds without a release tag (e.g. `cargo run --bin dev`, `--bin preview`) are deliberately unsupported for SSH remote-server installs. The `CARGO_PKG_VERSION` fallback (§4.1) keeps the path deterministic but the resulting `&version=CARGO_PKG_VERSION` query 404s against `/download/cli`, surfacing a clean `BinaryInstallComplete::Err(_)` and the existing failed-banner path. Developers who need to test against a real channel should either build with `GIT_RELEASE_TAG` set or use `cargo run` (Local) + `script/deploy_remote_server`.
### 4.5 Rollout / backwards compatibility
The first release with this change will, for every user, look for a versioned path that does not yet exist on their remote host. That's fine — it falls through to the normal install path and overwrites into the new versioned filename. The legacy unversioned binary (`{dir}/{binary_name}`) is simply orphaned on disk. We accept that (see §4.6) and can sweep it later.
### 4.6 Deliberate non-goals
- **Cleanup of old versioned binaries.** Accept accumulation for v1; easy to add later as a post-install step that keeps the current + previous version.
- **Upload-over-SSH fallback** when the remote can't reach the CDN. Reasonable future work, not required here.
- **Protocol change to send client version in `Initialize`.** We only read `server_version` off the response; the reverse direction can be added if/when the server wants to reject proactively.
## 5. How the solution maps to the requirements
- **R1 (exact match).** Versioned filename (§4.1) + handshake check (§4.3) give us two independent enforcements of exact match.
- **R2 (automatic reinstall).** A new version turns into a path miss (§4.1), which re-enters the existing `install_script` path (§4.2). No manual step.
- **R3 (cheap happy path).** Still a single `test -x` SSH command on the hot path; handshake check is a string compare on a response we already receive.
- **R4 (local dev workflow).** `Channel::Local` and `Channel::Oss` short-circuit to the unversioned path for both the install location and the existence check (§4.1). A deployed binary always wins via `test -x`; if missing but the install directory exists, the install script runs with empty `version_query`/`version_suffix` and pulls latest-for-channel to the same unversioned path (§4.2, §4.4). `deploy_remote_server` is unchanged and overwrites in place. Versioned-channel builds without a release tag fail cleanly via the `CARGO_PKG_VERSION` 404 path so they can never silently overwrite a Local/Oss-owned slot.
- **R5 (clear failure).** Install errors (CDN 404, network failure, etc.) propagate via `BinaryInstallComplete { result: Err(_) }` and the controller surfaces them through the existing `show_ssh_remote_server_failed_banner` path. Handshake version mismatches reach the same banner via `SessionConnectionFailed { phase: Initialize }` (§4.3).
- **R6 (defense-in-depth).** Handshake check catches stale or hand-placed binaries even when the filename is right (§4.3).
- **R7 (no regression).** Existing unversioned install is simply orphaned after the first upgrade; no code assumes its absence or presence (§4.5).
## 6. Testing and validation
Covers each requirement with a concrete check:
- **Unit tests in `crates/remote_server/src/setup_tests.rs`** (uses the existing `test-util` `ChannelState::set_app_version` hook):
- `remote_server_binary()` returns a `{name}-{version}` path on every versioned channel (`Stable`, `Preview`, `Dev`, `Integration`), whether `app_version()` is `Some` or falls back to `CARGO_PKG_VERSION`. *(R1, R2)*
- `remote_server_binary()` returns the bare `{name}` path on `Channel::Local` and `Channel::Oss`, regardless of `app_version()`. *(R4)*
- `install_script()` substitutes `{version_query}` / `{version_suffix}` into both the URL and the install path on every versioned channel. *(R1, R2)*
- `install_script()` substitutes empty strings for both placeholders on `Channel::Local` and `Channel::Oss`, so the script downloads latest-for-channel and installs at the unversioned path. *(R4)*
- **Unit tests for `version_is_compatible`** covering:
- Matching `Some("v…")` on both sides → compatible. *(R1)*
- Differing `Some`/`Some` → incompatible. *(R1, R6)*
- Both unknown (client `None`, server `""`) → compatible with warning. *(R4)*
- Mixed shape (`Some`/empty, `None`/non-empty) → incompatible. *(R6)*
- **Manual validation** (captured in the PR description):
- Connect once at version *V*; confirm the binary lands at `{dir}/{name}-{V}` and the session comes up. *(R1, R2, R3)*
- Bump the client to version *V+1* (or stub the version in `ChannelState::set_app_version`), reconnect; confirm a new install runs and the connection succeeds. *(R1, R2)*
- Corrupt the versioned binary (e.g. replace its contents with a stub) so the handshake reports a different version; confirm the session is torn down with `SessionDisconnected` and a user-visible error. *(R6)*
- `cargo run` (Local) against a host where the install directory exists but the unversioned binary is missing; confirm the controller auto-installs latest-for-channel at the unversioned path and the session comes up. *(R4)*
- After running `script/deploy_remote_server`, reconnect with `cargo run` (Local) and confirm `test -x` succeeds and we connect without re-downloading. *(R4)*
- **Existing remote-server manager tests** continue to pass unchanged (no behavioural change for the `None`/`""` path).
## 7. Risks and mitigations
- **Churn on every release.** Each stable/preview/dev release will pull a fresh binary on every host the user connects to the first time. Expected; bandwidth impact is minor.
- **Orphaned binaries accumulate.** See §4.6; acceptable for v1, cleanup is a straightforward follow-up.
- **Repeat handshake mismatches.** Addressed by deleting the offending binary on mismatch (§4.3) so the next reconnect reinstalls rather than looping. Worth explicit test coverage (§6).
## 8. Open questions
- Should `Initialize` (client → server) also carry the client's expected version so the server can reject proactively, or is client-side enforcement enough? Leaning "enough" for now.
- Do we want to surface the "version-skew detected, retrying" state as a distinct UI affordance, or is the standard reconnect flow sufficient? Current plan: reconnect-only, revisit if it surfaces as a usability issue.
+65
View File
@@ -0,0 +1,65 @@
# File-based global rules — Product Spec
Linear: APP-3893
Figma: none provided
## Summary
Let users define agent rules that apply across every project by dropping a Markdown file at a well-known location under `$HOME` (initially `~/.agents/AGENTS.md`). Warp picks the file up automatically, sends its contents with every agent query, and surfaces it in the existing Rules settings view alongside cloud rules.
## Problem
Today, rules that should apply to every project must be copied into a `WARP.md`/`AGENTS.md` inside each repo. Cloud rules (added via Settings → Rules) work across projects but require an account, an in-app editor, and aren't editable from external tools or version control. Users want a file-based path: drop a single Markdown file under their home directory, edit it with any tool, and have it applied to every Warp agent query.
## Non-goals
- A unified "disable all rules" toggle that covers file-based globals (today only cloud rules respect the `MemoryEnabled` setting).
- Editing the file from within the Rules view — clicking the row opens it in the editor; we never write to it.
- A separate Add button for file-based globals; the user creates the file themselves.
- New proto/server changes — globals reuse the existing `ProjectRules` request context entry.
## Behavior
### File detection and indexing
1. On startup, Warp checks for a global rule file at each registered location. The initial registry has one entry: `~/.agents/AGENTS.md`. Adding a new well-known location is a code-side change.
2. If the file exists at the registered path when Warp launches, its contents are read into memory and used as agent context for subsequent queries. The read is asynchronous; it does not block startup.
3. If the file does not exist at startup, Warp watches the parent directory (`~/.agents`) so the rule is picked up when the file is created later in the session. No restart is required.
4. If the user deletes the file, Warp drops it from memory within the directory-watcher's debounce window (~500 ms). Subsequent agent queries no longer include the deleted rule. The same applies if the file becomes unreadable for any other reason between FS events (e.g. the user revokes read permission, or replaces the file with a directory) — Warp must not keep stale rule contents active once the source goes away.
5. If the user edits the file, Warp re-reads its contents on the next directory-watcher tick. The next agent query reflects the new contents.
6. If the registered home subdir (e.g. `~/.agents`) does not exist at all, Warp does nothing visible. Once the user `mkdir`s that subdir and creates the rule file inside, Warp registers a watcher and picks the rule up — no restart required.
7. If the user's home directory cannot be determined (a degenerate environment), global-rule indexing silently does nothing — the agent continues to work with project rules only.
### Agent context
8. When an agent query is sent, the contents of every present global rule file are included in the request, alongside any project-scoped rules discovered for the working directory. The agent treats both layers as "rules to follow."
9. Precedence is `global > project WARP.md > project AGENTS.md`. Within a single project directory, an existing `WARP.md` continues to shadow a sibling `AGENTS.md`; the global rule is appended in addition to whichever project rule wins.
10. If no project rules exist for the working directory and no global rule file is present, agent queries behave exactly as they did before this feature — no rule context is attached.
11. If the user is offline or unauthenticated, file-based global rules still work. They are read locally and shipped with the request body; no server-side rule store is consulted.
12. The very first query immediately after launch may fire before the global rule has finished loading (the read is backgrounded). The next query will include the rule. This is the same race the existing project-rules indexing accepts.
13. "Is this project initialized?" surfaces — the `/init` flow's pending-rules step and the code-review empty state's "Repo is initialized with a {file_name} file." hint — answer based on **project-level** rule files only. Dropping a `~/.agents/AGENTS.md` does not by itself mark every repo as initialized; those surfaces still want to set up `WARP.md` / `AGENTS.md` *inside* the repo. Globals are still applied to agent queries; they just do not impersonate per-project initialization.
### Settings → Rules surface
14. In Settings → AI → Rules → **Global** tab, every detected file-based global rule appears as its own row alongside cloud rules. The row shows the absolute file path and an "Open file" button.
15. Clicking "Open file" opens the file in the configured editor. Warp never writes to the file from this surface.
16. When the file is created, edited, or deleted, the row appears, persists, or disappears in the Global tab live — without restarting Warp or re-opening Settings.
17. The Global tab's search bar matches both cloud-rule names/contents and file-based global paths. Search results from both sources are mixed in the result list.
18. The Global tab's "Add" button continues to create a cloud rule. There is no separate "Add" affordance for file-based globals; the user creates the file directly.
19. When neither cloud rules nor file-based globals exist, the Global tab's zero-state copy mentions both ways to add a rule, including dropping a file at `~/.agents/AGENTS.md`.
20. The "rules disabled" banner — shown when `Settings → AI → Memory` is off — continues to render on the Global tab. **Open question:** that toggle today only governs cloud rules; file-based rules currently bypass it. We accept the asymmetry for now and may revisit in a follow-up.
21. The Project-based tab is unchanged.
+107
View File
@@ -0,0 +1,107 @@
# File-based global rules — Tech Spec
Product spec: `specs/APP-3893/PRODUCT.md`
## Context
This change adds a third source of agent-rule context that sits alongside the existing two:
- **Project rules** — `WARP.md` / `AGENTS.md` walked up from the working directory. Indexed by `ProjectContextModel`, sent on every query inside `AIAgentContext::ProjectRules`.
- **Cloud rules** (`AIFact`s) — created in-app, persisted as cloud objects, and applied server-side when `rules_enabled: true` is set on the request. The client only ships an enable flag; the server has the contents.
- **File-based global rules** (this feature) — a Markdown file at a well-known home location (`~/.agents/AGENTS.md`). Indexed and shipped by value the same way project rules are.
Relevant code:
- `crates/ai/src/project_context/model.rs``ProjectContextModel` owns project-rule indexing via `path_to_rules: HashMap<PathBuf, ProjectRules>` and watches each project repo via `repo_metadata::DirectoryWatcher`. It exposes the public rule facade: `find_applicable_rules(path)`, `find_applicable_project_rules(path)`, `global_rule_paths()`, and `index_and_store_rules(root)`.
- `crates/ai/src/project_context/global_rules.rs` — owns file-based global-rule source metadata, cached global file contents, home-subdir watcher state, the update channel, and the global-rule `RepositorySubscriber`.
- `app/src/ai/blocklist/context_model.rs:398-451` — calls `find_applicable_rules` and packs the result into `AIAgentContext::ProjectRules { active_rules, additional_rule_paths, root_path }`.
- `app/src/ai/agent/api/convert_to.rs:763` — serializes `AIAgentContext::ProjectRules` into `api::input_context::ProjectRules { active_rule_files, ... }`. The server appends `active_rule_files` to the prompt directly.
- `app/src/ai/mcp/file_mcp_watcher.rs` — pre-existing pattern for watching a known home subdir (e.g. `~/.codex`) plus the home directory itself for subdir creation/deletion. Reused as a template for the global-rule watchers.
- `crates/watcher/src/home_watcher.rs``HomeDirectoryWatcher` singleton (non-recursive watch on `$HOME`) used to detect creation/deletion of the rule subdir at runtime.
- `app/src/ai/facts/view/rule.rs``RuleView` settings UI with `Global` and `ProjectBased` tabs. Cloud rules render via `CloudRuleRow`; project rules render via the path-only row type.
- `app/src/lib.rs` — singleton bootstrap site for `ProjectContextModel` and the startup call to `index_global_rules`.
## Proposed changes
### Model
`ProjectContextModel` (`crates/ai/src/project_context/model.rs`) remains the rule-context facade, but file-based global rules are isolated behind `GlobalRules` in `crates/ai/src/project_context/global_rules.rs`.
`GlobalRules` owns:
- A `GlobalRuleSource` enum that enumerates known global locations via `strum::EnumIter`. Variants expose `name() / home_subdir() / file_pattern()` accessors. Today there is one variant, `Agents``~/.agents/AGENTS.md`. Adding a new global source = one variant + one match arm in each accessor.
- `rules: BTreeMap<PathBuf, ProjectRule>` — discovered file contents, sorted by path so iteration is deterministic.
- `source_watchers: HashMap<PathBuf, GlobalSourceWatcherState>` — keyed by the absolute home subdir path so duplicate registrations naturally dedup.
- `updates_tx: Option<Sender<GlobalRulesUpdate>>` — single channel that all per-source `RepositorySubscriber` instances push into; tagged with the originating `GlobalRuleSource`.
`ProjectContextModel` owns one `global_rules: GlobalRules` field and exposes thin integration methods:
- `index_global_rules(ctx)` — invoked once at startup and delegated to `GlobalRules::index`. Native builds use the file-watching implementation in `global_rules.rs`; non-`local_fs` builds call the no-op `dummy_global_rules.rs` implementation so the public facade and startup call stay unconditional. For each `GlobalRuleSource`, the native delegated logic:
1. Spawns an async read of the target file via `ctx.spawn`. The callback inserts into `global_rules` and emits `GlobalRulesChanged`.
2. Subscribes to `HomeDirectoryWatcher` to react to creation/deletion of the subdir at runtime.
3. If the subdir exists, registers a `repo_metadata::DirectoryWatcher` on it and starts a `GlobalRulesRepositorySubscriber` that funnels per-file events back through the channel.
- Home-subdir watcher events handle deletions before additions so bundled create+delete events do not leave stale watchers or cached rule state behind.
- `find_applicable_rules` is extended to layer global rules on top of project rules in the returned `ProjectRulesResult.active_rules`. Globals iterate via `BTreeMap` order (deterministic). The `pending_context()` consumer in `BlocklistAIContextModel` is unchanged.
- A separate `find_applicable_project_rules(path)` accessor returns *only* indexed project rules — globals are deliberately not layered in. This is the signal callers should use when they want to know "does this repo itself have rules indexed?" rather than "what rules apply to this path?". Two such callers exist today (PRODUCT.md invariant 13): `app/src/terminal/view/init_project/model.rs:189-202` (`should_have_available_steps`) and `app/src/code_review/code_review_view.rs:4505-4534` ("Repo is initialized with a {file_name} file." hint). Both were migrated as part of this change so a stray `~/.agents/AGENTS.md` does not flip every repo into the "already initialized" state. `find_applicable_rules` continues to be the right entry point for the agent-context packing path in `BlocklistAIContextModel::pending_context`.
- `GlobalRules::spawn_global_rule_read` reacts to a failed re-read by removing any previously cached entry for that path and emitting a `GlobalRulesChanged` deletion delta. This covers cases where the FS event arrives but the read fails (file deleted between event and read, perms revoked, replaced with a non-regular file) — silently keeping stale rule text active would surprise users who had thought they removed it. PRODUCT.md invariant 4 is the user-visible promise this enforces.
- The `safe_warn!` calls in `GlobalRules::register_global_source_watcher` keep the underlying error in the `full:` (dogfood) branch only; the `safe:` branch never includes the error or the path because both can embed the user's home directory.
- A new event variant `ProjectContextModelEvent::GlobalRulesChanged(GlobalRulesDelta)` is emitted whenever the set of indexed global rules changes (initial read, FS update, subdir deletion, or a previously-known file becoming unreadable).
- A `pub fn global_rule_paths(&self)` accessor is added for the settings view to read without exposing the full `ProjectRule` content.
The implementation deliberately does **not** persist global rule paths to SQLite. The locations are well-known constants, so we re-scan on every launch.
### Wire-up
`app/src/lib.rs` calls `ProjectContextModel::handle(ctx).update(ctx, |me, ctx| me.index_global_rules(ctx))` immediately after constructing the singleton. The call is unconditional; platform differences live behind the `GlobalRules` alias (`global_rules.rs` for native file-system builds, `dummy_global_rules.rs` no-op for builds without file-system watcher support).
### Settings UI
`app/src/ai/facts/view/rule.rs`:
- Renames `ProjectScopedRow``FileBackedRow` and `RuleRow::ProjectScoped``RuleRow::FileBacked` since the row shape (path + open-file button) is now used for two sources, not just project rules.
- Adds `file_backed_global_rules: Vec<FileBackedRow>` to `RuleView`, populated from `ProjectContextModel::global_rule_paths()` in `RuleView::new`.
- Extends the existing `ProjectContextModel` subscription block to also handle `GlobalRulesChanged`, refreshing the new field and calling `ctx.notify()`. The match is exhaustive over event variants now, so future variants will trigger a compile error.
- `get_filtered_rules` for `RuleScope::Global` now chains cloud rows (`RuleRow::Global`) with file-backed-global rows (`RuleRow::FileBacked`). The render path for `FileBacked` is unchanged from the project-based render path; the same `OpenFile(PathBuf)` action is dispatched and the editor opens.
- The Global-tab zero-state string is updated to mention `~/.agents/AGENTS.md` as a second way to add a rule.
### What is reused unchanged
- `AIAgentContext::ProjectRules` proto / serialization. Globals piggyback on the existing variant; the server appends `active_rule_files` regardless of where they came from.
- The `OpenFile(PathBuf)` action / event already plumbed for project rows.
- Persistence: nothing new lands in SQLite.
## Testing and validation
### Unit tests
Located in `crates/ai/src/project_context/model_tests.rs`. They populate `ProjectContextModel` through local test helpers (direct test-visible `global_rules.rules` insertion for globals and `path_to_rules` for project rules), so they exercise the layering logic without spinning up the watcher infrastructure (which requires the warpui runtime).
- Global rule alone, no project rules → `find_applicable_rules` returns it. Covers PRODUCT invariants 8, 10.
- Global rule + project `WARP.md` for the same path → both appear in `active_rules`, ordered global first. Covers invariants 8, 9.
- Global rule + project `WARP.md` and `AGENTS.md` in the same dir → project `WARP.md` shadows project `AGENTS.md`; global is appended. Covers invariant 9.
- No rules anywhere → `None`. Covers invariant 10.
- Global-only → `root_path` falls back to the parent of the global file.
- Multiple global sources both contribute (uses set-based assertions because `BTreeMap` orders by path).
- `find_applicable_project_rules` ignores globals: with only a global indexed, project-only is `None` while layered `find_applicable_rules` is `Some`. Covers invariant 13.
- `find_applicable_project_rules` returns the project rule (only) when a project rule and a global are both indexed. Covers invariant 13.
The project-context tests pass via `cargo nextest run -p ai --features local_fs project_context`.
### Manual end-to-end
Maps directly to the PRODUCT.md behavior section:
1. Without `~/.agents/AGENTS.md`, fire an agent query and confirm no global rule is attached. (Invariant 10.)
2. `mkdir -p ~/.agents && echo "prefer 4-space indentation" > ~/.agents/AGENTS.md`. The next agent query includes the file's contents. (Invariants 1, 2, 8.)
3. Edit the file in any external editor and re-fire. New contents appear. (Invariant 5.)
4. `rm ~/.agents/AGENTS.md` and re-fire. Global is gone. (Invariant 4.)
5. Without restarting, recreate the file. Watcher picks it up. (Invariants 3, 6.)
6. Open Settings → AI → Rules → Global. With the file present, expect a row showing `~/.agents/AGENTS.md` with an "Open file" button. (Invariants 13, 14.)
7. Edit/delete the file while the Global tab is open. Row updates live. (Invariant 15.)
8. Add a cloud rule via the existing "Add" button. Both row types coexist. Search filters across both. (Invariants 16, 17.)
9. With both cloud and file-based empty, the zero-state copy mentions `~/.agents/AGENTS.md`. (Invariant 18.)
### Lint / format
`cargo fmt`, `cargo nextest run -p ai --features local_fs project_context`, and `cargo clippy -p ai --all-features --tests -- -D warnings` pass.
## Follow-ups
- Decide whether the `MemoryEnabled` toggle should also gate file-based global rules (currently it does not — see PRODUCT.md invariant 19). Either gate `find_applicable_rules` on the setting in `BlocklistAIContextModel::pending_context`, or add a separate file-rule toggle.
- Consider exposing the file's content in the Settings row (preview/truncate, like cloud rules) instead of just the path.
- If we want the rule file open-state to feel editable in-app, surface an "Edit" affordance that opens it in Warp's code editor with a buffer rather than just a file open.
+66 -66
View File
@@ -1,76 +1,76 @@
# APP-4218: Git operations dialogs compare against the branch's actual parent — Tech Spec
Product spec: `specs/APP-4218/PRODUCT.md`
## Context
Today the Push / Publish dialog, the Create PR dialog, and their AI helpers hard-code the repo's main branch as the comparison base whenever the current branch has no upstream. On a branch-off-a-branch, every commit inherited from the parent branch shows up as "included" in the push / PR.
All of the offending code paths already route their base through one function call: `detect_main_branch`. The fix is to introduce a `detect_parent_branch` helper that returns the closest-ancestor branch (falling back to main), and swap the four callers that currently say `detect_main_branch` to say `detect_parent_branch`. No function signatures change.
APP-4218 is about avoiding misleading Git Operations previews when a user creates a branch from another feature branch. The original implementation fell back to the detected default branch when a branch had no upstream, so the Push / Publish dialog could show inherited parent-branch commits as if they belonged to the current branch.
This PR implements the first, low-risk part of that behavior: the no-upstream Push / Publish commit list now falls back to a fork-point SHA instead of the default branch. The PR dialog, PR AI inputs, and `gh pr create --base` path still use the detected default branch in this checkout; they are listed as follow-ups below so the checked-in spec does not overstate what shipped.
Relevant code:
- `app/src/util/git.rs:316-359``get_unpushed_commits`: `git log @{u}..HEAD` with `main..HEAD` fallback.
- `app/src/util/git.rs:602-634``get_branch_diff_entries`: `main..<end>`.
- `app/src/util/git.rs:743-766``get_diff_for_pr`: `main..<end>`, feeds AI.
- `app/src/util/git.rs:775-784``get_branch_commit_messages`: `main..HEAD`, feeds AI.
- `app/src/util/git.rs:803-826``create_pr`: invokes `gh pr create` without `--base`.
- `app/src/code_review/git_dialog/{push,pr}.rs` — per-dialog state, unchanged in shape. The detected parent is used transparently through the four util helpers.
- `app/src/util/git.rs (199-249)``detect_fork_point`, which computes a SHA for the point where `HEAD` forked from other refs.
- `app/src/util/git.rs (391-428)``get_unpushed_commits`, which uses `<upstream>..HEAD` when an upstream exists and `<fork>..HEAD` only in the no-upstream fallback.
- `app/src/code_review/diff_state.rs (1360-1399)``load_metadata_for_repo`, which detects the current branch and upstream, then stores `unpushed_commits` in `DiffMetadata`.
- `app/src/code_review/code_review_view.rs (6770-6796)``primary_git_action_mode`, which turns `unpushed_commits` into Publish / Push button state.
- `app/src/util/git.rs (677-904)` — PR diff / AI / create helpers, which still compare against and target the detected default branch.
## Proposed changes
### 1. `detect_parent_branch`
```rust path=null start=null
// app/src/util/git.rs
/// Returns the closest-ancestor branch of `HEAD`, or the main branch when
/// no candidate qualifies. Ties prefer the detected main branch, then local
/// over `origin/*`, then alphabetical for determinism.
pub async fn detect_parent_branch(repo_path: &Path) -> Result<String>;
```
Implementation (all upfront queries run in parallel via `futures::join!`):
1. `git for-each-ref --merged HEAD --format='%(objectname) %(refname:short)' refs/heads refs/remotes` to list ancestor refs with their commit SHAs. `--merged HEAD` filters out non-ancestors at the git level, avoiding per-candidate subprocess spawns.
2. `git log HEAD --format=%H` to walk HEAD's history once. A `HashMap<&str, usize>` of `sha → position` gives each candidate's distance from HEAD in O(1) lookups.
3. Resolve the actual upstream via `git rev-parse --abbrev-ref --symbolic-full-name @{u}` and exclude it (plus the current branch name) from candidates. Handles non-`origin` upstream configurations.
4. Rank candidates by `(distance, !is_main, !is_local, name)`. Log the winner at debug.
5. If no candidate qualified, return `detect_main_branch(repo_path)`.
Return type is a plain `String` — either a local branch (`feature-a`) or a remote-tracking ref (`origin/feature-a`).
### 2. Swap `detect_main_branch` for `detect_parent_branch` inside the four helpers
No caller / signature changes. Each helper keeps its current shape; only the internal base-branch lookup changes:
- `get_unpushed_commits`: the no-upstream fallback branch becomes `detect_parent_branch` instead of `detect_main_branch`. The primary `@{u}..HEAD` path is unchanged (when an upstream exists, it's still the most accurate "what will be pushed"). When upstream is unset, the fallback now uses the closest ancestor.
- `get_branch_diff_entries`: `let base = detect_parent_branch(repo_path).await?;` in place of the current `detect_main_branch` call. The `{base}..{end_ref}` range logic is unchanged.
- `get_diff_for_pr`: same one-line swap.
- `get_branch_commit_messages`: same one-line swap.
### 3. `create_pr` passes `--base`
`create_pr` internally calls `detect_parent_branch`, strips any `origin/` prefix, and passes `--base <parent>` to `gh pr create`. Signature unchanged:
```rust path=null start=null
pub async fn create_pr(
### 1. Add `detect_fork_point`
`detect_fork_point(repo_path, current_branch_name)` returns the SHA where `HEAD` forked from other local or remote refs. It accepts the current branch name so the current branch and `origin/<current>` can be excluded from the comparison set; otherwise the branch would subtract itself and report no unique commits.
The current implementation uses one reachability query plus a `rev-parse`:
```rust
pub async fn detect_fork_point(
repo_path: &Path,
title: Option<&str>,
body: Option<&str>,
) -> Result<PrInfo> {
let base = detect_parent_branch(repo_path).await?;
let base = base.strip_prefix("origin/").unwrap_or(&base).to_string();
// ...existing gh pr create invocation, plus --base <base>...
}
current_branch_name: Option<&str>,
) -> Result<Option<String>>;
```
If detection errors, propagate the error — the caller's existing `user_facing_git_error` path shows the generic failure toast.
### 4. Dialogs
No dialog-level plumbing. The four util helpers (`get_unpushed_commits`, `get_branch_diff_entries`, `get_diff_for_pr`, `get_branch_commit_messages`) already feed the Push and Create-PR dialogs; swapping them to `detect_parent_branch` internally is enough. The Commit dialog's `CommitAndCreatePr` chain inherits the fix via `create_pr` (§3).
Surfacing the detected parent in the dialog chrome (e.g. a "Based on" row) was evaluated and dropped for now — it added visible latency waiting on detection to resolve, with limited user value. Tracked as a follow-up.
### 5. Feature flag gating
All changes live under `FeatureFlag::GitOperationsInCodeReview` (already gating the dialogs); no new flag.
Algorithm:
1. Normalize `current_branch_name`; ignore empty names and detached `HEAD`.
2. Build `git rev-list HEAD --not --exclude=<current> --branches --exclude=origin/<current> --remotes`.
3. Treat the last non-empty line as the oldest commit unique to `HEAD`.
4. Return that commit's parent via `git rev-parse <oldest-unique>^`.
5. If there are no unique commits, resolve `HEAD` itself; if the git commands fail, return `Ok(None)`.
This differs from the earlier plan that introduced a separate `for-each-ref` step and a branch-name detector. The branch-name detector is not present in this implementation.
### 2. Use the fork point only for no-upstream unpushed commits
`get_unpushed_commits(repo_path, current_branch_name, upstream_ref)` keeps the upstream path unchanged:
- If `upstream_ref` exists, run `git log <upstream>..HEAD --format=COMMIT:%H\t%s --numstat`.
- If `upstream_ref` is missing, call `detect_fork_point(repo_path, current_branch_name)` and run `git log <fork>..HEAD ...`.
- If no fork point can be resolved, fall back to `git log HEAD ...`.
This is the behavior that fixes the Publish dialog for stacked no-upstream branches while preserving existing behavior for branches that already have an upstream.
### 3. Keep dialog and metadata wiring unchanged
`DiffStateModel::load_metadata_for_repo` already computes the current branch, upstream ref, and `unpushed_commits` during metadata refresh. The Git Operations button already derives its mode from `unpushed_commits`, `upstream_ref`, uncommitted stats, and PR info.
No new UI state is required. The Push / Publish dialog still receives a `Vec<Commit>` from `DiffStateModel` when opened, so switching the no-upstream fallback is enough to change the included commit list.
### 4. Leave PR helpers on the default branch for this PR
The current code still uses `detect_main_branch` for:
- `get_branch_diff_entries` — Create PR dialog file stats.
- `get_diff_for_pr` — AI PR title / body diff input.
- `get_branch_commit_messages` — AI PR title / body commit-message input.
- `create_pr``gh pr create --base <default-branch>`.
This means `PRODUCT.md` behavior around Create PR targeting the detected parent is not fully implemented by this PR. The tech spec should not claim `detect_pr_base_branch` exists or that these helpers use the fork-point SHA.
## End-to-end flow
1. Code review metadata refresh runs in `DiffStateModel::load_metadata_for_repo`.
2. The model resolves `current_branch_name` and optional `upstream_ref`.
3. `get_unpushed_commits` computes either `<upstream>..HEAD` or `<fork>..HEAD`.
4. `CodeReviewView::primary_git_action_mode` uses non-empty `unpushed_commits` plus upstream state to choose Publish or Push.
5. Opening the Push / Publish dialog passes those commits into `GitDialog::new_for_push`.
## Risks and mitigations
### Heuristic picks the wrong branch
Two branches pointing at the same commit, deleted historical parents, etc. The parent isn't visible in the UI right now, so bad detections only manifest as a wrong commit list / wrong PR base. A follow-up can surface the parent or add a per-branch override.
### Cost of repeated detection
`detect_parent_branch` runs inside each of the four helpers on every dialog open (and once more in `create_pr`). Each detection is 24 parallel subprocess calls (`for-each-ref --merged HEAD`, `log HEAD --format=%H`, `rev-parse @{u}`, `detect_main_branch`) regardless of branch count — typically sub-100ms even in repos with thousands of remote-tracking refs. For the common PR-create flow, that's ~4× the cost on top of the AI call, which is already the dominant latency. Acceptable. If large-repo latency becomes measurable, cache per-repo inside `detect_parent_branch` itself (follow-up).
### PR targets an unpushed base
If the parent is a local-only branch, `gh pr create --base <b>` fails. Surfaces via the generic "Git operation failed." toast; we do not silently retry without `--base`.
### Backwards compatibility
Fresh-feature-off-main shapes resolve to `main`, so today's behavior is preserved on the common path.
### Fork-point SHA is not a PR base branch name
The fork point is enough for a commit range, but `gh pr create --base` needs a branch name. This PR intentionally does not infer a parent branch name from the fork point. Create PR behavior remains default-branch based until that follow-up lands.
### Stale refs can affect fork-point detection
Because `rev-list` subtracts all branches and remotes except the current branch and `origin/<current>`, stale local or remote-tracking refs can make the fork point earlier than a user expects. This is conservative for Publish commit lists but can still be surprising. Pruning stale refs remains the mitigation.
### Remote name assumption
The current self-exclusion only excludes `origin/<current>`. Repos whose current branch is pushed to a differently named remote could still include that remote-tracking ref in the subtraction set. That can hide unique commits after a manual push to a non-origin remote. If that matters, resolve the actual remote-tracking branch before building excludes.
### Root commit / no other refs
If the oldest unique commit has no parent, `rev-parse <sha>^` fails and `detect_fork_point` returns `None`; `get_unpushed_commits` then falls back to logging `HEAD`. This is acceptable for orphan or brand-new repositories.
## Testing and validation
References below are to `specs/APP-4218/PRODUCT.md` success criteria.
### Manual validation
- `feature-a` (pushed), `git checkout -b feature-b feature-a`, 1 new commit, no push: Publish dialog shows 1 commit (SC 1). Create PR shows only those files; confirming runs `gh pr create --base feature-a` and the PR targets `feature-a` (SC 2).
- Fresh branch off main, no upstream: dialog shows `main..HEAD` (SC 3).
- Rebase `feature-b` onto `main`, reopen dialog: commits list reflects the rebased range (SC 4).
- Commit-and-create-PR on `feature-b`: PR targets `feature-a` (SC 6).
- Change the pane's diff-mode dropdown; reopen dialogs: previews are unchanged (SC 7).
### Integration / screenshot coverage
None added. The `git_dialog` module ships without an integration harness today (see `specs/APP-4125/TECH.md`).
Manual validation for the current implementation:
- Create `feature-a` from main, add commits, then create `feature-b` from `feature-a` without setting an upstream. The Publish dialog on `feature-b` should show only commits unique to `feature-b`, not all of `feature-a`.
- Create a fresh no-upstream branch from main and add commits. The Publish dialog should still show commits since the branch forked from main.
- On a branch with an upstream, the Push dialog should still use `<upstream>..HEAD`.
- Reopen the dialog after rebasing the current branch; metadata refresh should recompute the fallback range from the new graph.
- Create PR dialog validation should expect current behavior for this PR: file stats, AI inputs, and `gh pr create --base` still use the detected default branch.
Recommended unit coverage in `app/src/util/git_tests.rs`:
- `detect_fork_point` returns the original fork commit after main advances beyond the branch point.
- No-upstream `get_unpushed_commits` excludes commits inherited from the parent feature branch.
- Upstream-backed `get_unpushed_commits` remains unchanged and uses `<upstream>..HEAD`.
- Detached `HEAD` does not try to exclude a branch named `HEAD`.
## Follow-ups
- **Surface the detected parent in the dialog chrome** (a "Based on" row) once we have a way to populate it without visible latency — e.g. caching it on `DiffMetadata` so it's ready by the time the dialog opens.
- **Per-branch override** for mis-detected parents (stored in `.git/config` as `branch.<name>.warpParent`).
- **Per-repo caching** inside `detect_parent_branch` if repeated detection shows up in profiles.
- Add parent branch-name detection for PR creation. A likely implementation is to find refs that contain or are closest to the fork point, then choose the best branch name with deterministic tiebreakers.
- Switch `get_branch_diff_entries`, `get_diff_for_pr`, and `get_branch_commit_messages` from default-branch ranges to the detected parent range once branch-name detection exists.
- Switch `create_pr` to pass `--base <detected-parent>` and strip `origin/` when the selected parent is a remote-tracking ref.
- Consider resolving the actual remote-tracking branch for current-branch self-exclusion instead of assuming `origin/<current>`.
+141
View File
@@ -0,0 +1,141 @@
# Remote Server Integration Tests
## Context
The `SshRemoteServer` feature flag gates a new SSH session flow where a persistent binary (`remote-server-proxy`) runs on the remote host, replacing the legacy ControlMaster-based command execution. The feature has unit test coverage at the protocol layer (`crates/remote_server/src/client_tests.rs`) but no integration test coverage exercising the full client ↔ server lifecycle over a real SSH connection.
### Current integration test infra
The existing SSH integration tests (`crates/integration/src/test/ssh.rs`) cover the legacy warpification flow:
- Connect to a GCP-hosted Ubuntu VM (`ubuntu-14-04`) via IAP tunnel with password auth
- Helper steps in `app/src/integration_testing/subshell/``setup_gcloud_sdk()`, `enter_ssh_command()`, `enter_ssh_password()`, `wait_for_password_prompt()`
- Builder pattern: `new_builder().with_step(TestStep)` with assertion callbacks
- Feature flag gating via `set_should_run_test(|| FeatureFlag::X.is_enabled())`
### Remote server flow
When `SshRemoteServer` is enabled for a legacy SSH session (`app/src/terminal/writeable_pty/remote_server_controller.rs`):
1. `RemoteServerController` intercepts `SshInitShell`, stashes the bootstrap script
2. Runs `check_binary` via `RemoteServerManager` → if missing, `install_binary` → then `connect_session`
3. `connect_session` (`crates/remote_server/src/manager.rs:368`) spawns the proxy over SSH, performs the proto `Initialize` handshake → emits `SessionConnected { host_id }`
4. Bootstrap is flushed; `RemoteServerCommandExecutor` (`app/src/terminal/model/session/command_executor/remote_server_executor.rs`) is wired as the session's `CommandExecutor`
5. On CWD change, `navigate_to_directory` fires → returns `is_git` flag + triggers `RepoMetadataSnapshot` push
Key config: `SshExtensionInstallMode::AlwaysInstall` (setting in `app/src/terminal/warpify/settings.rs:85`) bypasses the choice block UI, needed for deterministic test flow.
### Binary deployment problem
The production install script (`crates/remote_server/src/install_remote_server.sh`) downloads from the CDN (`app.warp.dev/download/cli`). This fetches a published binary, not one built from the developer's branch. For integration tests, the binary must come from the current codebase so that changes to the remote-server protocol or logic are tested. The existing `script/deploy_remote_server` already solves this for local development — it cross-compiles for `x86_64-unknown-linux-musl` and uploads via rsync.
## Proposed changes
### 1. CI step: cross-compile and deploy binary to test VM
Add `script/deploy_remote_server_to_test_vm` that:
1. Cross-compiles the Oz CLI for the test VM target:
```
cargo build -p warp --bin warp --target x86_64-unknown-linux-musl \
--profile dev-remote \
--features release_bundle,crash_reporting,standalone,agent_mode_debug
```
Same build command as `script/deploy_remote_server`.
2. Uploads the binary to `ubuntu-14-04` at `~/.warp-dev/remote-server/oz-dev` via `sshpass` + `scp` through the GCP IAP tunnel (the test VM uses password auth; `sshpass` provides it non-interactively). Uses the same proxy command as the SSH integration tests (`app/src/integration_testing/subshell/util.rs:2`).
CI calls this script once before launching the integration test suite. Since `check_binary` will find the binary already present, the `RemoteServerController` flow becomes `check_binary → Ok(true) → connect_session`, skipping the CDN-based install.
### 2. Assertion helpers — `app/src/integration_testing/remote_server.rs`
New module with reusable test steps and action helpers:
- **`wait_for_remote_server_ready(tab_idx)`** — `TestStep` that polls until `Sessions::remote_server_setup_states` for the active session reaches `RemoteServerSetupState::Ready`.
- **`assert_remote_server_connected(tab_idx)`** — reads `RemoteServerManager` singleton, asserts the active session is in `RemoteSessionState::Connected`.
- **`assert_command_executor_is_remote_server(tab_idx)`** — downcasts the session's `CommandExecutor` via `as_any().downcast_ref::<RemoteServerCommandExecutor>()`.
- **`assert_remote_server_has_navigated(tab_idx)`** — asserts that `host_id_for_session` is populated for the active session.
- **`write_file_via_remote_server(tab_idx, path, content)`** — action callback that calls `RemoteServerClient::write_file` on a background thread (async → sync bridge via `tokio::runtime::Runtime::block_on`).
- **`load_repo_metadata_directory_via_remote_server(tab_idx, repo_path, dir_path)`** — action callback that calls `RemoteServerManager::load_remote_repo_metadata_directory` through the model handle.
Also adds `Session::command_executor()` accessor gated on `#[cfg(any(test, feature = "integration_tests"))]` (`app/src/terminal/model/session.rs`).
Registered in `app/src/integration_testing/mod.rs`.
### 3. Integration tests — `crates/integration/src/test/remote_server.rs`
All tests gated on `FeatureFlag::SshRemoteServer.is_enabled()` and configured with `with_user_defaults` setting `SshExtensionInstallMode` to `AlwaysInstall`.
#### Test A — Connection and handshake (`test_remote_server_connect_bash` / `_zsh`)
Validates the core flow: SSH → binary check → proto handshake → executor wiring.
Steps:
1. `wait_until_bootstrapped_single_pane_for_tab(0)` — local shell ready
2. `setup_gcloud_sdk()`
3. `enter_ssh_command(shell)` + `wait_for_password_prompt` + `enter_ssh_password`
4. `wait_for_remote_server_ready(0)` — covers check → connect → handshake
5. `wait_until_bootstrapped_single_pane_for_tab(0)` — remote shell bootstrapped
6. `assert_remote_server_connected(0)` — manager has `Connected` state with a `HostId`
7. `assert_command_executor_is_remote_server(0)` — session uses `RemoteServerCommandExecutor`
#### Test B — Repo metadata (`test_remote_server_navigate_to_repo`)
Validates the full navigate-to-directory flow: create git repo on remote → `cd` into it → `NavigatedToDirectory` response received → session has `host_id` tracked.
After Test A setup, plus:
8. Create a git repo on the remote via `execute_command("mkdir -p /tmp/warp-test-repo && cd /tmp/warp-test-repo && git init -b main ...")`
9. `execute_command("cd /tmp/warp-test-repo")` — triggers CWD change → `navigate_to_directory`
10. `assert_remote_server_has_navigated(0)` — host_id present for active session
11. `assert_remote_server_connected(0)` — session still healthy after navigation
#### Test C — Completions routing (`test_remote_server_completions`)
Validates that completions run through `RemoteServerCommandExecutor::execute_command` (the `RunCommand` proto path) rather than falling back to the legacy `RemoteCommandExecutor`.
After Test A setup, plus:
8. Run a command to trigger completions loading
9. `assert_command_executor_is_remote_server(0)` — confirm executor type
#### Test D — File write via proto client API (`test_remote_server_file_operations`)
Validates `WriteFile` through the `RemoteServerClient` proto API. Uses `write_file_via_remote_server` helper to dispatch the async write from an action callback, then reads the file back via a shell command (which goes through `RemoteServerCommandExecutor::run_command`) to confirm content integrity.
After Test A setup, plus:
8. `write_file_via_remote_server(0, "/tmp/warp-rs-test-file.txt", "hello from proto")` — writes via proto
9. `execute_command("cat /tmp/warp-rs-test-file.txt")` — reads back via `RunCommand`, asserts `"hello from proto"`
10. Clean up and verify executor type
#### Test E — Lazy loading repo metadata (`test_remote_server_lazy_load_directory`)
Validates the `LoadRepoMetadataDirectory` proto round-trip: navigate to a git repo → create a subdirectory → call `load_remote_repo_metadata_directory` for that subdirectory → verify the response flows through without error.
After Test A setup, plus:
8. Create a git repo with a `subdir/nested` file on the remote
9. `cd /tmp/warp-lazy-repo` → triggers `NavigatedToDirectory` and full indexing
10. `load_repo_metadata_directory_via_remote_server(0, repo_path, "subdir")` — triggers lazy-load proto request
11. `assert_remote_server_connected(0)` — connection still healthy
12. `execute_command("cat subdir/nested")` — verifies subdirectory content accessible
### 4. Wire into the test runner
- Add `mod remote_server;` + `pub use remote_server::*;` in `crates/integration/src/test.rs`
- Register test functions in `crates/integration/tests/integration/shell_integration_tests.rs`
- Register in `crates/integration/src/bin/integration.rs` for the manual runner
## Testing and validation
The tests in this spec *are* the validation — they verify the remote-server feature works end-to-end over a real SSH connection. Specifically:
- **Test A** proves the install-check → handshake → executor-wiring pipeline works against a real remote host, catching protocol mismatches or connection failures that unit tests with mock streams cannot.
- **Test B** proves the `NavigatedToDirectory` → repo metadata pipeline works through the proto, catching serialization issues or git-detection regressions on the remote.
- **Test C** proves completions are routed through the remote-server binary rather than silently falling back to the legacy ControlMaster executor, which would mask remote-server regressions.
- **Test D** proves the `WriteFile` proto message works end-to-end by writing via the client API and reading back via `RunCommand`, catching serialization or `FileModel` regressions invisible to shell-only tests.
- **Test E** proves the `LoadRepoMetadataDirectory` lazy-loading proto round-trip works, catching regressions in the subdirectory expansion path that is distinct from the initial `NavigatedToDirectory` indexing.
All tests run against a binary built from the current branch (via the CI deploy step), ensuring protocol changes are tested before merge.
## Parallelization
- **CI script** (step 1) and **assertion helpers** (step 2) can be built in parallel — they touch disjoint files.
- **Test module** (step 3) depends on the assertion helpers but the test groups (AE) are independent functions that can be written in parallel once the shared helpers exist.
- **Test runner wiring** (step 4) is trivial and can be done alongside any other step.
+35
View File
@@ -0,0 +1,35 @@
# APP-4267: Mermaid render failures show an explicit callout
## Summary
Mermaid diagrams in rendered markdown surfaces should never leave users staring at an indefinite “Rendering Mermaid diagram…” placeholder. When rendering fails or remains pending too long, Warp replaces the loading state with a clear failure callout while preserving the underlying markdown source.
## Problem
The markdown viewer can show a large Mermaid placeholder that remains stuck on “Rendering Mermaid diagram…” indefinitely. Users cannot tell whether the diagram is still rendering, failed due to invalid Mermaid syntax, failed during SVG/image conversion, or hit an internal renderer hang.
## Goals
- Show an explicit, readable failure state for Mermaid diagrams that fail or time out.
- Preserve successful Mermaid rendering behavior.
- Preserve the authored Mermaid markdown for editing, copying, storage, and export.
- Keep the first iteration lightweight by reusing existing markdown/code-block styling.
## Non-goals
- Adding a dedicated Mermaid source editor.
- Adding retry, “copy raw,” or “open raw source” controls in this iteration.
- Changing Mermaid syntax support, diagram theme, layout algorithm, or rendered SVG fidelity.
- Changing ordinary image loading behavior outside the explicit implementation surface needed for Mermaid failures.
## Figma
Figma: none provided (use existing markdown/code block styling).
## Behavior
1. When a rendered markdown surface contains a fenced Mermaid code block and Mermaid rendering is enabled, Warp initially shows the existing pending state: “Rendering Mermaid diagram…”.
2. The pending state is temporary. A Mermaid render attempt enters the failure state if:
- Mermaid-to-SVG rendering returns an error.
- The SVG/image conversion pipeline returns an error.
- The diagram remains unresolved for longer than the Mermaid render timeout, initially 10 seconds.
3. When a Mermaid render attempt enters the failure state, Warp replaces “Rendering Mermaid diagram…” with a visible callout that says “Failed to render Mermaid diagram”.
4. The failure callout appears inside the same diagram/code-block container where the rendered diagram or loading placeholder would have appeared. It uses theme-derived text, border, and background colors consistent with existing rendered code/markdown blocks.
5. The failure callout is compact enough that a failed diagram does not reserve the large default placeholder height when Warp can determine the render has permanently failed. If the failure is only a UI timeout while the underlying render is still unresolved, the callout must still be visible in the existing placeholder area.
6. A successfully rendered Mermaid diagram continues to display as the rendered SVG, not as a callout.
7. If a render attempt times out but later resolves successfully, Warp replaces the timeout callout with the successfully rendered diagram. Warp must not switch back to the loading placeholder for the same unresolved render attempt.
8. If the underlying Mermaid source changes, Warp treats the new source as a new render attempt: the previous success, failure, or timeout state does not permanently carry over to the changed diagram.
9. Multiple Mermaid diagrams in the same markdown document are independent. A failure in one diagram does not change the rendering, loading, or failure state of any other diagram.
10. The authored Mermaid markdown remains the source of truth. Copying, storing, exporting, sharing, undo/redo, and editing behavior continue to operate on the original fenced Mermaid markdown, not on the failure callout text.
11. When Mermaid rendering is disabled or the surface is intentionally showing raw code blocks, existing raw Mermaid code block behavior is unchanged.
12. The callout has no interactive controls in this iteration, so it does not add keyboard focus stops. It must still be readable by text-based accessibility surfaces as ordinary visible text.
13. Selection behavior around the failed diagram remains consistent with rendered Mermaid diagrams today: users should not accidentally select or copy the failure message instead of the authored Mermaid markdown when operating on the block as markdown content.
14. The failure state should not log a user-visible toast or modal. The error is localized to the diagram block so the rest of the document remains readable.
+94
View File
@@ -0,0 +1,94 @@
# APP-4267: Mermaid render failures show an explicit callout — Tech Spec
Product spec: `specs/APP-4267/PRODUCT.md`
Linear issue: https://linear.app/warpdotdev/issue/APP-4267/show-failure-callout-when-mermaid-diagram-rendering-gets-stuck
## Context
Mermaid rendering is implemented as an async image asset layered on top of the rich-text markdown renderer.
- `crates/editor/src/content/text.rs (721-729)` classifies fenced Mermaid blocks as `CodeBlockType::Mermaid` when `FeatureFlag::MarkdownMermaid` is enabled.
- `crates/editor/src/content/edit.rs (671-726)` converts styled Mermaid code blocks into `LayoutTask::MermaidDiagram` and calls `mermaid_diagram_layout`.
- `crates/editor/src/content/edit.rs (1031-1067)` converts that layout task into `BlockItem::MermaidDiagram` while preserving the source block content length.
- `crates/editor/src/content/mermaid_diagram.rs:20` defines the current default pending height as 10 line-heights.
- `crates/editor/src/content/mermaid_diagram.rs (28-44)` creates an `AssetSource::Async` whose fetch future calls `mermaid_to_svg::render_mermaid_to_svg`.
- `crates/editor/src/content/mermaid_diagram.rs (47-66)` computes Mermaid block dimensions from the loaded SVG and falls back to the default placeholder height while the asset is not loaded.
- `crates/editor/src/render/element/mermaid.rs (33-66)` renders a Mermaid block by wrapping `Image::new(...).contain().before_load(...)` with the “Rendering Mermaid diagram…” placeholder.
- `crates/editor/src/render/element/mermaid.rs (68-112)` paints the code-block-like rounded background/border and then paints the image element into the content rect.
- `crates/warpui_core/src/elements/image.rs (316-358)` currently paints `before_load_element` for `Loading`, `Evicted`, and `FailedToLoad`; the shared image element has no separate failed-state element.
- `crates/warpui_core/src/assets/asset_cache.rs (284-330)` stores new async assets as `Loading` and spawns the fetch future once per async asset ID.
- `crates/warpui_core/src/assets/asset_cache.rs (415-486)` promotes async assets to `Loaded` or `FailedToLoad` only when the background future resolves.
There is already an attached implementation branch, `origin/oz-agent/APP-4267/mermaid-failure-callout`, that adds an `Image::on_load_failure` element and a compact Mermaid failure notice for `AssetState::FailedToLoad`. That direction fits the current architecture, but it should be tightened to cover the product-level timeout behavior: a truly stuck render can remain `AssetState::Loading` forever because the asset cache only transitions when the fetch future resolves.
## Proposed changes
### 1. Add a Mermaid-specific failed height
In `crates/editor/src/content/mermaid_diagram.rs`, add a compact failed-height multiplier next to the existing pending-height multiplier:
- `DEFAULT_MERMAID_HEIGHT_LINE_MULTIPLIER` remains the pending/success fallback height.
- `FAILED_MERMAID_HEIGHT_LINE_MULTIPLIER` should be 2 line-heights for known permanent failures.
Update `mermaid_diagram_layout` so it distinguishes:
- `Loaded` SVG asset: use intrinsic SVG aspect ratio, as today.
- `FailedToLoad`: use `max_width` and `base_line_height * FAILED_MERMAID_HEIGHT_LINE_MULTIPLIER`.
- `Loading` or `Evicted`: keep the existing default pending height.
Keep the helper focused on layout sizing; do not add render-element state to the content model.
### 2. Add explicit failed-load rendering to `Image`
In `crates/warpui_core/src/elements/image.rs`, add an optional failed-state element to `Image`:
- Store `failed_to_load_element: Option<Box<dyn Element>>`.
- Add `pub fn on_load_failure(mut self, element: Box<dyn Element>) -> Self`.
- During `layout` and `after_layout`, lay out both the before-load element and failed-load element when present.
- During `paint`, render `failed_to_load_element` for `AssetState::FailedToLoad(_)`; if none is provided, preserve current behavior by falling back to `before_load_element`.
This keeps all existing image callers behavior-compatible while allowing Mermaid to provide a distinct error UI.
### 3. Add a loading-timeout path for Mermaid
`AssetState::FailedToLoad` only covers futures that resolve with an error. To satisfy `PRODUCT.md` Behavior 2 for stuck renders, add a Mermaid render timeout without making every image in Warp time out.
Preferred shape:
- Add an optional timeout API to `Image`, for example `pub fn on_load_timeout(mut self, timeout: Duration, element: Box<dyn Element>) -> Self`.
- Track load start time by stable asset-source key inside the image element module. Do not reuse the existing animation `started_at`, and do not store the timeout start only on a single `Image` instance because rich-text layout can rebuild the element before the timeout fires.
- When `paint` sees `AssetState::Loading`, initialize `load_started_at` if needed, schedule a repaint for the timeout deadline, and paint the before-load element until the timeout expires.
- Once the timeout expires, paint the timeout element instead of the before-load element while the asset remains `Loading`.
- If the asset later becomes `Loaded`, paint the image normally and clear backup elements as the existing loaded path does.
- If the asset later becomes `FailedToLoad`, prefer the failed-load element.
If a generic `Image` timeout API feels too broad during implementation, keep the timeout state in `RenderableMermaidDiagram` instead. The important invariant is user-visible: Mermaid cannot show the loading text indefinitely. Avoid wrapping `render_mermaid_to_svg` with `warpui::r#async::FutureExt::with_timeout` as the only timeout mechanism; that helper only times out while the wrapped future yields, and Mermaid rendering is currently a synchronous call inside the async fetch body.
### 4. Wire the Mermaid failure and timeout callouts
In `crates/editor/src/render/element/mermaid.rs`:
- Build the existing loading placeholder as today, using `model.styles().placeholder_color`.
- Build a failure callout text element with the exact string `Failed to render Mermaid diagram`, the code text font family, font size, line-height ratio, and theme-derived placeholder or secondary text color.
- Create the `Image` with:
- `.contain()`
- `.before_load(loading_placeholder)`
- `.on_load_failure(failure_callout)`
- the Mermaid timeout API from §3, using a 10 second timeout and the same visible failure callout text.
Keep the existing rounded background, border, selection overlay, and cursor painting in `RenderableMermaidDiagram::paint`.
### 5. Preserve source and selection semantics
Do not change `BlockItem::MermaidDiagram` content length, markdown serialization, copy behavior, hidden block handling, or editor selection semantics. The failure callout is a render-state presentation for the diagram block, not new document content.
### 6. Logging
Do not add new user-visible toasts. Existing `AssetCache` warning logs for fetch/conversion failures are sufficient for actual failed loads. If timeout logging is added, use at most a debug-level log so a pathological document with many invalid diagrams does not create noisy client logs.
## Testing and validation
Use `cargo nextest run --no-fail-fast --workspace <test identifier>` for focused Rust tests in this repo.
### Unit tests
- Add `crates/editor/src/content/mermaid_diagram_tests.rs` and import it from `mermaid_diagram.rs` with a `#[cfg(test)]` path module. Test that a failed Mermaid asset uses the compact failed height while loading still uses the default pending height.
- Extend or add `crates/warpui_core/src/elements/image_tests.rs` coverage for:
- `AssetState::FailedToLoad` renders the failed-load element when one is provided.
- `AssetState::FailedToLoad` falls back to `before_load_element` when no failed-load element is provided.
- A loading image switches from before-load element to timeout element after the configured timeout.
- Timeout start time survives rebuilding an `Image` element for the same asset source.
- Keep the existing `test_layout_mermaid_block_uses_loaded_svg_aspect_ratio` coverage in `crates/editor/src/content/edit_tests.rs` passing for successful diagrams.
### Integration/manual validation
- In a markdown viewer or editable plan, render a valid Mermaid diagram and confirm it still becomes an SVG.
- Render invalid Mermaid syntax and confirm the block shows `Failed to render Mermaid diagram` instead of staying on the loading placeholder.
- Simulate a stuck Mermaid render by using a test-only async asset source that never resolves, or a temporary local patch in `mermaid_asset_source`, and confirm the loading text is replaced after 10 seconds.
- Confirm editing the Mermaid source creates a new render attempt and does not permanently preserve the previous failure state.
- Confirm two Mermaid diagrams in the same document can independently show success and failure states.
- Confirm selecting/copying/exporting the document still preserves the authored fenced Mermaid markdown, not the failure callout text.
### Commands
- Focused editor tests: `cargo nextest run --no-fail-fast --workspace mermaid`
- Focused image tests: `cargo nextest run --no-fail-fast --workspace image`
- Compile check after implementation: `cargo check`
## Risks and mitigations
### Timeout does not cancel underlying work
A UI-level timeout prevents an indefinite placeholder, but it does not necessarily cancel a synchronous Mermaid render already running on the background executor. Keep the timeout scoped to presentation for this issue. If stuck renders consume executor capacity in practice, follow up with renderer-level cancellation or process isolation.
### Shared `Image` behavior regresses other callers
The new failure and timeout behavior must be opt-in. Existing callers without `on_load_failure` or timeout configuration should behave exactly as they do today.
### Layout height for timed-out loading assets
Known `FailedToLoad` assets can use compact layout on the next layout pass. A UI timeout while the asset remains `Loading` may still occupy the pending placeholder height unless implementation adds model-level timeout state and requests relayout. The first iteration prioritizes replacing the indefinite loading text; compact timeout layout can be a follow-up if needed.
## Parallelization
- One agent can implement the shared `Image` failed-load and timeout behavior plus unit tests.
- Another agent can wire Mermaid-specific layout and render behavior plus Mermaid-focused tests.
- Manual UI validation should wait until both code paths are integrated.
## Follow-ups
- Add a retry affordance for failed diagrams if users need to retry the same source without editing or reopening the document.
- Add a “copy Mermaid source” affordance if the failure state needs an explicit raw-source escape hatch.
- Investigate renderer-level cancellation or isolation if stuck Mermaid renders are confirmed to consume background executor capacity.
+93
View File
@@ -0,0 +1,93 @@
# APP-4271: Vertical Tabs Summary v2 — Per-Line Titles, Working Directories, and Conversation Status Icons
## Summary
Refine the Tabs / Summary tab item mode introduced in APP-3875. Render each work label and each working directory on its own line instead of coalescing them with ` • `, prefix conversation title lines with a status icon, sort title lines so conversations come before non-conversation lines, and lock the card's region order to titles → working directories → branches.
## Problem
The v1 Summary card from APP-3875 keeps the primary line and working-directory line each as a single `•`-joined line. Two failure modes show up in practice:
- When a tab has more than two or three work labels or working directories, the joined line truncates and hides everything past the first one or two values, defeating the purpose of the summary.
- Conversation status — the most actionable piece of information about an agent pane — is not visible on the Summary card; it currently lives only on the focused-session row in `Tab item = Focused session`.
The card needs to surface each work label and each working directory directly, and convey conversation status alongside the title that owns it.
## Figma
Figma: https://www.figma.com/design/CsBdBW4YoLgSAbr5eSkwV6/House-of-Agents?node-id=7633-129739&t=0DkBL0SwricwRNSz-11
The mock is the reference for status-icon styling on conversation title lines. The mock's metadata layout is **not** authoritative — the metadata content, ordering, and overflow behavior described in this spec take precedence over what the mock shows.
## Behavior
The Summary card's region order, top to bottom, is:
1. Title region (one line per work label)
2. Working-directory region (one line per working directory)
3. Branch region (one line per coalesced branch context)
Regions with no data are omitted entirely. The card never renders placeholder text for an empty region.
### Region order
1. The Summary card always renders regions in the order titles → working directories → branches. No setting changes this order.
2. If the title region has any visible lines, it appears first. If the working-directory region has any visible lines, it appears below the title region. If the branch region has any visible lines, it appears below the working-directory region.
3. Omitting an earlier region does not change the relative order of later regions; e.g. a tab with no work labels but with working directories and branches renders working directories first, then branches.
### Title region
4. Each unique work label gathered for the tab renders on its own line. Labels are not joined with ` • ` or any other separator.
5. Title lines appear in first-seen order across the tab's visible panes, matching v1's existing label-gathering order, with one exception: lines whose contributing pane has a known `ConversationStatus` are sorted ahead of lines without one. The relative first-seen order is preserved within each group, so the title region effectively renders all conversation lines first (in first-seen order) followed by all non-conversation lines (in first-seen order).
6. Title-line normalization and dedupe rules from APP-3875 carry over unchanged: trim leading and trailing whitespace, collapse repeated internal whitespace, drop empty labels, and dedupe exact-equivalent normalized labels while preserving the first-seen display text. The card must not semantically rewrite, fuzzy-match, or merge distinct labels.
7. The title region renders at most three title lines. If more than three unique labels exist for the tab, the title region ends with a `+ N more` overflow line, where `N` is the number of additional unique labels not visible. Because conversation lines are sorted first (invariant 5), they take precedence in the visible 3-line cap; non-conversation lines spill into the `+ N more` overflow before any conversation line does.
8. Each title line truncates with an end-ellipsis when its content does not fit the card's available width on a single line.
9. A tab with no work labels for any visible pane omits the title region entirely. No `+ 0 more` line is ever rendered.
### Conversation status icon prefix
10. A title line whose contributing pane has a known conversation status renders a small status pill at the start of that line, before the title text. The pill contains only the status icon — no agent (Oz or CLI agent) icon — and is styled like the conversation status pill used in the pane header / detail sidecar (icon over a 10%-opacity colored background with rounded corners).
11. A title line is eligible for the status pill when its contributing terminal pane has a `ConversationStatus` available — CLI agent sessions that support rich status (their session status), or Oz agent / ambient agent conversations (their `selected_conversation_status_for_display`). Plain terminals, CLI agents without rich status, and conversations without a known status do not get a prefix.
12. The status the pill reflects is the conversation's current `ConversationStatus` (in progress, success, error, cancelled, blocked).
13. A title line whose underlying source is not a conversation pane — plain terminal commands, code panes, notebooks, workflows, settings, file viewers, etc. — does not render a status icon prefix; the line begins directly with the title text.
14. If two distinct panes contribute the same normalized work label and the dedupe rule keeps only the first-seen one, the status pill shown on that one visible line reflects the first-seen pane's status.
15. The `+ N more` overflow line in the title region never renders a status icon prefix, even if some of the hidden labels would otherwise qualify.
### Working-directory region
16. Each unique working directory gathered for the tab renders on its own line. Directories are not joined with ` • ` or any other separator.
17. Directory lines appear in first-seen order across the tab's visible panes, matching v1's gathering order.
18. Working-directory normalization and dedupe rules from APP-3875 carry over unchanged: trim, collapse internal whitespace, drop empty values, and dedupe exact-equivalent normalized values while preserving the first-seen display text. No "most representative" directory is heuristically chosen.
19. The working-directory region renders at most three directory lines, followed by a `+ N more` overflow line when more unique directories exist.
20. Each directory line uses start-clip truncation when it does not fit, so the trailing path segment stays visible (consistent with how working directories truncate in the focused-session row today).
21. Working-directory lines never render a status icon prefix.
22. A tab with no working-directory data on any visible pane omits the working-directory region entirely.
### Branch region
23. Branch-region behavior is unchanged from APP-3875. Branches are coalesced by repository + branch context, ordered by first-appearance in the tab's visible pane order, and capped at three visible branch lines with a `+ N more` overflow line when more unique branch contexts exist.
24. Diff stats and PR chips continue to render on the right side of each branch line, keyed to the branch context rather than to any specific pane.
25. Two different repositories on the same branch name (e.g. both on `main`) continue to render as separate branch lines.
### Card-level icon and interactions
26. The card's left-side pane-kind icon (a single icon for homogeneous tabs, or a stacked pair of icons for heterogeneous tabs, chosen by pane creation order) is unchanged from APP-3875. The new per-line conversation status icons are scoped to the title region; they do not replace or modify the card's left-side icon.
27. Clicking the Summary card activates the tab and focuses its active pane, unchanged from APP-3875.
28. Per-line status icons, individual title lines, individual directory lines, branch lines, diff stats, and PR chips are all informational. None of them introduce new click targets in v2.
29. Tab-level selection, hover, drag-and-drop, rename, close, and hover-sidecar behavior remain unchanged.
### Mixed and missing data
30. A tab whose visible title lines have no conversation status renders title lines without any status pill prefix; the absence of statuses must not push the title text rightward as if a prefix slot were reserved.
31. A tab whose visible title lines all carry a conversation status renders each line prefixed by its own status pill.
32. A tab whose visible title lines mix lines with and without status renders status pill prefixes only on the lines with status; the lines without status share the same horizontal text start by reserving an empty prefix slot, so titles align vertically when at least one visible line in the region has a prefix.
33. A tab with exactly one work label, one working directory, and one branch context still renders three single-line regions in the documented order; nothing collapses to a one-line layout.
34. The card never inserts placeholder copy such as `No branch`, `No directory`, or `No title` for an empty region.
### Search behavior
35. Search / filtering in Summary mode continues to match against the full underlying summary dataset for the tab — every gathered work label, every gathered working directory, every coalesced branch label, every PR label, and every diff-stat text — including values currently hidden behind a `+ N more` overflow line in any region.
### Settings popup
36. The vertical tabs settings popup, including `View as` / `Tab item` structure and the controls hidden by `Tab item = Summary`, is unchanged from APP-3875. This v2 only changes how the Summary card itself renders.
+153
View File
@@ -0,0 +1,153 @@
# APP-4271: Tech Spec — Vertical Tabs Summary v2
## Context
This is a follow-up to APP-3875 that changes how the Tabs / Summary card lays out its content. See `specs/APP-4271/PRODUCT.md` for the user-visible behavior and `specs/APP-3875/PRODUCT.md` / `specs/APP-3875/TECH.md` for the v1 baseline.
The v1 Summary path is fully contained in `app/src/workspace/view/vertical_tabs.rs`, with pure helpers covered by `vertical_tabs_tests.rs`. Most of the work for v2 is replacing the single-line title and working-directory rendering with per-line rendering, threading per-pane status info through the aggregation layer, sorting title lines so conversations come before non-conversation lines, and adding a per-line status icon prefix on conversation lines. No new settings, no new actions, no popup changes.
Key existing code to anchor against:
- `app/src/workspace/view/vertical_tabs.rs (777-790)``VerticalTabsSummaryData`, `VerticalTabsSummaryBranchEntry`. The `primary_labels: Vec<String>` shape is what changes.
- `app/src/workspace/view/vertical_tabs.rs (843-925)` — pure helpers for normalization, dedupe, branch coalescing, search fragments, and primary-label formatting (`format_summary_primary_labels`). The `•`-joining lives here.
- `app/src/workspace/view/vertical_tabs.rs (2638-2754)``build_vertical_tabs_summary_data`, the per-pane aggregation pass that needs to start carrying conversation-source info alongside each label.
- `app/src/workspace/view/vertical_tabs.rs (3494-3590)``render_summary_tab_item`, where the title line currently joins labels and the working-directory line currently joins directories. Branch lines and the `+ N more` overflow already render per-line; v2 mirrors that pattern for titles and directories.
- `app/src/workspace/view/vertical_tabs.rs (2251-2356)``resolve_icon_with_status_variant`, the existing source of truth for "is this a conversation pane, and if so which agent / what status." V2 should reuse this.
- `app/src/ui_components/icon_with_status.rs (29-145)``IconWithStatusVariant` and `render_icon_with_status`. The Oz/CLI agent variants with `status` are exactly what the per-line prefix needs; the only new piece is a smaller `IconWithStatusSizing` tuned for inline use next to 12pt text.
- `app/src/workspace/view/vertical_tabs.rs (109-132, 262-274)` — existing `IconWithStatusSizing` constants (`VERTICAL_TABS_SIZING`, `VERTICAL_TABS_AGENT_SIZING`) and `render_pane_icon_with_status`, which the new inline prefix sizing will sit beside.
- `app/src/workspace/view/vertical_tabs_tests.rs` — pure helper tests; existing patterns for `format_summary_primary_labels`, `coalesce_summary_branch_entries`, and `summary_search_text_fragments` give us the template for new tests.
## Proposed changes
### 1. Upgrade `primary_labels` to carry conversation status
Replace `primary_labels: Vec<String>` on `VerticalTabsSummaryData` with a richer per-label entry:
```rust path=null start=null
#[derive(Clone, Debug, PartialEq)]
struct VerticalTabsSummaryPrimaryLabel {
text: String,
status: Option<ConversationStatus>,
}
```
The v2 prefix is just a status pill (icon + 10%-opacity colored background), not a full agent-icon-with-status composite, so we only need to carry an `Option<ConversationStatus>` per label — no agent (Oz / CLI) discriminator.
`working_directories: Vec<String>` and `branch_entries: Vec<VerticalTabsSummaryBranchEntry>` are unchanged.
### 2. Plumb conversation status through `build_vertical_tabs_summary_data`
In the per-pane loop, tag each candidate primary label with its `Option<ConversationStatus>`:
- For terminal panes, extract a small helper `summary_conversation_status_for_terminal(...)` that returns the same status the focused-session row would show: CLI agent session status when the agent supports rich status, otherwise the Oz / ambient agent's `selected_conversation_status_for_display`. Plain terminals and CLI agents without rich status return `None`.
- For non-terminal pane types, `status: None`.
Replace `push_normalized_unique_summary_text` for the title region with `push_normalized_unique_summary_label(...)` that preserves the first-seen status alongside the first-seen display text. Keep dedupe semantics identical: dedupe by normalized text; if a later pane contributes the same normalized label, drop the duplicate (first-seen wins, matching invariant 14).
After the per-pane loop, run a stable sort `sort_summary_primary_labels_status_first(&mut primary_labels)` (`Vec::sort_by_key` keyed on `label.status.is_none()`) so labels with a known `ConversationStatus` move ahead of labels without one while preserving the first-seen relative order within each group. This satisfies invariant 5 — the visible 3-line cap then naturally prioritizes conversation lines, and any non-conversation lines spill into the `+ N more` overflow first (invariant 7).
The working-directory and branch helpers stay as-is.
### 3. Replace `format_summary_primary_labels` with a per-line API
`format_summary_primary_labels` currently joins labels with `` and appends ` + N more`. Delete it and have `render_summary_tab_item` iterate the entries directly, capping at 3 and emitting a separate `+ N more` line — exactly the pattern branch lines use today.
Working-directory rendering changes the same way: iterate up to 3 entries, then emit `+ N more` if there are extras. Reuse `summary_overflow_count`.
### 4. Render conversation status pill prefix per title line
Reuse the existing status-pill renderer (`render_status_element`) from `app/src/ai/conversation_status_ui.rs`. It produces an icon over a 10%-opacity colored background with rounded corners — exactly the styling shown in the Figma mock and used today on the pane header / detail sidecar status pill.
Define an icon-size constant beside the other vertical-tabs sizing constants:
```rust path=null start=null
const VERTICAL_TABS_SUMMARY_STATUS_ICON_SIZE: f32 = 10.;
```
This pairs with `STATUS_ELEMENT_PADDING` (2px, defined in `conversation_status_ui.rs`) for an overall ~14px element next to a 12pt title.
In the title-region rendering loop:
- For each rendered title line: when `label.status` is `Some`, build a `Flex::row` with `render_status_element(status, VERTICAL_TABS_SUMMARY_STATUS_ICON_SIZE, appearance)` followed by the `Text` element.
- When `label.status` is `None` and at least one visible title line in the card has a status, render a fixed-width spacer (`icon_size + STATUS_ELEMENT_PADDING * 2`) so the text columns align across the region (invariant 32).
- When no visible title line has a status, no slot is reserved — plain text only (invariant 30).
- The `+ N more` overflow line never gets a prefix and never reserves a slot (invariant 15).
With the status-first sort from step 2, all visible status-bearing labels are at the front of the list. The `reserve_prefix_slot = visible_labels.iter().any(|l| l.status.is_some())` check therefore only ever turns on the spacer for non-conversation lines that share the visible region with at least one conversation line.
### 5. Lock region order in `render_summary_tab_item`
Today `render_summary_tab_item` already renders title → working dir → branches in that order. Make this contract explicit: the function takes `summary: &VerticalTabsSummaryData` and emits regions in the documented order, omitting empty regions entirely (invariants 13). No setting affects ordering.
The existing `render_title_override` short-circuit (when the user has set a custom tab title) should keep rendering the override as a single line above any other content, with no status icon prefix and no overflow line — custom titles aren't part of the work-label set.
### 6. Update summary search fragments
`summary_search_text_fragments` (vertical_tabs.rs 905-925) currently calls `summary.primary_labels.iter().cloned()`. With the new type, change it to `summary.primary_labels.iter().map(|entry| entry.text.clone())`. Search behavior stays unchanged — the conversation source is not searchable (invariant 35 covers labels, directories, branches, PR labels, and diff text, not status icons).
### 7. Tests
Extend `vertical_tabs_tests.rs` with pure helper coverage for the new behavior. Existing tests like `coalesce_summary_branch_entries_groups_by_repo_and_branch` and `summary_search_fragments_include_hidden_overflow_values` are the right templates.
New tests:
- `primary_labels_dedupe_preserves_first_seen_status` — given two panes that contribute the same normalized label where only the second has a status, the kept entry has `status: None` (first-seen wins).
- `primary_labels_preserve_status_through_aggregation` — `ConversationStatus` values round-trip through the aggregation pass intact.
- `sort_summary_primary_labels_moves_status_first_and_preserves_order` — a mixed input list interleaving status-bearing and non-status labels sorts to all status-bearing labels first (in first-seen order) followed by all non-status labels (in first-seen order).
- `summary_search_fragments_use_label_text_only` — `summary_search_text_fragments` returns the label text and ignores the status.
- `summary_overflow_count_caps_visible_region` — `summary_overflow_count` reports the remainder past a 3-line cap.
- Update existing assertions that reference `primary_labels: vec!["..."]` to construct `VerticalTabsSummaryPrimaryLabel { text, status: None }` via a small `fn label(text)` test helper (mechanical).
The render path itself is exercised manually — there is no element-tree snapshot harness for this view today, and adding one is out of scope.
### 8. Manual / UI validation
Mapped to PRODUCT.md invariants. Each row covers one or more invariants:
- Region order (13): open a tab with all three region kinds; confirm titles → directories → branches and that omitting a region collapses cleanly (e.g. a notebook-only tab with no terminals shows only a title line).
- Per-line titles + dedupe (46, 9): create a tab with multiple distinct work labels; confirm each renders on its own line. Add a duplicate normalized label (` cargo test ` and `cargo test`) and confirm only one line.
- Title overflow (78): create >3 unique labels; confirm exactly 3 visible lines plus `+ N more` and end-ellipsis on long single lines.
- Status icon prefix (1013): create a tab with a CLI agent, an Oz conversation, and a plain terminal command; confirm only the conversation lines have a status icon, status reflects current state, and the icon styling matches the Figma mock.
- Status-first sort (5, 7): create a tab where the first-created pane is a plain terminal and a later pane is an Oz conversation; confirm the conversation line still renders before the terminal line in the title region. Add enough non-conversation labels that some would normally be cut off; confirm the `+ N more` overflow includes the non-conversation labels first while the conversation labels remain visible.
- Prefix dedupe (14): two panes contribute the same conversation title with different statuses; the visible line shows the first-seen status.
- Overflow has no prefix (15): >3 conversation labels; confirm the `+ N more` line has no icon.
- Per-line directories (1622): multi-directory tab renders each directory on its own line, deduped, capped at 3, with `+ N more`. Empty directory tab omits the region.
- Branches unchanged (2325): re-run the v1 branch validation steps from APP-3875.
- Card icon and click (2629): card-level pane-kind icon unchanged; clicking the card focuses the active pane.
- Mixed/missing data (3034): tab with only non-conversation labels has no prefix slot; mixed tab has aligned text columns; single-pane tab still renders three single-line regions.
- Search (35): search for a hidden-overflow title, hidden-overflow directory, and hidden-overflow branch — all match.
- Settings popup (36): `View as = Tabs` + `Tab item = Summary` continues to hide `Density`, `Pane title as`, `Additional metadata`, `Show`; switching back restores them.
Run `./script/presubmit` (cargo fmt + clippy + tests) before opening the PR.
## Risks and mitigations
### Risk: Per-region prefix slot causes inconsistent alignment
If the prefix slot is reserved on some cards but not others, the eye sees subtle misalignment when scrolling the panel.
Mitigation: reserve the slot per-region (per card), not globally per panel. Within one card, all visible title lines share the same left edge for text. Across cards, alignment may differ — that's fine and matches how branch-line right-side badges already behave.
### Risk: Sort obscures pane creation order in the title region
Users may expect title lines to appear in pane creation order (matching the v1 first-seen order). Promoting conversation lines above plain ones changes that.
Mitigation: invariant 5 documents the new ordering explicitly (status-first, then first-seen within each group). The change is intentional — status-bearing lines are the most actionable and the visible cap of 3 needs to favor them. The card-level pane-kind icon, working-directory region, and branch region all keep their existing first-seen / coalesced ordering, so pane creation order is still discoverable for non-title metadata.
### Risk: Status changes do not trigger a re-render
The summary card relies on the existing vertical-tabs render cycle. CLI agent session status and Oz conversation status changes already drive re-renders for the focused-session row; verify the summary aggregation runs on the same notify path.
Mitigation: `build_vertical_tabs_summary_data` runs inside the existing render pass over `pane_group.visible_pane_ids()`. As long as the same `app.notify()` triggers fire (CLI agent session updates, conversation status updates), Summary mode picks them up. Add this to the manual validation pass: start a CLI agent in a Summary-mode tab and confirm the prefix icon transitions through running → idle.
### Risk: Mechanical test fallout from the `primary_labels` type change
Every existing test that constructs `VerticalTabsSummaryData` literal needs updating.
Mitigation: this is intentionally mechanical. Group the updates into one commit so reviewers can verify it's a pure type lift. A small `fn label(text: &str) -> VerticalTabsSummaryPrimaryLabel` test helper keeps the existing assertions compact.
## Follow-ups
- Snapshot or harness-based tests for `render_summary_tab_item` once the surrounding code grows enough to justify the harness — currently we'd need to mock `AppContext`, `Theme`, and `Appearance`.
- If product later wants per-line click targets (e.g. clicking a conversation title to jump to that pane), the per-line `VerticalTabsSummaryPrimaryLabel` is already the right place to carry a `PaneId`; thread that through then.
- Consolidate `summary_conversation_status_for_terminal` with `resolve_icon_with_status_variant` if both keep growing — for now they share a small helper but render through different paths (status pill vs. icon-with-status composite).
+118
View File
@@ -0,0 +1,118 @@
# Refactor AmbientAgentViewModel to be cloud-agent scoped
Linear: APP-4274
## Context
This refactor simplifies the relationship between `AmbientAgentViewModel`, `AgentViewController`, and cloud-mode terminal views.
Before the change, `AmbientAgentViewModel` existed more broadly than its actual responsibility. It represented cloud-agent-specific state, but it could also be present in local/non-cloud agent flows, which made `TerminalView`, `Input`, and `AgentViewController` look like they all owned parallel pieces of agent state. This made it unclear which abstraction was the source of truth for local Agent View state, cloud-agent lifecycle state, root vs nested cloud-mode navigation state, and cloud-only UI like harness selection and ambient progress.
The final model is:
- `AgentViewController` owns local/fullscreen/inline Agent View conversation state.
- `AmbientAgentViewModel` owns cloud-agent conversation/session state only.
- `TerminalView` optionally has an `AmbientAgentViewModel` only when the terminal represents cloud mode.
- Root vs nested cloud mode is derived from `PaneStack`, not stored as parallel navigation state.
Relevant files:
- `app/src/terminal/view.rs:2849``TerminalView::is_nested_cloud_mode`
- `app/src/terminal/view.rs:2970``TerminalView::new(..., is_cloud_mode: bool, ...)`
- `app/src/terminal/input.rs:1660``Input.ambient_agent_view_state`
- `app/src/terminal/input.rs:1669` — private `AmbientAgentViewState`
- `app/src/terminal/input.rs:2176` — construction of `AmbientAgentViewState`
- `app/src/terminal/view/pane_impl.rs:939``TerminalView::is_ambient_agent_session`
- `app/src/terminal/view/ambient_agent/model.rs:77``AmbientAgentViewModel`
- `app/src/terminal/view_test.rs:338` — root/nested cloud-mode keymap regression test
## Goals
Make `AmbientAgentViewModel` cloud-scoped, avoid constructing dummy ambient models for local/non-cloud Agent View flows, keep cloud-agent state attached to the terminal/view entry for the cloud conversation, remove denormalized cloud navigation state from `TerminalView`, make the schema encode invariants around cloud-only UI state, and preserve cloud setup, composing, spawning, shared-session viewer, progress, and cancellation flows.
## Non-goals
This does not redesign `AgentViewController`, change local Agent View behavior except by removing ambient-model coupling, change cloud-agent server APIs or task spawning semantics, or change `PaneStack` ownership semantics.
## Proposed changes
### `AmbientAgentViewModel` is optional on `TerminalView`
`TerminalView::new` takes `is_cloud_mode: bool` and constructs an ambient model only when that flag is true. Cloud-mode terminals get `Some(ModelHandle<AmbientAgentViewModel>)`; normal/local terminals get `None`.
This makes the presence of `AmbientAgentViewModel` meaningful: if it exists, the terminal is capable of cloud-agent UI and lifecycle behavior.
### `AgentViewController` no longer carries ambient model state
`AgentViewController` remains responsible for Agent View entry/exit/conversation display state. Cloud-agent-specific progress, setup, harness, task, environment, and cancellation state lives in `AmbientAgentViewModel`.
This removes the previous parallel-state ambiguity where local Agent View and ambient-agent state appeared coupled even when local flows did not need ambient state.
### `AmbientAgentViewModel` represents only cloud-agent lifecycle state
`AmbientAgentViewModel` starts in a cloud-relevant state (`Composing`) and tracks cloud-only concepts:
- setup/composing/waiting/running/failure/cancelled status
- selected cloud environment
- selected harness
- spawned task ID
- cloud-agent request
- progress timing
- setup command state
Because non-cloud terminals do not construct this model, the model no longer needs a “not ambient agent” status variant or parent-terminal bookkeeping.
### `Input` groups ambient-only UI state
`Input` now stores `ambient_agent_view_state: Option<AmbientAgentViewState>` instead of independent optional ambient-model and harness-selector fields.
`AmbientAgentViewState` contains:
- `view_model: ModelHandle<AmbientAgentViewModel>`
- `harness_selector: ViewHandle<HarnessSelector>`
The grouped state is constructed only when `ambient_agent_view_model` exists. This encodes the invariant directly: no ambient model means no harness selector, and if a harness selector exists then an ambient model exists.
Accessors on `Input` preserve existing call-site ergonomics:
- `ambient_agent_view_model()`
- `harness_selector()`
### Cloud navigation is derived from `PaneStack`
The refactor removes `CloudAgentNavigation` and `TerminalView.cloud_agent_navigation`.
`TerminalView::is_nested_cloud_mode` now checks:
1. The terminal is an ambient/cloud session.
2. The terminal has an owning `PaneStack`.
3. The terminals view appears in the stack.
4. The terminal is not the first entry.
This makes `PaneStack` the source of truth:
- first stack entry = root cloud-mode pane
- later stack entries = nested cloud-mode panes
- no stack = not nested
This avoids stale cached navigation state and handles root/nested state consistently after push/pop. The important subtlety is that stack depth alone is insufficient: a root pane in a stack with depth greater than one is still root. The implementation checks the terminals actual position in the stack instead.
### Root cloud-mode keymap context uses derived state
The root cloud-mode key is set only when the terminal is an ambient/cloud session and is not nested according to `PaneStack`.
This preserves the intended behavior for bindings like setting input mode to Agent Mode: root cloud-mode panes should not accidentally enter local Agent View.
### Pane chrome and cloud-mode entry use derived nested state
The pane header and cloud-agent entry paths now consult `is_nested_cloud_mode` instead of cached navigation state.
Important behavior:
- nested cloud-mode panes show parent/back navigation where appropriate
- starting cloud mode from a nested cloud-mode pane can pop back to the parent and start a sibling run
- root cloud-mode panes remain distinguishable from nested panes even when the stack depth is greater than one
## End-to-end flow
### Local/non-cloud Agent View
1. `TerminalView::new(..., is_cloud_mode: false, ...)`
2. `ambient_agent_view_model = None`
3. `Input.ambient_agent_view_state = None`
4. `AgentViewController` handles Agent View state
5. Cloud-only UI/state is absent
### Cloud-mode terminal
1. `TerminalView::new(..., is_cloud_mode: true, ...)`
2. `AmbientAgentViewModel` is constructed for that terminal view
3. `Input` creates `AmbientAgentViewState`
4. Cloud setup/composing/progress/cancellation use the ambient model
5. Root vs nested navigation is derived from `PaneStack`
### Nested cloud-mode pane
1. A cloud-mode terminal is pushed onto a `PaneStack`
2. `PaneStack::push` calls `TerminalView::set_pane_stack`
3. `is_nested_cloud_mode` finds the terminals index in `PaneStack::entries`
4. Index `> 0` means nested
5. Keymap/chrome/cloud-entry behavior follows from that derived state
## Review comment resolutions
### Group `AmbientAgentViewModel` and `HarnessSelector`
Resolved by introducing private `AmbientAgentViewState` in `Input`.
This prevents impossible/ambiguous optional-state combinations and makes the schema match the real invariant.
### Remove denormalized cloud navigation
Resolved by removing `CloudAgentNavigation` and `TerminalView.cloud_agent_navigation`.
Root/nested state now comes from the owning `PaneStack`.
## Testing and validation
Focused validation passed:
- `cargo test -p warp root_cloud_mode_pane_sets_root_cloud_mode_context_key --features local_tty,local_fs`
- `cargo test -p warp set_input_mode_agent_does_not_enter_local_agent_from_root_cloud_mode_pane --features local_tty,local_fs`
- `cargo check -p warp --features local_tty,local_fs`
The key regression test is `root_cloud_mode_pane_sets_root_cloud_mode_context_key` in `app/src/terminal/view_test.rs:338`.
It verifies:
1. A standalone cloud-mode terminal gets `ROOT_CLOUD_MODE_PANE_KEY`.
2. After creating a `PaneStack`, the root terminal still gets the key.
3. A pushed nested cloud-mode terminal does not get the key.
The test keeps the `PaneStack` handle alive through assertions so `TerminalView`s weak stack handle can upgrade during keymap evaluation.
## Risks and mitigations
### Optional ambient model requires many call sites to handle `None`
Call sites use optional accessors or only pass the model into cloud-capable components when present. This makes non-cloud behavior explicit rather than hidden behind dummy state.
### Deriving nested state from `PaneStack` could misclassify panes
The helper checks stack membership and entry index, not only stack depth. This keeps root panes root even when they have nested children.
### Cloud shared-session viewers still need ambient state
Shared-session viewer construction passes `is_cloud_mode` through to `TerminalView::new`, so cloud-mode shared session viewers still construct `AmbientAgentViewModel`.
## Follow-ups
- Cloud UI verification requires pushing the branch so a cloud agent can build/test the changed client state.
- If more cloud-only UI state is added to `Input`, it should live under `AmbientAgentViewState` rather than as independent optional fields.
- If more root/nested cloud behavior appears, it should continue to derive from `PaneStack` rather than reintroducing cached navigation state.
+72
View File
@@ -0,0 +1,72 @@
# APP-4281: SSH into hosts with unsupported glibc
Linear: APP-4281
## Summary
When a user SSHes into a Linux host whose glibc (or libc family) is too old to run Warp's prebuilt remote-server binary, Warp must avoid offering or attempting an install that would never succeed. The user lands in the legacy SSH experience without seeing any error banner, modal, or install prompt — the SSH session feels indistinguishable from a normal SSH into a host where remote-server features simply aren't enabled.
## Figma
Figma: none provided. This feature is primarily about *suppressing* UI surfaces; there are no new visual states to design.
## Problem
The prebuilt Linux `oz` binary that powers Warp's remote-server SSH integration requires a recent glibc. When it lands on a host with an older glibc (RHEL/CentOS 7/8, Amazon Linux 2, Ubuntu 18.04, Debian 10, etc.) or a non-glibc libc (Alpine/musl, Termux/bionic), the dynamic loader refuses to launch it with errors like:
```
/lib64/libm.so.6: version `GLIBC_2.29' not found
```
Today, the user sees the install prompt, the install "succeeds," and the failure surfaces only when Warp tries to spawn the proxy — at which point the user sees a generic `SetupFailed` state. Worse, the failed install is left on disk, so every subsequent SSH session repeats the same failed cycle.
## Behavior
### Pre-detection: ideal path (host's libc is positively detected as unsupported)
1. When the user SSHes into a Linux host and Warp can positively detect that the host's libc is unsupported (glibc below the supported floor, or a non-glibc libc such as musl or bionic), Warp does **not** present the "Install Warp SSH Extension" choice block, regardless of the user's `SshExtensionInstallMode` setting (`AlwaysAsk`, `AlwaysInstall`, `NeverInstall`).
2. On an unsupported-libc host, Warp does **not** invoke the install script, does **not** download the binary, and does **not** attempt to launch the remote-server proxy.
3. On an unsupported-libc host, the SSH session falls back to the legacy SSH flow (the same flow used today when the user has chosen `NeverInstall`, or when the install is skipped). The user gets a working shell with normal command execution; remote-server-specific features (e.g. richer completions, repo metadata) are simply absent for that session.
4. While Warp is determining whether the host is supported, the prompt area shows the same loading state it shows today during the binary-check phase ("Starting shell..." / "Checking..."). When the determination completes and the host is unsupported, the loading state ends and the legacy SSH prompt appears with no error banner, no failure block, and no modal.
5. If the host has an existing remote-server binary on disk from a previous (now-incompatible) install, Warp removes that stale binary as part of the fall-back so the host does not accumulate unusable files. The cleanup is silent — the user is not asked to confirm and is not informed if it fails.
6. The fall-back is sticky for the duration of the SSH session: once Warp has decided the host is unsupported, it does not retry the install, does not show the choice block, and does not show a failure banner mid-session.
7. Every subsequent SSH into the same host repeats the same detection and reaches the same conclusion. The user is never re-prompted with the install choice block on a host known to be unsupported. If the host is later upgraded so that its libc becomes supported, the next SSH detects the change and the normal install/auto-update flow resumes — there is no client-side cache that would prevent recovery.
### Fallback path: install or launch fails despite supported detection
8. If pre-detection cannot positively classify the host (the libc probe didn't run, returned unparseable output, or otherwise failed), Warp proceeds with today's behavior: it offers or runs the install according to the user's `SshExtensionInstallMode` setting.
9. If the install itself fails, or the install succeeds but the remote-server proxy fails to launch (for example, because the binary's loader rejects the host's glibc), Warp must not leave the user stranded. The SSH session falls back to the legacy SSH flow and the user gets a working shell.
10. In the fallback-after-failure path, Warp may surface a single, dismissible failure banner explaining that the SSH extension could not be installed/launched on this host (consistent with today's `SshRemoteServerFailedBanner`). It must **not** loop on the failure: the user does not see a new banner, modal, or install prompt for the same host on subsequent SSH sessions in the same Warp run.
11. If a previous install attempt left an incompatible binary on disk, Warp cleans it up before falling back so the next SSH does not silently re-enter the auto-update / re-fail loop described in the Problem section.
### Cross-cutting invariants
12. macOS remote hosts are unaffected. Warp does not run a libc probe against macOS hosts and does not change any existing macOS SSH behavior.
13. Hosts with supported glibc are unaffected. The install prompt, auto-update, loading footer, and connect flow behave exactly as they do today.
14. The user's `SshExtensionInstallMode` setting is honored:
- `AlwaysAsk`: the choice block is shown only when the host is supported (or when pre-detection was inconclusive). It is never shown on a host known to be unsupported.
- `AlwaysInstall`: install runs only when the host is supported (or pre-detection was inconclusive). On a known-unsupported host, install is silently skipped and the session falls back to legacy SSH.
- `NeverInstall`: behavior is unchanged — Warp falls back to legacy SSH regardless of host support.
15. Detection latency must be small enough that the user does not perceive an additional delay before the legacy SSH prompt appears on an unsupported host. The probe runs over the existing SSH connection and adds at most a single round-trip on Linux hosts.
16. SSH-level failures during detection (timeout, broken pipe, permission denied) do not block the SSH session. If detection cannot run, Warp treats the result as inconclusive and follows invariant 8.
17. Nothing about an unsupported-libc fall-back is presented as an error to the user. The legacy SSH session that results is a normal, working shell; the absence of remote-server features is the only observable difference, and it matches the experience the user already has today on hosts where they chose not to install the extension.
18. Detection and fall-back state is per-host, not global. Encountering one unsupported host does not change behavior for any other host the user SSHes into in the same Warp session.
## Open questions
- **User awareness:** is it acceptable that the user has *no* signal that Warp's SSH integration is intentionally inactive on this host? Some users may wonder why richer completions are missing. A future iteration may surface a one-time, dismissible explanation in the SSH choice area, but the default for this spec is silent fall-back.
+430
View File
@@ -0,0 +1,430 @@
# APP-4281: Gate remote-server install on a remote-side preinstall check
Linear: APP-4281
## Context
The prebuilt Linux `oz` CLI is built on the `namespace-profile-ubuntu-20-04` runner (`.github/workflows/create_release.yml:513`, `:668`, `:839`). That toolchain links against glibc 2.31, so the resulting binary carries glibc 2.29-era symbol versions in its dynamic table. When the install script (`crates/remote_server/src/install_remote_server.sh`) drops that binary onto a Linux host whose runtime glibc is older than ~2.29, the dynamic loader refuses to launch it:
```
/lib64/libm.so.6: version `GLIBC_2.29' not found (required by /home/wasp-dev/.warp-preview/remote-server/oz-preview)
```
This affects long-lived enterprise distros — RHEL/CentOS 7 (glibc 2.17), RHEL/CentOS 8 (glibc 2.28), Amazon Linux 2 (glibc 2.26), Ubuntu 18.04 (glibc 2.27), Debian 10 (glibc 2.28) — as well as non-glibc systems like Alpine (musl) and Termux (bionic).
Today the setup pipeline does not consult the remote host's capabilities. `RemoteServerController::on_binary_check_complete` (`app/src/terminal/writeable_pty/remote_server_controller.rs:202`) decides between install, auto-update, prompt, and fall-back purely from `Result<bool, String>` (binary present?) and `has_old_binary`. Once `install_binary` succeeds the controller advances to `connect_session`, the SSH proxy spawns `oz remote-server-proxy`, and the loader's `GLIBC_…` error surfaces only at connect time as an opaque `SetupFailed`. The product spec (`specs/APP-4281/PRODUCT.md`) calls for surfacing **no** install UI in that case — the user should land directly in the legacy SSH flow.
The legacy SSH/`RemoteCommandExecutor` flow is already a first-class outcome of the controller's state machine: it is reached today via `SshExtensionInstallMode::NeverInstall` and via the `Err(_)` arm of `on_binary_check_complete` (`remote_server_controller.rs:281`, `:286`), both of which call `flush_stashed_bootstrap` to release the stashed bootstrap so `Sessions::initialize_bootstrapped_session` wires up the ControlMaster-backed `RemoteCommandExecutor`. We reuse that path for unsupported hosts.
## Goals
Run a single **preinstall check script** over the existing SSH connection — before any install UI surfaces — that decides whether the host can run the prebuilt remote-server binary, and gate every user-visible install affordance (choice block, `AlwaysInstall` auto-install, `has_old_binary` auto-update) on its result. When the host is positively unsupported, fall back silently to the legacy SSH flow. When the check is inconclusive, fail open and proceed as today.
Make the script the single source of truth for "can this host run the binary?" so future capability checks (additional shared libs, kernel version, free disk, presence of `curl`/`tar`) are an additive, script-only change.
## Non-goals
This spec does not lower the glibc floor of the prebuilt binary, ship multiple Linux artifacts targeting different glibc versions, or expose user-visible UI explaining the fall-back. Those are tracked as follow-ups.
## Relevant code
- `crates/remote_server/src/setup.rs``RemoteServerSetupState`, `RemotePlatform`/`RemoteOs`/`RemoteArch`, `parse_uname_output` (`:94`), `binary_check_command`, `install_script`, `CHECK_TIMEOUT`.
- `crates/remote_server/src/install_remote_server.sh` — existing install script template; the new preinstall script lives next to it.
- `crates/remote_server/src/transport.rs``RemoteTransport` trait (`:63`).
- `crates/remote_server/src/manager.rs``RemoteServerManager::check_binary` (`:452`), `RemoteServerManagerEvent::BinaryCheckComplete` (`:311`), session state machine.
- `crates/remote_server/src/ssh.rs``run_ssh_command`, `run_ssh_script` (used by `install_binary`; reused for the preinstall check).
- `app/src/remote_server/ssh_transport.rs``SshTransport` impl of `RemoteTransport`.
- `app/src/terminal/writeable_pty/remote_server_controller.rs``SshInitState`, `on_binary_check_complete` (`:202`), `flush_stashed_bootstrap` (`:150`).
- `app/src/terminal/prompt_render_helper.rs:248` and `app/src/terminal/view.rs:11491` — UI surfaces that match on `RemoteServerSetupState`.
## Proposed changes
### 1. New `preinstall_check.sh`
A new shell script template alongside `install_remote_server.sh`. It runs **before** any install UI surfaces and emits a structured, machine-parseable summary on stdout. The script is the only place we encode "can this host run the prebuilt binary?" — the client side just reads the verdict.
Output format: one `key=value` pair per line. Unknown keys are ignored on the client, so the script can grow new checks without a coordinated client release. Required keys for v1:
```
status=supported|unsupported|unknown
reason=<short identifier when unsupported, omitted otherwise>
libc_family=glibc|musl|bionic|uclibc|unknown
libc_version=<major.minor when libc_family=glibc, omitted otherwise>
required_glibc=<major.minor>
```
Script (lives at `crates/remote_server/src/preinstall_check.sh`):
```sh
#!/usr/bin/env bash
# Preinstall check for the Warp remote-server binary.
#
# Emits a structured key=value summary on stdout. Exits 0 on success.
# A non-zero exit indicates a probe-level failure; the client treats
# those as `status=unknown` (fail open).
set -u
# The minimum glibc the prebuilt Linux CLI requires. The Linux CLI is
# built on Ubuntu 20.04 (see `.github/workflows/create_release.yml`),
# which ships glibc 2.31. Bump this when the runner image is bumped.
required_glibc="2.31"
echo "required_glibc=${required_glibc}"
# 1. Detect libc family and (when glibc) its version.
libc_family="unknown"
libc_version=""
if version=$(getconf GNU_LIBC_VERSION 2>/dev/null); then
# Output: "glibc 2.31"
libc_family="glibc"
libc_version="${version##* }"
elif ldd_out=$(ldd --version 2>&1 | head -n1); then
case "$ldd_out" in
*musl*) libc_family="musl" ;;
*uClibc*) libc_family="uclibc" ;;
*)
v=$(printf '%s\n' "$ldd_out" | grep -oE '[0-9]+\.[0-9]+' | head -n1)
if [ -n "$v" ]; then
libc_family="glibc"
libc_version="$v"
fi
;;
esac
fi
echo "libc_family=${libc_family}"
[ -n "$libc_version" ] && echo "libc_version=${libc_version}"
# 2. Decide status from the gathered facts.
status="unknown"
reason=""
if [ "$libc_family" = "glibc" ] && [ -n "$libc_version" ]; then
have_major="${libc_version%%.*}"
have_minor="${libc_version#*.}"
have_minor="${have_minor%%.*}"
req_major="${required_glibc%%.*}"
req_minor="${required_glibc#*.}"
if [ "$have_major" -gt "$req_major" ] \
|| { [ "$have_major" -eq "$req_major" ] && [ "$have_minor" -ge "$req_minor" ]; }; then
status="supported"
else
status="unsupported"
reason="glibc_too_old"
fi
elif [ "$libc_family" = "musl" ] || [ "$libc_family" = "bionic" ] || [ "$libc_family" = "uclibc" ]; then
status="unsupported"
reason="non_glibc"
fi
echo "status=${status}"
[ -n "$reason" ] && echo "reason=${reason}"
```
The script is loaded with `include_str!` — no templating, since the floor is hardcoded into the script itself for now:
```rust
pub const PREINSTALL_CHECK_SCRIPT: &str = include_str!("preinstall_check.sh");
```
Keeping the floor inside the script keeps the bash side self-contained and avoids splitting the supported-glibc value across two source files. If we later need to template the script (e.g. to inject the artifact's actual symbol-version floor at release time), we can switch back to a `replace` helper without touching the trait or controller. The `required_glibc` value still rides on the script's stdout (`required_glibc=2.31`) so the Rust parser can populate `UnsupportedReason::GlibcTooOld { required }` directly from the script's report rather than a separate Rust constant.
### 2. `PreinstallCheckResult` and parser
Add to `setup.rs`:
```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreinstallCheckResult {
pub status: PreinstallStatus,
pub libc: RemoteLibc,
/// Verbatim script stdout (trimmed). Forwarded to telemetry for
/// diagnostics on hosts that report `Unknown`.
pub raw: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PreinstallStatus {
Supported,
Unsupported { reason: UnsupportedReason },
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UnsupportedReason {
GlibcTooOld { detected: (u32, u32), required: (u32, u32) },
NonGlibc { name: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteLibc {
Glibc { major: u32, minor: u32 },
NonGlibc { name: String },
Unknown,
}
impl PreinstallCheckResult {
pub fn is_supported(&self) -> bool {
match self.status {
// Fail open on Unknown — see §3.
PreinstallStatus::Supported | PreinstallStatus::Unknown => true,
PreinstallStatus::Unsupported { .. } => false,
}
}
}
pub fn parse_preinstall_output(stdout: &str) -> PreinstallCheckResult { /**/ }
```
Parser rules:
- Treat stdout as a list of `key=value` lines; lines without `=` and unknown keys are ignored (forward-compatibility).
- `status=supported``PreinstallStatus::Supported`.
- `status=unsupported` + `reason=glibc_too_old``Unsupported { GlibcTooOld { detected, required } }` populated from `libc_version` and `required_glibc`. Missing/malformed numbers → `PreinstallStatus::Unknown`.
- `status=unsupported` + `reason=non_glibc``Unsupported { NonGlibc { name: libc_family } }`.
- Any other / missing `status``PreinstallStatus::Unknown`.
- `libc_family` + `libc_version` populate the `libc` field independently of `status`, so telemetry has the underlying signal even on `Unknown`.
### 3. Fail-open semantics
`is_supported()` returns true for both `Supported` and `Unknown`. Hosts where the script could not classify the libc (no `getconf`, weird `ldd` output, exotic distro, busybox-only environments) keep today's install-and-try behavior. Only positive detection of an incompatible libc — a glibc version below the script's hardcoded floor, or any non-glibc libc — triggers the silent fall-back.
The supported-glibc floor lives in `preinstall_check.sh` itself (currently `required_glibc="2.31"`, matching the Ubuntu 20.04 build runner). Bumping the build image is a script-only change. The Rust side does not duplicate the value; it reads `required_glibc` back out of the script's stdout when constructing telemetry and `UnsupportedReason::GlibcTooOld { required }`.
### 4. `RemoteTransport::run_preinstall_check`
Extend `RemoteTransport` with a single new method (matching the boxed-future style of the existing probes — see `transport.rs:68-96`):
```rust
fn run_preinstall_check(
&self,
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, String>> + Send>>;
```
`SshTransport::run_preinstall_check` pipes `setup::PREINSTALL_CHECK_SCRIPT` through the existing ControlMaster socket via `remote_server::ssh::run_ssh_script` (the same helper `install_binary` already uses), with `CHECK_TIMEOUT`. On success it parses stdout into `PreinstallCheckResult` via `parse_preinstall_output`. On SSH-level failure (timeout, broken pipe, non-zero exit with no parseable summary) it returns `Err(_)`, which the manager logs and treats as inconclusive (the controller then falls into the existing fail-open path).
### 5. Run before deciding install UI
`RemoteServerManager::check_binary` (`manager.rs:452`) keeps the existing `futures::join!` over the three concurrent probes and adds the preinstall script call afterwards, gated on Linux:
```rust
let (platform_result, check_result, old_binary_result) = futures::join!(
transport.detect_platform(),
transport.check_binary(),
transport.check_has_old_binary(),
);
let preinstall = match &platform_result {
Ok(p) if matches!(p.os, RemoteOs::Linux) => match transport.run_preinstall_check().await {
Ok(r) => Some(r),
Err(e) => {
log::warn!("preinstall check failed for session {session_id:?}: {e}");
None
}
},
_ => None,
};
```
The result rides on `BinaryCheckComplete`:
```rust
BinaryCheckComplete {
session_id: SessionId,
result: Result<bool, String>,
remote_platform: Option<RemotePlatform>,
preinstall_check: Option<PreinstallCheckResult>,
has_old_binary: bool,
}
```
`RemoteServerManager` also caches the preinstall result per-session (mirroring `session_platforms` at `manager.rs:414`) so it is available on later events for telemetry.
Sequencing the preinstall check after `detect_platform` (instead of folding it into the `join!`) keeps macOS hosts at zero extra round-trips. Linux hosts pay one additional ControlMaster channel — same cost class as `check_has_old_binary`, multiplexed through the existing socket.
### 6. Controller: gate the install UI on the preinstall result
This is the user-visible point of the change. `RemoteServerController::on_binary_check_complete` (`remote_server_controller.rs:202`) now performs the preinstall gate **before** any of the existing branches that surface the choice block (`AlwaysAsk``request_remote_server_block`), force install (`AlwaysInstall`), auto-update (`has_old_binary`), or connect:
```rust
if let Some(check) = preinstall_check.as_ref() {
if !check.is_supported() {
log::info!(
"Preinstall check returned {:?} for {session_id:?}; \
falling back to legacy SSH",
check.status,
);
send_telemetry_from_ctx!(
TelemetryEvent::RemoteServerHostUnsupported { /**/ },
ctx,
);
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.mark_setup_unsupported(session_id, check.clone(), ctx);
});
// Best-effort cleanup of any prior install on this host —
// it cannot launch and would otherwise force the auto-update
// path on every reconnect.
if let Ok(true) = result {
mgr.schedule_remove_remote_server_binary(session_id, &transport, ctx);
}
self.flush_stashed_bootstrap(session_info, ctx);
return;
}
}
// Existing branches unchanged below this point:
// Ok(true) -> connect_session
// Ok(false) + has_old_binary -> install_binary(is_update=true)
// Ok(false) + AlwaysAsk -> request_remote_server_block
// Ok(false) + AlwaysInstall -> install_binary(is_update=false)
// Ok(false) + NeverInstall -> flush_stashed_bootstrap
// Err(_) -> flush_stashed_bootstrap
```
Because this branch runs **before** `request_remote_server_block`, an unsupported host never sees the choice block — exactly what the product spec requires for the "ideal path." The legacy SSH flow is reached via the existing `flush_stashed_bootstrap` exit, so no new fall-back code path is added — only a new entry point into the existing one.
### 7. New `Unsupported` setup state
Extend `RemoteServerSetupState` (`setup.rs:8`) with a non-error terminal variant so the controller can distinguish "the remote is incompatible, fall back silently" from "the install failed, surface a real error":
```rust
pub enum RemoteServerSetupState {
Checking,
Installing { progress_percent: Option<u8> },
Updating,
Initializing,
Ready,
Failed { error: String },
/// Preinstall check classified the host as incompatible. Treated
/// as a clean fall-back to the legacy ControlMaster-backed SSH flow.
Unsupported { reason: UnsupportedReason },
}
```
`is_terminal()` and `is_in_progress()` are extended so `Unsupported` behaves like `Failed` for downstream code that asks "is this still in flight?" The two existing UI sites (`prompt_render_helper.rs:255` and `view.rs:11492`) already fall through to a `_ =>` arm and need no changes; the user sees the same prompt they see on a `NeverInstall` legacy SSH session today.
### 8. Stale-install cleanup
Hosts that connected before this change may have an `oz` binary on disk that can no longer launch. When the controller takes the unsupported branch with `result == Ok(true)`, it asks the manager to schedule a best-effort `transport.remove_remote_server_binary()` (already implemented at `ssh_transport.rs:235`). The call is fire-and-forget: failure is logged and does not block the legacy fall-back. Without this, `check_has_old_binary` would return `true` on every reconnect and the controller would silently re-enter the auto-update path against a host that now reports `Unsupported`.
### 9. Telemetry
Replace the previously-proposed `RemoteServerLibcUnsupported` with a script-shaped event:
```rust
TelemetryEvent::RemoteServerHostUnsupported {
remote_os: Option<String>,
remote_arch: Option<String>,
status: String, // "unsupported" | "unknown"
reason: Option<String>, // "glibc_too_old" | "non_glibc"
detected_libc: String, // "glibc 2.28", "musl", "unknown"
required_glibc: String, // "2.31"
had_old_binary: bool,
/// First 256 bytes of the script's stdout, for diagnosing
/// `Unknown` outcomes on exotic distros.
script_stdout_preview: String,
}
```
Also extend the existing `RemoteServerSetupDuration` (sent from `on_session_connected` at `remote_server_controller.rs:372`) with `remote_libc: Option<String>` so we can correlate setup latency with libc distribution on supported hosts and watch for regressions after future `required_glibc` bumps in the script.
## End-to-end flow
### Supported Linux host (e.g. Ubuntu 22.04, glibc 2.35)
1. SSH session opens; `on_ssh_init_shell_requested` schedules `check_binary`.
2. Manager runs `uname -sm`, `test -x …`, `test -d …`, then on Linux pipes `preinstall_check.sh` through the SSH socket.
3. Script emits `status=supported`, `libc_family=glibc`, `libc_version=2.35`.
4. `BinaryCheckComplete { preinstall_check: Some(Supported), … }` arrives at the controller.
5. The fail-open gate is bypassed; existing branches drive install/auto-update/prompt/connect as today.
### Unsupported Linux host (e.g. RHEL 7, glibc 2.17)
1. SSH session opens; `check_binary` runs as above.
2. Script emits `status=unsupported`, `reason=glibc_too_old`, `libc_version=2.17`.
3. `BinaryCheckComplete { preinstall_check: Some(Unsupported { GlibcTooOld { … } }), … }` arrives.
4. Controller logs, emits `RemoteServerHostUnsupported`, asks the manager to mark the session `Unsupported`, optionally schedules `remove_remote_server_binary`, and calls `flush_stashed_bootstrap`.
5. `Sessions::initialize_bootstrapped_session` wires up `RemoteCommandExecutor` against the existing ControlMaster socket. The user sees the legacy SSH prompt with no modal, no banner, no error.
### Inconclusive Linux host (e.g. minimal busybox container)
1. Script emits `status=unknown` (no `getconf`, `ldd` output unparseable).
2. `is_supported()` returns true; existing branches run as today, including the choice block under `AlwaysAsk`. If the install or connect later fails, the existing `Failed` banner path takes over.
### macOS host
1. `detect_platform` returns `RemoteOs::MacOs`; the preinstall check is skipped.
2. `BinaryCheckComplete { preinstall_check: None, … }` arrives.
3. The unsupported branch is bypassed entirely (the early return is gated on `Some(check)`); existing logic applies unchanged.
## Diagram
```mermaid
stateDiagram-v2
[*] --> Checking: on_ssh_init_shell_requested
Checking --> Connecting: binary present + supported (or macOS / Unknown)
Checking --> Installing: binary missing + supported (or macOS / Unknown)
Checking --> Updating: binary missing + has_old_binary + supported
Checking --> Unsupported: preinstall returned Unsupported
Installing --> Connecting: install ok
Updating --> Connecting: update ok
Connecting --> Ready: initialize ok
Unsupported --> [*]: flush_stashed_bootstrap → legacy SSH executor
Installing --> Failed: install error
Connecting --> Failed: connect/initialize error
```
## Testing and validation
### Unit
- Bash-level: a small shell harness (`crates/remote_server/src/preinstall_check_test.sh`) that runs `preinstall_check.sh` against a stubbed `getconf` / `ldd` (`PATH`-injected) for each scenario — Ubuntu 20.04, RHEL 7, RHEL 8, Alpine musl, busybox-no-getconf-no-ldd — and asserts the emitted key/value lines exactly. This catches script regressions independently of the Rust parser.
- `setup_tests.rs`: table-driven tests for `parse_preinstall_output` covering each of the script's golden outputs, plus malformed/partial inputs (missing `status`, unknown reason, garbled `libc_version`).
- `setup_tests.rs`: truth table for `PreinstallCheckResult::is_supported` against `Supported`, `Unsupported { GlibcTooOld { … } }`, `Unsupported { NonGlibc { "musl" } }`, and `Unknown` — the last must be reported as supported per the fail-open rule.
- Manager tests: a mock `RemoteTransport::run_preinstall_check` that returns each variant; assert `BinaryCheckComplete.preinstall_check` carries the value, the per-session cache is populated, and the corresponding setup state is reached.
- Controller tests: drive `on_binary_check_complete` with `Some(Unsupported { … })` and assert `flush_stashed_bootstrap` was called, no `request_remote_server_block`/`install_binary`/`connect_session` was issued, and `RemoteServerHostUnsupported` was emitted. Repeat with `result = Ok(true)` to assert `remove_remote_server_binary` is scheduled.
### Manual
- Ubuntu 22.04 / Debian 12 (glibc 2.35+): unchanged install / auto-update / connect path. Choice block still appears under `AlwaysAsk` for first-time hosts.
- RHEL 7 (glibc 2.17), RHEL 8 (glibc 2.28), Amazon Linux 2 (glibc 2.26), Ubuntu 18.04 (glibc 2.27): SSH lands in the legacy flow with no choice block, modal, or error block; `Warp.log` shows the unsupported-host telemetry line.
- Alpine 3.x (musl): legacy fall-back, telemetry tagged `reason=non_glibc`.
- Busybox-only minimal container: `status=unknown`, choice block still appears under `AlwaysAsk`. Confirm that today's install-then-fail behavior is preserved (regression check for fail-open).
- Host with a pre-existing incompatible binary (simulate by `scp`-ing a Linux binary onto an Alpine VM): legacy fall-back; `ssh <host> 'ls ~/.warp-*/remote-server'` afterwards shows the binary was removed.
- macOS remote: unchanged.
### Presubmit
`./script/presubmit` (cargo fmt, clippy, tests). The bash harness from §Unit runs as part of `cargo test` via a `[[bin]]` test wrapper invoked from `setup_tests.rs`.
## Risks and mitigations
### Script behaves badly on an exotic distro
`status=unknown` falls open and the user keeps today's install-and-try path. The script-level `set -u` plus explicit `case` arms keep the interpreter from emitting partial/garbage output that would confuse the parser; truly unparseable stdout maps to `PreinstallStatus::Unknown` in the parser too.
### Script is the new install bottleneck
The script is templated and shipped in-tree under `crates/remote_server/src/preinstall_check.sh`, exactly like `install_remote_server.sh`. Updates ride the normal release train. Because the parser tolerates unknown keys, we can extend the script without coordinating a client release — only behavior that depends on the new keys requires a client bump.
### Hardcoded `required_glibc` drifts from the build environment
The floor is hardcoded in `preinstall_check.sh` and documented to track the runner image in `.github/workflows/create_release.yml`. Bumping the runner means editing the script in the same PR. Follow-up: derive the value at release time from `objdump -T <oz> | grep GLIBC_ | sort -V | tail -1` and inject it into the script during `script/bundle` so the source of truth is the artifact itself.
### Extra round-trip on every Linux SSH connect
One additional ControlMaster channel multiplexed through the existing socket — same cost class as `check_has_old_binary`, well under the human perceptual threshold. macOS hosts pay nothing because the probe is gated on `RemoteOs::Linux`.
### Users on marginal hosts lose the install prompt entirely
Intentional for the prebuilt artifact: installing a binary that crashes on launch is worse than skipping the install. Follow-up considers shipping a lower-glibc artifact and lowering the script's `required_glibc` per-artifact to broaden the supported set.
### Removing a stale binary fails
Best-effort and logged. The session still falls back to legacy SSH; the worst case is a leftover binary on disk that subsequent runs detect again and skip again.
## Follow-ups
- The preinstall script is now the natural place to add additional host capability checks: CPU instruction set requirements, free disk space in `~/.warp-XXXX/remote-server`, presence of `curl`/`tar`. Each new check is an additive script-only change plus a parser key.
- Derive the script's `required_glibc` from the released binary at bundle time instead of a hardcoded value.
- Ship a second prebuilt Linux CLI built against an older glibc (Ubuntu 18.04 / glibc 2.27) and pick the right artifact based on the script's reported libc version; this is what closes APP-4281's stated goal of "support glibc 2.28."
- Once telemetry sizes the affected population, surface a one-time, dismissible explanation in the SSH choice area on unsupported hosts so users understand they are on the legacy SSH path by design.
+65
View File
@@ -0,0 +1,65 @@
# Inline conversation and history navigation via agent entries
## Context
APP-4313 makes inline conversation surfaces use `AgentConversationsModel` entries instead of local-only `ConversationNavigationData`.
The old source, `ConversationNavigationData`, is local-conversation-centric. It is built from open terminal views, cleared/live local conversations, and local metadata in `app/src/ai/conversation_navigation/mod.rs (18-348)`. It has pane/window navigation fields, but no ambient task identity.
The new source, `AgentConversationsModel`, exposes one normalized projection for local conversations and ambient runs:
- `get_entries` merges task rows, loaded conversation rows, and historical metadata rows in `app/src/ai/agent_conversations_model.rs (1032-1081)`.
- When a task shadows a local conversation, the task keeps row ownership.
- `AgentConversationEntryId` identifies either an `AmbientRun` or `Conversation` in `app/src/ai/agent_conversations_model/entry.rs (29-38)`.
The same model owns open-action policy. `resolve_open_action` re-reads current state at accept time, then prefers:
- already-open ambient sessions
- already-open local conversations
- joinable ambient sessions
- restorable local conversations
- transcript viewer fallback
See `app/src/ai/agent_conversations_model.rs (1117-1239)`.
`ActiveAgentViewsModel` tracks open/focused local conversations and ambient sessions through `ConversationOrTaskId` in `app/src/ai/active_agent_views_model.rs (25-66)`. Its focused/open terminal view lookup APIs feed inline menu labels and suffixes in `app/src/ai/active_agent_views_model.rs (297-515)`.
Before this change, inline conversation menu rows carried `ConversationNavigationData`, and inline history rows carried `AIConversationId`. That excluded task-backed cloud agent rows and duplicated navigation rules in input handlers.
## Proposed changes
### Shared row identity
Use `AgentConversationEntryId` as the row action payload.
`AcceptConversation` now stores `item_id`. The message bar maps that id to `ConversationOrTaskId` to choose “go to conversation” vs “continue in this pane” in `app/src/terminal/input/conversations/mod.rs (29-72)`.
Inline history mirrors that shape. `AcceptHistoryItem`, `MenuItem`, row identity restoration, and accepted events now preserve `AgentConversationEntryId` in `app/src/terminal/input/inline_history/data_source.rs (33-219)` and `app/src/terminal/input/inline_history/view.rs (34-87)`.
### Inline conversation menu
`ConversationMenuDataSource` now starts from `AgentConversationsModel::get_entries(...)`, then keeps only rows where `resolve_open_action(..., ActivePane, ...)` returns an action. This hides unopenable rows and centralizes navigation policy in `app/src/terminal/input/conversations/data_source.rs (32-50)`.
Search behavior stays the same:
- empty query shows up to 50 recent entries
- non-empty query fuzzy-matches title
- Current Directory compares `entry.display.working_directory`
- the currently active local conversation is hidden
See `app/src/terminal/input/conversations/data_source.rs (57-156)`.
Rendering uses entry display data:
- status icon from `entry.display.status`
- timestamp from `entry.display.last_updated`
- title/accessibility text from `entry.display.title`
See `app/src/terminal/input/conversations/search_item.rs (24-196)`.
Open-state rendering asks `ActiveAgentViewsModel::get_terminal_view_id_for_entry(...)` for the open terminal view, using task id first and local conversation id as fallback. It compares that terminal view with `get_focused_terminal_view_id(...)` to decide whether to show “open in different pane” in `app/src/terminal/input/conversations/search_item.rs (99-113)` and `app/src/ai/active_agent_views_model.rs (297-515)`.
## Testing and validation
Preserve:
- inline conversation menu hides the currently active local conversation
- Current Directory filter still works
Verify:
- task-backed `AgentConversationEntryId::AmbientRun` survives inline-history interleaving
- accepting a task-backed row opens or focuses the ambient session when possible
- accepting a local conversation row still restores/navigates into the active pane
- labels and suffixes are correct for open local conversations, open ambient sessions, and task-backed rows with local ids
Manual checks:
- Open inline conversation menu, search a local conversation, accept it, confirm it opens in active pane.
- Open inline history, search a cloud/ambient run, accept it, confirm it focuses or opens through the shared resolver.
- Toggle Current Directory filter and confirm only matching `entry.display.working_directory` rows appear.
## Risks and mitigations
### Stale action payloads
Risk: rows carry a stale `WorkspaceAction` or old `ConversationNavigationData`.
Mitigation: rows carry only `AgentConversationEntryId`; actions resolve at accept time.
### Hidden task-backed rows
Risk: menus only show local conversations.
Mitigation: data sources start from `AgentConversationsModel::get_entries(...)` and only drop rows without an open action.
### Local/task alias mismatch
Risk: a task-backed row with a local conversation id renders wrong open/focused state.
Mitigation: rendering uses the same task-first/local-fallback terminal view lookup shape as open-action resolution.
### Over-broad history rows
Risk: display-only metadata creates unopenable history rows.
Mitigation: both menu data sources filter through `resolve_open_action`.
+157
View File
@@ -0,0 +1,157 @@
# HandoffCloudCloud master tech spec
## Context
Cloud Mode ambient conversations currently treat one ambient task/run as one shared-session execution. The first Cloud Mode pane is created as a deferred shared-session viewer in `app/src/terminal/view/ambient_agent/mod.rs:35`; the ambient model emits `SessionReady`, and the viewer manager joins the session. This branch adds the foundation needed to reuse that same terminal view/model for later sessions: `TerminalManager::attach_followup_session` can replace the active viewer `Network` and join a fresh shared session in append mode in `app/src/terminal/shared_session/viewer/terminal_manager.rs:338`.
The hotswap foundation is documented separately in `specs/REMOTE-1478/TECH.md`. The important shipped boundary is that session IDs are transport-scoped, while the visible ambient conversation, terminal view, terminal model, and task/run identity remain stable across follow-up executions. The event loop already has a follow-up load mode, and `TerminalModel::append_followup_shared_session_scrollback` appends only unknown block IDs instead of replacing the blocklist in `app/src/terminal/shared_session/viewer/event_loop.rs:29`, `app/src/terminal/model/terminal_model.rs:1481`, and `app/src/terminal/model/blocks.rs:759`.
The remaining client work is broader than a small change. It touches the public API client, ambient task/run data models, ambient spawn/follow-up state transitions, the cloud conversation tombstone, terminal input behavior, and tests. This should be implemented as a stack of mergeable PRs rather than one large PR.
The server-side public API now exposes `POST /api/v1/agent/runs/{runId}/followups`, which accepts `RunFollowupRequest { message }` and returns an empty success object when the follow-up is accepted. The server contract says clients should observe readiness by fetching `GET /api/v1/agent/runs/{runId}` until the run exposes an active shared session. The route and handler are in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:181` and `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:608`; the OpenAPI schema is in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/openapi.yaml:644`.
Run executions are a new server abstraction. A stable run/task can have many execution attempts, each with its own input, state, shared session, conversation ID, and compute accounting. The server model is in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/model/types/ai_run_executions.go:37`. The current client is not execution-aware: `AmbientAgentTask` stores one `session_id`, one `session_link`, one `conversation_id`, and one `is_sandbox_running` value in `app/src/ai/ambient_agents/task.rs:213`; `AIConversation` stores a single `task_id`/`run_id` in `app/src/ai/agent/conversation.rs:123`; `spawn_task` polls a task until it sees a single session ID in `app/src/ai/ambient_agents/spawn.rs:27`.
The Cloud Mode tombstone already exists and can render artifacts plus “Continue locally” from `ConversationEndedTombstoneView` in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:139`. It is currently inserted by the generic shared-session end path in `TerminalView::on_session_share_ended` in `app/src/terminal/view/shared_session/view_impl.rs:685`. The new ambient hotswap path intentionally avoids that full teardown, so the follow-up implementation needs a dedicated ambient-execution-ended UI path that inserts the tombstone without making the viewer read-only or permanently finished.
The feature must be gated behind a new client feature flag, `HandoffCloudCloud`. Enabling `HandoffCloudCloud` must imply `CloudModeSetupV2` is enabled. Runtime code may assume `CloudModeSetupV2` behavior whenever `HandoffCloudCloud` is enabled, so new call sites should check only `FeatureFlag::HandoffCloudCloud` unless they are still preserving old setup-v1 behavior.
## Proposed changes
### Feature flag and rollout invariant
Add `FeatureFlag::HandoffCloudCloud` in `crates/warp_features/src/lib.rs` near `CloudModeSetupV2`. Do not add it to release/preview/dogfood lists until the full flow is ready for that audience. When it is added to any rollout list, add `CloudModeSetupV2` to the same list if it is not already there.
Add a small feature-flag test or helper assertion that encodes the dependency: any static rollout set containing `HandoffCloudCloud` must also contain `CloudModeSetupV2`. Runtime feature checks should assume that dependency rather than repeatedly checking both flags.
### Run/execution-aware client model
Introduce explicit run identity and execution-scoped types in the ambient agent model layer. A minimal shape is:
- `AmbientAgentRunId` or reuse `AmbientAgentTaskId` with clearer accessors while server/client naming finishes migrating from task to run.
- `AmbientAgentRunExecutionId` for server execution IDs when the public API starts returning them.
- `AmbientAgentRunExecutionSummary`, containing execution ID if present, state, shared session ID/link, conversation ID, started/updated timestamps, and whether the execution is active.
- `AmbientAgentRun`, or an evolved `AmbientAgentTask`, that exposes stable run fields separately from active/latest execution projections.
For the first client PR, the public API may still return flattened fields. The model should still expose execution-aware APIs that use those fields as the active/latest execution projection:
- `run_id()`
- `active_execution_session_id()`
- `latest_execution_session_id()`
- `active_execution_conversation_id()`
- `has_active_execution()`
- `is_terminal_run_state()`
- `can_submit_cloud_followup()`
Existing callers should stop reaching directly into `session_id`, `session_link`, `conversation_id`, and `is_sandbox_running` when the meaning is execution-scoped. That keeps later server responses with an `executions` array or `active_execution` object from forcing another cross-codebase rename.
`AIConversation` should keep stable conversation identity by server conversation token/conversation ID, while treating run/execution metadata as auxiliary. The current single `task_id` field can remain as the stable run ID for compatibility, but methods and comments should distinguish “run ID” from any future “execution ID”. Do not make a follow-up execution allocate a new local `AIConversationId`; following up continues the same local conversation and same server conversation/run.
`AgentConversationsModel` should merge fetched run data by stable run ID. Details panels and management lists can continue to show one row per run, with aggregate state from the run and active/latest execution session info for open/continue actions.
### Public API client
Add `RunFollowupRequest { message }` and `AIClient::submit_run_followup(run_id, message)` in `app/src/server/server_api/ai.rs`. Implement it as `POST agent/runs/{run_id}/followups` against the public API, returning `Result<(), anyhow::Error>`.
Split the current `spawn_task` stream into reusable pieces:
- `spawn_task(request, ai_client, timeout)` continues to create the initial run and monitor it.
- A new monitor helper polls an existing run until it reaches either a terminal error state or a new joinable execution session.
- A new follow-up helper calls `submit_run_followup`, then uses the monitor helper to wait for the next active execution session.
The follow-up monitor must know the previous execution session ID and ignore it. A ready follow-up session is a session ID that is present, parseable, active according to the run/execution projection, and different from the previous ended session ID.
### Ambient view model state
Extend `AmbientAgentViewModel` to track stable run state and per-execution startup state:
- stable run ID/task ID;
- local conversation ID;
- active execution session ID;
- last ended execution session ID;
- current startup kind: initial run or follow-up execution;
- the prompt currently being submitted for optimistic rendering.
Replace the implicit “if already `AgentRunning`, then a `SessionStarted` means follow-up” logic in `app/src/terminal/view/ambient_agent/model.rs:606` with explicit state. A good shape is `Status::WaitingForSession { progress, kind }`, where `kind` is `Initial` or `Followup`.
Add a public model method such as `submit_cloud_followup(prompt, ctx)`. It should:
1. require `HandoffCloudCloud`;
2. require an existing run ID;
3. record the follow-up prompt for optimistic rendering;
4. set `Status::WaitingForSession { kind: Followup }`;
5. emit a setup/loading event so the terminal shows the existing Cloud Mode setup UI;
6. call the follow-up API;
7. poll for the new session;
8. emit `FollowupSessionReady { session_id }` when ready;
9. surface errors through the same loading/error UI path as initial Cloud Mode setup.
When the follow-up session becomes ready, keep using `TerminalManager::attach_followup_session` rather than opening a new terminal view. When the follow-up execution starts, update the existing conversation status back to `ConversationStatus::InProgress`; when it finishes, update status based on the run state.
### Ambient execution-ended UI boundary
Add a dedicated ambient execution-ended path instead of using the generic shared-session teardown. The current branchs `ambient_session_ended` in `app/src/terminal/shared_session/viewer/terminal_manager.rs:1516` should trigger a terminal-view method such as `on_ambient_agent_execution_ended`.
That terminal-view method should:
- insert the conversation-ended tombstone once per ended execution, or update the existing tombstone to represent the latest ended execution;
- keep the existing `TerminalView`, `TerminalModel`, ambient view model, and input model alive;
- keep the terminal pane eligible to attach another shared session;
- not set `SharedSessionStatus::FinishedViewer`;
- not put the editor into permanent read-only/selectable state;
- not cancel the local ambient conversation merely because the execution ended.
If multiple follow-ups are supported, the tombstone insertion policy should avoid stacking multiple large terminal-state cards in a way that buries the active input. The simplest acceptable policy for the first UI PR is to append a tombstone after each terminal execution, because each one marks a real boundary in the transcript. If that feels noisy in implementation review, the alternative is to keep one tombstone view and update it in place until the user submits the next follow-up.
### Tombstone Continue entrypoint and terminal input
Under `HandoffCloudCloud`, `ConversationEndedTombstoneView` should show a primary “Continue” action for ambient Cloud Mode tombstones that have a run ID and are eligible for cloud follow-up. Keep “Continue locally” as a secondary or fallback action.
Clicking “Continue” should reveal/focus the terminal input for a follow-up prompt. Prefer reusing the existing terminal input/editor rather than embedding a separate text editor inside the tombstone. That keeps submission, attachments, editor state, and keyboard behavior consistent with Cloud Mode setup.
The submit path should route through `AmbientAgentViewModel::submit_cloud_followup`, not through the shared-session viewer network. The previous shared session has ended, so there is no active sharer to receive `SendAgentPrompt`. The new run execution is created by the public follow-up API; once its shared session is ready, the viewer network is attached.
During setup, reuse the Cloud Mode setup-v2 loading screen and progress footer/screen instead of inventing a new progress UI. The initial-user-query rich content pattern in `CloudModeInitialUserQuery` can be generalized to render optimistic follow-up user prompts while the environment is starting.
### Conversation and blocklist continuity
Following up must preserve the same local `AIConversationId`, same server conversation ID, and same stable run ID. The new execution creates a new shared session and a new execution record, not a new user-visible conversation.
When the new session joins, append session scrollback using `SharedSessionInitialLoadMode::AppendFollowupScrollback`. The server/runtime contract should preserve `SerializedBlock.id` for prior rehydrated blocks or send continuation-only scrollback. Without one of those contracts, the client cannot reliably dedupe prior output from new output.
The existing hotswap append path should be treated as the only blocklist mutation path for follow-up execution output. Avoid separately loading conversation transcript data into the same view during follow-up setup, because that risks duplicating AI blocks and shell command blocks.
### Error handling
If `submit_run_followup` fails before the server accepts the prompt, restore the tombstone/input to an editable state and show the error in the same Cloud Mode setup area. Do not append the optimistic follow-up prompt permanently unless the server accepted it.
If the server accepts the follow-up but polling reaches a terminal failure before a new session is available, show the Cloud Mode error/cancelled/auth/capacity state and leave the tombstone/input available for retry when the server marks the run retryable.
If the user closes the pane while waiting for a follow-up session, cancel only local polling. Do not cancel the run unless the user explicitly invokes a cancel action.
## End-to-end flow
1. User starts a Cloud Mode ambient conversation.
2. The initial run is created with `POST /agent/run`; the client stores the stable run ID and local conversation ID.
3. `spawn_task` polls until the run exposes the initial active execution shared session.
4. `AmbientAgentViewModel` emits `SessionReady`; the viewer manager joins the initial session.
5. The execution reaches a terminal state and the shared session ends.
6. The viewer manager handles this as an ambient execution boundary, inserts or updates the tombstone, and keeps the pane/input resumable.
7. User clicks “Continue” on the tombstone and submits a prompt through the terminal input.
8. The ambient view model calls `POST /agent/runs/{runId}/followups`.
9. The client shows Cloud Mode setup-v2 loading UI while polling `GET /agent/runs/{runId}`.
10. When a new active execution session ID appears, `AmbientAgentViewModel` emits `FollowupSessionReady`.
11. The viewer manager calls `attach_followup_session`, replaces the network, and joins the new session in append mode.
12. New output streams into the same terminal view/model and same local conversation.
13. Steps 5-12 can repeat for additional follow-up executions.
## Increment plan
### PR 0: shared-session hotswap foundation
This is the current branch and can stand alone. It keeps reusable viewer resources across networks, adds `attach_followup_session`, adds append-mode scrollback loading, and prevents ambient `SessionEnded` from permanently poisoning the pane. The existing `specs/REMOTE-1478/TECH.md` covers this increment.
### PR 1: feature flag, API client, and run/execution-aware model scaffolding
Add `HandoffCloudCloud`, `submit_run_followup`, follow-up request/response tests, and run/execution-aware accessors on `AmbientAgentTask`/run models. Update existing call sites to use accessors for active session/conversation state. This PR should not expose UI or change runtime behavior except behind tests and the disabled flag.
Merge criteria: existing Cloud Mode spawn, task list, details panel, and shared-session viewer behavior are unchanged with the flag off.
### PR 2: ambient follow-up orchestration without the visible entrypoint
Add `AmbientAgentViewModel::submit_cloud_followup`, explicit initial-vs-follow-up waiting state, polling for a new active session, optimistic follow-up prompt state, and `FollowupSessionReady` wiring to the existing hotswap API. Add unit tests using a mocked `AIClient`.
This can be mergeable behind the disabled flag with a test-only or debug-only invocation path. No tombstone button is required yet.
Merge criteria: a model-level follow-up can accept a prompt, call the API, ignore the previous session ID, emit a new session ID, and handle API/polling errors.
### PR 3: tombstone Continue UX and terminal input submission
Add the cloud “Continue” tombstone action, reveal/focus the existing terminal input, route submission to the ambient follow-up model method, show setup-v2 loading UI, and render the optimistic follow-up prompt. Keep “Continue locally” available. Add telemetry for continue-in-cloud attempts, success, and failures.
Merge criteria: with `HandoffCloudCloud` off, the tombstone is unchanged; with it on, terminal-state Cloud Mode conversations can start a follow-up and attach the new shared session in the same pane.
### PR 4: polish, details panel, and end-to-end validation
Update conversation details/agent-management surfaces to use run/execution-aware helpers, ensure active/past sections treat a run with an active follow-up execution as active, and add integration coverage for at least two execution boundaries. This PR can also tune tombstone stacking/updating based on product review.
Merge criteria: repeated cloud follow-ups preserve one conversation/run identity, append output in order, and do not regress normal shared-session viewers or local “Continue locally”.
## Testing and validation
Unit tests:
- `AIClient::submit_run_followup` constructs `POST agent/runs/{runId}/followups` with `{ message }` and handles success/error responses.
- Run/execution accessors derive active/latest session state from current flattened API fields and from future optional execution-shaped test fixtures.
- Ambient follow-up model calls the API, transitions to `WaitingForSession { kind: Followup }`, polls until a new session ID appears, ignores the old session ID, emits `FollowupSessionReady`, and handles terminal failure states.
- `ConversationEndedTombstoneView` shows/hides Continue based on `HandoffCloudCloud`, task/run presence, AI settings, and target platform.
- Non-ambient shared-session `SessionEnded` still uses the generic finished/read-only viewer path.
Viewer/session tests:
- Follow-up attach replaces the active network and does not duplicate outbound subscriptions.
- Ambient `SessionEnded` inserts or updates a tombstone without setting `FinishedViewer`.
- Repeated follow-up sessions append scrollback without duplicating block IDs.
Integration or manual validation:
- Start a Cloud Mode conversation, wait for the execution to end, click Continue, submit a prompt, verify setup UI appears, then verify a fresh shared session attaches to the same pane.
- Repeat with a second follow-up to catch subscription leaks and stale session-ID handling.
- Verify “Continue locally” still forks locally from the same tombstone.
- Verify a normal shared-session viewer still becomes read-only and shows the ended banner when its session ends.
Before opening or updating PRs in this stack, run the repo-required formatting and clippy checks for touched Rust code, plus targeted tests for ambient model, server API client, tombstone view, and viewer terminal manager. For the user-facing UI increments, run UI verification with the `verify-ui-change-in-cloud` skill after implementation.
## Risks and mitigations
### Run/execution naming churn
Risk: continuing to use `task_id` everywhere makes execution-scoped changes confusing and easy to misuse.
Mitigation: add accessors and comments that separate stable run identity from execution-scoped session/conversation state, even if the underlying ID type remains `AmbientAgentTaskId` during migration.
### Stale session readiness
Risk: after submitting a follow-up, `GET /agent/runs/{runId}` may briefly return the ended executions previous session ID.
Mitigation: the follow-up monitor must track the previous session ID and require a different active execution session before emitting `FollowupSessionReady`.
### Duplicate transcript content
Risk: follow-up sessions may replay prior scrollback, and client-side transcript restoration may also try to render conversation data.
Mitigation: use only append-mode shared-session scrollback in the live pane, dedupe by block ID, and require the runtime/server to preserve block IDs or send continuation-only scrollback.
### Input routed to the wrong transport
Risk: the terminal input could accidentally send a prompt over the old shared-session `NetworkEvent::SendAgentPrompt` path.
Mitigation: while between executions, route submission through the ambient follow-up API path and keep `current_network` empty until a new session is attached.
### Feature flag dependency drift
Risk: `HandoffCloudCloud` could be enabled without setup-v2, exposing code paths that assume setup-v2 UI/state.
Mitigation: encode the rollout-list dependency in a test and document that runtime code only checks `HandoffCloudCloud`.
### Tombstone noise
Risk: repeated executions can append multiple large tombstones.
Mitigation: start with the simple append-per-execution behavior only if it reads well in product review; otherwise update the latest tombstone in place until the next follow-up is submitted.
## Parallelization
The work can split across three agents or branches after PR 1 lands:
- API/model track: follow-up client method, run/execution accessors, agent management/details model updates.
- Ambient orchestration track: view-model follow-up state machine, polling, and hotswap event wiring.
- UI track: tombstone Continue action, terminal input reveal/submission, optimistic prompt rendering, and setup-v2 loading/error states.
The tracks converge at `AmbientAgentViewModel::submit_cloud_followup` and the existing `FollowupSessionReady -> attach_followup_session` subscription in `create_cloud_mode_view`.
## Follow-ups
- Add first-class execution arrays or active/latest execution objects to the public API response once the server shape is ready, then remove flattened-field compatibility from client accessors.
- Decide whether conversation details should show per-execution runtime/credit rows or only aggregate run-level totals.
- Consider adding execution IDs to session-sharing source metadata so the client can associate a joined session with a specific execution without inferring from session ID.
- Remove the `HandoffCloudCloud` flag after cloud-to-cloud follow-ups are stable.
+79
View File
@@ -0,0 +1,79 @@
# Cloud-to-cloud handoff PR 1 tech spec
## Problem statement
The master cloud-to-cloud handoff plan needs an initial mergeable PR that adds the scaffolding required by later follow-up orchestration and UI work without changing end-user behavior. PR 1 should make the client aware of the `HandoffCloudCloud` rollout boundary, add a typed client method for the server follow-up API, and start isolating task/run identity from execution/session-scoped data in the client model.
This PR intentionally does not add the tombstone Continue entrypoint, does not submit follow-ups from the UI, and does not attach a follow-up shared session to an existing terminal. Those behaviors belong in later PRs after the foundational APIs exist.
## Current state
The current branch already adds the UI/model layer needed to attach a new backing shared session to an existing shared-session viewer; that foundation is documented in `specs/REMOTE-1478/TECH.md`. The broader sequencing and intended user flow are documented in `specs/handoff-cloud-cloud/TECH.md`.
Feature flags are defined in `crates/warp_features/src/lib.rs`. `CloudModeSetupV2` already exists near the end of the enum at `crates/warp_features/src/lib.rs:827`, and the rollout arrays are defined at `crates/warp_features/src/lib.rs:852`, `crates/warp_features/src/lib.rs:910`, and `crates/warp_features/src/lib.rs:926`. There is currently no `HandoffCloudCloud` flag. The Cargo feature graph in `app/Cargo.toml` is the right place to encode that `handoff_cloud_cloud` depends on `cloud_mode_setup_v2`.
The public API client already has ambient run methods for spawn, list, and get. The `AIClient` trait includes `spawn_agent`, `list_ambient_agent_tasks`, and `get_ambient_agent_task` at `app/src/server/server_api/ai.rs:799`, and the `ServerApi` implementation posts to `agent/run`, lists `agent/runs`, and gets `agent/runs/{task_id}` at `app/src/server/server_api/ai.rs:1404`. Adjacent public API methods post to run-scoped subresources such as `agent/runs/{task_id}/attachments/prepare` at `app/src/server/server_api/ai.rs:1785`, which is the natural implementation pattern for the follow-up API.
The server follow-up API is `POST /api/v1/agent/runs/{runId}/followups` with a JSON body containing `message`. The server route and request type are in the server worktree at `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/openapi.yaml:644`, `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:181`, `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:608`, and `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/types/types.gen.go:1225`.
`AmbientAgentTask` currently exposes execution-scoped fields directly: `session_id`, `session_link`, `conversation_id`, `request_usage`, and `is_sandbox_running` at `app/src/ai/ambient_agents/task.rs:217`. `is_no_longer_running` already combines sandbox liveness with run state at `app/src/ai/ambient_agents/task.rs:257`. `SessionJoinInfo::from_task` reads `task.session_link` and `task.session_id` directly at `app/src/ai/ambient_agents/spawn.rs:32`. Agent management and details code also reads these flattened fields directly for session status, links, conversation dedupe, open actions, and details-panel display, including `app/src/ai/agent_conversations_model.rs:468`, `app/src/ai/agent_conversations_model.rs:515`, `app/src/ai/agent_conversations_model.rs:528`, `app/src/ai/agent_conversations_model.rs:553`, `app/src/ai/agent_conversations_model.rs:1293`, and `app/src/ai/conversation_details_panel.rs:337`.
## Goals
Add a disabled `HandoffCloudCloud` feature flag, with the app Cargo feature depending on `cloud_mode_setup_v2`.
Add typed client support for submitting a follow-up prompt to a run via the public API.
Introduce run/execution-aware accessors on `AmbientAgentTask` that preserve current behavior while giving later PRs a place to route active/latest-execution semantics.
Move the most important existing call sites from direct flattened fields to the new accessors when doing so is behavior-preserving and low-risk.
Add targeted unit tests for the new API serialization/path helper and task accessor behavior.
## Non-goals
No visible cloud conversation tombstone changes.
No terminal input submission changes.
No cloud mode setup UI changes.
No follow-up polling or shared-session hotswap orchestration.
No attempt to parse or store a full run-executions array unless the public API already returns it to the client in the current schema. PR 1 should define seams that can absorb that shape later.
No rollout enablement in `DOGFOOD_FLAGS`, `PREVIEW_FLAGS`, or `RELEASE_FLAGS`.
## Proposed changes
### Feature flag scaffolding
Add `HandoffCloudCloud` to `FeatureFlag` in `crates/warp_features/src/lib.rs`, preserving the enum's chronological ordering. Keep the description concise and product-focused, for example gating cloud-to-cloud continuation of cloud mode conversations.
Do not add the flag to any rollout arrays in PR 1. The flag should be available for local overrides and future PRs, but disabled by default.
Add `handoff_cloud_cloud = ["cloud_mode_setup_v2"]` to `app/Cargo.toml` and map the Cargo feature to `FeatureFlag::HandoffCloudCloud` in `app/src/lib.rs`. This keeps the dependency explicit in the build feature graph without adding rollout dependency tests.
### Follow-up API client
Add a request type near `SpawnAgentRequest` in `app/src/server/server_api/ai.rs`:
`RunFollowupRequest { message: String }`, serialized as `{"message":"..."}`.
Add an `AIClient` method named `submit_run_followup` with parameters `run_id: &AmbientAgentTaskId` and `request: RunFollowupRequest`, returning `anyhow::Result<(), anyhow::Error>`. The current server behavior does not expose a client-needed response payload, so implement with `post_public_api_unit(&build_run_followup_url(run_id), &request)`.
Add a small path helper, for example `build_run_followup_url(run_id: &AmbientAgentTaskId) -> String`, mirroring `build_list_agent_runs_url` at `app/src/server/server_api/ai.rs:521`. This makes unit testing the endpoint path independent from HTTP mocking.
Do not call this new method from UI or orchestration code in PR 1.
### Run/execution-aware task accessors
Add lightweight accessors to `AmbientAgentTask` in `app/src/ai/ambient_agents/task.rs`. For PR 1 these should project the existing flattened fields as the active/latest execution:
`run_id() -> AmbientAgentTaskId` returning `task_id`.
`conversation_id() -> Option<&str>` returning `conversation_id.as_deref()`.
`active_run_execution() -> RunExecution<'_>` returning a borrowed projection of the flattened execution-scoped fields.
Add `RunExecution<'a>` with borrowed `session_id`, non-empty `session_link`, `request_usage`, and `is_sandbox_running` fields. The key is that callers stop encoding the assumption that task-level fields are intrinsically task-scoped.
Update `is_no_longer_running` to use `active_run_execution().is_sandbox_running` and preserve existing behavior.
Move `SessionJoinInfo::from_task` in `app/src/ai/ambient_agents/spawn.rs:32` to use `active_run_execution()`.
Update low-risk UI/model call sites that only need the current projection:
`ConversationOrTask::session_id` should parse `active_run_execution().session_id`.
`link_preference` should use `active_run_execution().is_sandbox_running`.
`session_or_conversation_link` should use `active_run_execution().session_link` and `conversation_id()`.
`get_session_status` should use the session/link accessors.
`get_open_action` and conversation shadowing should use `run_id()` and `conversation_id()`.
`ConversationDetailsData::from_task` should use `conversation_id()` for the details-panel conversation id and `active_run_execution().request_usage` for credits.
Avoid broad mechanical churn in call sites where fields are clearly task metadata rather than execution data, such as title, prompt, state, source, creator, artifact list, and config snapshot.
### Optional naming cleanup
Do not rename `AmbientAgentTaskId` or public UI labels in PR 1. The server and much of the client still use run/task terminology interchangeably, and a broad rename would create churn without improving mergeability. The new `run_id()` accessor is enough to clarify stable identity for later PRs.
## Testing strategy
Add public API helper tests in `app/src/server/server_api/ai_test.rs` for the follow-up endpoint path. If the request type is public enough to serialize directly, add a serialization test asserting the JSON shape is exactly `{"message":"..."}`.
Add or update ambient task tests in `app/src/ai/ambient_agents/spawn_tests.rs` or a new `app/src/ai/ambient_agents/task_tests.rs` to cover:
`SessionJoinInfo::from_task` still prefers server-provided session links.
It still falls back to constructing a join link from `session_id`.
It returns `None` when neither an active link nor parseable session id exists.
The new accessors preserve current flattened-field behavior.
Run targeted checks after implementation:
`cargo nextest run -p warp server::server_api::ai::tests::build_run_followup_url_routes_to_run_followups server::server_api::ai::tests::serialize_run_followup_request ai::ambient_agents::spawn::tests`
`cargo nextest run -p warp ai::agent_conversations_model::tests ai::conversation_details_panel::tests`
`cargo check -p warp --features handoff_cloud_cloud`
The exact package names should be verified during implementation from `Cargo.toml` before running. Do not use `cargo fmt --all` or file-specific `cargo fmt`; if formatting is needed before review, use the repos standard `cargo fmt` per project guidance.
## Rollout and compatibility
The flag is disabled by default, so PR 1 should not change runtime behavior.
The new API client method is unused in PR 1 and should therefore be safe to merge before server rollout, as long as it compiles against existing client code.
The accessor migration should be behavior-preserving because each accessor initially projects the same flattened fields. If a direct field use is ambiguous or risky, leave it in place and document it as follow-up rather than expanding the PR scope.
## Risks and mitigations
The largest risk is accidentally changing management view link selection or session-open behavior while replacing direct field reads. Keep changes small, prefer local accessor substitutions, and rely on existing spawn and agent management tests where available.
The follow-up endpoint response shape may differ from the assumed empty response. During implementation, verify the server contract before choosing `post_public_api_unit`; if the server returns a body, add a minimal response type and test deserialization.
Runtime implication of `HandoffCloudCloud` to `CloudModeSetupV2` is intentionally not implemented in PR 1. The Cargo feature dependency covers compiled builds, while local runtime overrides can still force unusual states for targeted testing.
The accessor names may need to change once the client consumes first-class run-execution data. Keep PR 1 names descriptive but avoid adding a large abstraction that is not backed by current API data.
## Definition of done
`HandoffCloudCloud` exists and is disabled by default.
`handoff_cloud_cloud` in `app/Cargo.toml` depends on `cloud_mode_setup_v2`.
`AIClient` and `ServerApi` expose a typed follow-up submission method for `POST agent/runs/{run_id}/followups`.
`AmbientAgentTask` exposes run/execution-aware accessors and the main session/conversation call sites use them without behavior changes.
Targeted tests cover the follow-up API path/serialization and task/session accessor behavior, and a feature-gated compile check passes.
@@ -0,0 +1,69 @@
# Cloud-to-cloud handoff PR 2 tech spec
## Problem statement
PR 2 should add the orchestration layer that turns an existing cloud agent run into a follow-up execution and hands the resulting fresh shared session to the hotswap path. This PR should remain mergeable while `HandoffCloudCloud` is disabled by default, and it should not add the user-visible tombstone Continue button or terminal-input submission route yet.
The intended boundary is model/API behavior: a future UI can call one ambient-model method with a follow-up prompt, the client submits `POST agent/runs/{runId}/followups`, polls the same run until a new joinable session appears, ignores the ended session, and emits `FollowupSessionReady` so the existing viewer manager attaches the new session in append mode.
## Current state
PR 1 added the disabled `HandoffCloudCloud` flag and encoded the Cargo feature dependency on `cloud_mode_setup_v2` in `app/Cargo.toml:924`. It also added `RunFollowupRequest`, `AIClient::submit_run_followup`, and `build_run_followup_url` in `app/src/server/server_api/ai.rs (211-216, 837-840, 1467-1471)`, with endpoint/serialization tests in `app/src/server/server_api/ai_test.rs (988-1000)`.
`AmbientAgentTask` now has `run_id()`, `conversation_id()`, and `active_run_execution()` accessors that project the current flattened response fields into a `RunExecution` view in `app/src/ai/ambient_agents/task.rs (247-279)`. `SessionJoinInfo::from_task` already consumes that projection in `app/src/ai/ambient_agents/spawn.rs (31-57)`.
Initial cloud startup is still a single combined helper: `spawn_task` creates a run, polls `get_ambient_agent_task`, emits state changes, and ends when the first session is joinable in `app/src/ai/ambient_agents/spawn.rs (85-176)`. There is no reusable “poll an existing run until a new execution session is ready” helper yet.
`AmbientAgentViewModel` still models startup as `Status::WaitingForSession { progress }` without distinguishing an initial run from a follow-up execution in `app/src/terminal/view/ambient_agent/model.rs (50-64)`. The existing `SessionStarted` handler infers follow-up readiness from being already in `AgentRunning`, which is too implicit for a model-driven follow-up flow in `app/src/terminal/view/ambient_agent/model.rs (627-638)`. The current `attach_followup_session` method simply emits `FollowupSessionReady` for a known session ID, which is useful test scaffolding but does not submit or poll a follow-up in `app/src/terminal/view/ambient_agent/model.rs:349`.
The hotswap receiver already exists. `create_cloud_mode_view` routes `SessionReady` to `connect_to_session` and `FollowupSessionReady` to `attach_followup_session` in `app/src/terminal/view/ambient_agent/mod.rs (69-82)`. The viewer managers follow-up attach path replaces the active network and joins with append-mode scrollback in `app/src/terminal/shared_session/viewer/terminal_manager.rs (338-384)`.
The UI has important side effects tied to initial dispatch. `DispatchedAgent` inserts the initial optimistic user query in `TerminalView::handle_ambient_agent_event` and drives the ambient entry-block insertion subscription in `app/src/terminal/view/ambient_agent/view_impl.rs (105-131, 445-481)`. PR 2 should avoid reusing that event for follow-ups, because doing so would blur initial-run and follow-up behavior before the UX PR.
## Goals
Add reusable follow-up orchestration that submits a prompt to an existing run and waits for a fresh active execution session.
Make the ambient view model explicitly track whether it is waiting for an initial session or a follow-up session.
Track the active or previous execution session ID so follow-up polling can ignore stale readiness from the ended session.
Emit the already-supported `FollowupSessionReady` event when the new session is ready, allowing the existing hotswap path to attach it.
Reuse existing Cloud Mode setup/loading/error state machinery for follow-up waiting and failures, but without adding a visible Continue entrypoint.
Keep the implementation behind `FeatureFlag::HandoffCloudCloud` and preserve behavior with the flag off.
## Non-goals
No tombstone Continue button, action, or copy changes.
No terminal input routing changes for submitting follow-up prompts.
No embedded follow-up prompt editor in the tombstone.
No product decision on tombstone stacking or update-in-place behavior.
No first-class server execution-array parsing unless the public API response shape already exposes it in this branch.
No rollout enablement for `HandoffCloudCloud`.
## Proposed changes
### Reusable run polling and follow-up helper
Refactor `spawn_task` in `app/src/ai/ambient_agents/spawn.rs` so run creation and run readiness monitoring are separate. Keep the public `spawn_task(request, ai_client, timeout)` behavior the same by having it call a new internal polling helper after `spawn_agent` succeeds.
Add a helper such as `poll_run_until_joinable_session(run_id, ai_client, previous_session_id, timeout)` that repeatedly calls `get_ambient_agent_task(&run_id)`, emits `StateChanged` when state changes, and returns `SessionStarted` only when the task is `InProgress` and `SessionJoinInfo::from_task` contains a parseable `session_id` that differs from `previous_session_id` when one was provided.
Add a follow-up stream/helper such as `submit_run_followup(prompt, run_id, previous_session_id, ai_client, timeout)`. It should call `AIClient::submit_run_followup(run_id, RunFollowupRequest { message: prompt })` first, then call the polling helper. API failure before acceptance should yield an error without polling. Polling errors should surface through the same error path as initial spawn.
For initial spawn, preserve the existing tolerance for a session link without a parsed session ID if any caller still needs that metadata. For follow-up readiness, require a parsed session ID because the hotswap API needs a `SessionId`.
Terminal states before a fresh session is found should not leave the follow-up wait indefinitely. Failure-like states should emit the state change and then surface the task status message as an error; successful terminal completion without a new session should complete with a clear “no follow-up session became available” error.
### Explicit ambient model startup kind
Add a small enum such as `SessionStartupKind { InitialRun, Followup }` and change `Status::WaitingForSession` to carry `{ progress, kind }`. Existing accessors like `agent_progress()` and `is_waiting_for_session()` should remain behavior-preserving.
Add fields to `AmbientAgentViewModel` for follow-up bookkeeping: the active execution `SessionId`, the last ended execution `SessionId` if available, and the currently submitted follow-up prompt. The prompt field is for PR 3s optimistic rendering; PR 2 should store it but not insert a visible follow-up query block.
Update initial spawn to set `WaitingForSession { kind: InitialRun }`. Update `AmbientAgentEvent::SessionStarted` handling to emit `SessionReady` for `InitialRun` and `FollowupSessionReady` for `Followup`, rather than relying on whether the current status happens to be `AgentRunning`.
Add `AmbientAgentViewModel::submit_cloud_followup(prompt, ctx)`. It should require `FeatureFlag::HandoffCloudCloud`, require an existing `task_id`/run ID, capture the previous active or ended session ID, set `WaitingForSession { kind: Followup }`, start the progress timer, store the pending prompt, emit a distinct follow-up dispatch event, and spawn the follow-up helper stream.
On follow-up success, stop the timer, set `status` to `AgentRunning`, update the active execution session ID, clear the pending prompt, and emit `FollowupSessionReady { session_id }`. On failure, reuse the existing failure/auth/quota/capacity mapping logic as much as possible so follow-up setup errors render through the same state as initial setup errors.
### Execution-ended bookkeeping without visible UI
Extend the ambient session-ended path only enough for bookkeeping. `viewer::TerminalManager::ambient_session_ended` currently leaves the pane resumable and clears the active network in `app/src/terminal/shared_session/viewer/terminal_manager.rs (1490-1515)`. In PR 2 it can notify the ambient view model of the ended session ID behind `HandoffCloudCloud`, so the model records `last_ended_execution_session_id` and can reject duplicate readiness from that session.
This notification should not call `TerminalView::on_session_share_ended`, should not insert a tombstone, should not set `SharedSessionStatus::FinishedViewer`, and should not cancel the local conversation. Those UI and lifecycle decisions remain PR 3 scope.
### Event and view integration
Add a new model event such as `FollowupDispatched` instead of reusing `DispatchedAgent`. `create_cloud_mode_view` only needs an exhaustive-match update for the new event because `FollowupSessionReady` is already wired to `attach_followup_session`.
Update `TerminalView::handle_ambient_agent_event` to handle `FollowupDispatched` by notifying/re-rendering progress UI and marking the active ambient conversation as `ConversationStatus::InProgress` if one exists. It should not insert `CloudModeInitialUserQuery`, should not insert a second `AmbientAgentEntryBlock`, and should not auto-open new UI beyond the existing setup/progress rendering.
The existing loading screen in `app/src/terminal/view/ambient_agent/view_impl.rs (529-571)` can continue to derive messages from `AgentProgress` for PR 2. If copy changes are desired for follow-ups, keep them minimal and keyed off `SessionStartupKind`, but deferring user-facing copy to PR 3 is acceptable.
## Testing strategy
Add stream-level tests in `app/src/ai/ambient_agents/spawn_tests.rs` covering follow-up submission and polling. The important cases are: the helper calls `submit_run_followup` before polling; it ignores the previous session ID returned by the server; it emits `SessionStarted` for a different new session ID; it propagates API errors before polling; it surfaces terminal failure before readiness.
Preserve existing `spawn_task` tests so initial spawn behavior remains unchanged after the refactor.
Add model-level tests if there is a lightweight existing harness for `AmbientAgentViewModel`; otherwise keep model changes small and validate via stream tests plus targeted compile checks. Model assertions should cover `submit_cloud_followup` preconditions, `WaitingForSession { kind: Followup }`, and `FollowupSessionReady` emission on a fresh session.
Run targeted validation after implementation: `cargo nextest run -p warp ai::ambient_agents::spawn::tests server::server_api::ai::tests::build_run_followup_url_routes_to_run_followups server::server_api::ai::tests::serialize_run_followup_request` and `cargo check -p warp --features handoff_cloud_cloud`. If model or terminal-view tests are added, include their module filters. Do not use `cargo fmt --all` or file-specific `cargo fmt`; use the repos standard formatting command only when preparing a PR update.
## Rollout and compatibility
With `HandoffCloudCloud` off, no production UI should call the new follow-up method and existing initial Cloud Mode startup should behave as it does today.
With the flag on, PR 2 only exposes an internal/model-level follow-up path. The absence of a visible entrypoint makes this safe to merge before product UX lands, while unit tests can still exercise the orchestration path.
The runtime code may assume `CloudModeSetupV2` when `HandoffCloudCloud` is enabled because the Cargo feature dependency was added in PR 1.
## Risks and mitigations
The server may briefly return the ended executions session fields after accepting a follow-up. Mitigate by passing the previous session ID into the polling helper and requiring a different parsed session ID before emitting readiness.
Reusing `DispatchedAgent` for follow-ups would insert initial-run UI artifacts again. Mitigate with a distinct follow-up event and explicit startup kind.
Refactoring `spawn_task` could regress initial Cloud Mode startup. Mitigate by preserving the public stream contract and keeping existing spawn tests green.
A follow-up may be accepted but fail before any session becomes joinable. Mitigate by reusing the existing failed/auth/quota/capacity UI states and leaving future UI free to retry from the tombstone in PR 3.
Model bookkeeping could drift if session-ended notifications are missed. Mitigate by also falling back to the last active execution session ID when submitting a follow-up.
## Parallelization
This PR is small enough to implement sequentially, but two independent tracks could run in parallel if needed. One track can refactor and test `spawn.rs` follow-up polling with mocked `AIClient`; the other can wire `AmbientAgentViewModel` state/events and terminal-manager bookkeeping. They converge at `submit_cloud_followup` consuming the follow-up helper and emitting `FollowupSessionReady`.
## Definition of done
`spawn_task` still behaves the same for initial runs after extracting reusable polling.
A follow-up helper submits a prompt, polls the stable run, ignores stale session IDs, and returns a fresh joinable session.
`AmbientAgentViewModel::submit_cloud_followup` exists behind `HandoffCloudCloud` and drives `WaitingForSession { kind: Followup }` through success and error states.
`FollowupSessionReady` is emitted for fresh sessions and continues to attach through the existing hotswap path.
No tombstone Continue UI or terminal-input follow-up route is added in this PR.
Targeted tests and `cargo check -p warp --features handoff_cloud_cloud` pass.
@@ -0,0 +1,39 @@
# Cloud-to-cloud handoff resumable completed conversations
## Summary
Completed cloud agent conversations should be resumable in cloud mode when cloud-to-cloud handoff is available. Opening a completed cloud task should restore a Cloud Mode pane seeded with the prior conversation history, preserving the familiar completed transcript/tombstone experience while allowing the user to submit a follow-up prompt that starts a new cloud execution in the same conversation.
## Problem
Before cloud-to-cloud handoff, a cloud task with no active execution was permanently complete from the clients perspective, so the transcript viewer and “Continue locally” affordance were sufficient. With multi-execution cloud runs, the same visual state can now be a pause between executions. Users should not hit a dead-end toast when the product offers a cloud Continue action.
## Figma
Figma: none provided. The current failure state is represented by the screenshot attached to the request.
## Behavior
1. When a user opens a cloud agent conversation that has no active execution and cloud-to-cloud continuation is available, Warp opens a Cloud Mode pane seeded with the completed conversation history instead of starting a new blank task.
2. The restored Cloud Mode pane preserves the existing completed-conversation presentation:
- The prior conversation output remains visible.
- The completed task tombstone remains visible at the end of the transcript.
- Existing metadata, artifacts, error status, runtime, credits, source, skill, and working-directory details remain available wherever they are already shown.
3. For a resumable cloud task, the tombstone shows a primary `Continue` action for continuing in cloud mode.
4. The existing `Continue locally` action remains available where it is supported today. Adding cloud continuation must not remove the local fork path.
5. If the task cannot be continued in cloud mode, Warp does not show a cloud `Continue` action. Users should not be offered an action that can only produce a generic “couldn't continue” failure.
6. Clicking cloud `Continue` does not immediately create a new execution. It prepares the same Cloud Mode pane for a follow-up prompt and focuses the existing terminal input.
7. While the pane is waiting for a follow-up prompt, the user can type, edit, or abandon the prompt using the normal terminal input behavior.
8. Submitting a non-empty follow-up prompt starts a new execution for the same cloud task/run, not a new user-visible conversation.
9. Submitting an empty follow-up prompt does not start a new execution. The pane stays usable and focused so the user can type a real prompt or leave the view.
10. After the follow-up prompt is submitted, the restored pane transitions into Cloud Mode setup/progress UI in the same pane.
11. During follow-up setup, the user sees their submitted prompt represented optimistically so the pane does not appear to ignore the input while the new cloud execution is starting.
12. When the new execution starts, Warp attaches to the new cloud session in the same pane. It does not open a separate tab, split, or replacement conversation unless the user separately chooses another navigation action.
13. The same logical conversation/run identity is preserved across the original transcript and every follow-up execution.
14. New output from the follow-up appears after the existing transcript content in chronological order.
15. Returning to the conversation list, agent management view, details panel, or another navigation surface should focus the already-open pane for this task while it is open.
16. If a follow-up execution finishes, the pane returns to a completed-between-executions state and can offer cloud `Continue` again when the task is still resumable.
17. Repeated follow-ups are allowed. A second or later follow-up should behave the same as the first: prompt input, setup/progress, same-pane session attachment, and preserved conversation identity.
18. A failed, blocked, cancelled, timed-out, unauthorized, or quota-limited follow-up attempt surfaces through the same user-facing Cloud Mode error/auth/capacity/credits states used by cloud task startup.
19. If the follow-up request fails before the prompt is accepted, the users prompt is restored in the input so they can edit or retry.
20. If the follow-up request is accepted but the new execution fails before a session is available, the pane shows the appropriate failure state and does not silently discard the completed conversation history.
21. Closing the pane while a follow-up execution is starting stops only the local viewing/waiting experience. It must not imply that the remote cloud run is cancelled unless the user explicitly invokes a cancel action.
22. Opening a completed local agent conversation still uses the existing local transcript behavior. Cloud continuation is only for cloud/ambient agent conversations that are resumable in cloud mode.
23. Opening a non-owned or otherwise non-resumable cloud transcript still works as a read-only transcript. It must not incorrectly expose cloud continuation.
24. If the cloud-to-cloud continuation feature is unavailable or disabled, completed cloud conversations continue to use the existing transcript/tombstone behavior.
25. Existing transcript share/open behavior should continue to work while the view is idle between cloud executions.
26. Keyboard focus should move to the prompt input after cloud `Continue` is clicked. Users should not need to click the input manually before typing the follow-up prompt.
27. The UI should avoid stacking confusing duplicate tombstones for the same between-executions state. After a follow-up starts, the old tombstone should not remain as an actionable end marker above the active setup/progress state.
28. The generic “Couldn't continue this cloud task.” toast is only acceptable for genuinely unexpected inconsistencies. It should not be part of the normal completed-cloud-task continuation flow.
@@ -0,0 +1,91 @@
# Cloud-to-cloud handoff resumable completed cloud panes
## Context
`PRODUCT.md` defines the target behavior: completed cloud conversations should restore into Cloud Mode panes seeded with prior conversation history, keep the completed transcript/tombstone presentation, and become resumable in cloud mode when cloud-to-cloud handoff is available.
The follow-up execution path mostly exists. `ConversationEndedTombstoneView` creates a cloud `Continue` action when it has an ambient task id and `FeatureFlag::HandoffCloudCloud` is enabled in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs (202-235)`, then emits `ConversationEndedTombstoneEvent::ContinueInCloud` from `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs (631-640)`. `TerminalView::start_cloud_followup_from_tombstone` sets `pending_cloud_followup_task_id` and focuses the existing input in `app/src/terminal/view/shared_session/view_impl.rs (810-842)`. `TerminalView::try_submit_pending_cloud_followup` routes the next prompt through `AmbientAgentViewModel::submit_cloud_followup` in `app/src/terminal/view.rs (19979-20075)`. The ambient model emits `FollowupSessionReady` after polling for a fresh session in `app/src/terminal/view/ambient_agent/model.rs (686-744)`, and the deferred Cloud Mode manager attaches it through `TerminalManager::attach_followup_session` in `app/src/terminal/view/ambient_agent/mod.rs (60-118)` and `app/src/terminal/shared_session/viewer/terminal_manager.rs (310-384)`.
The follow-up submit trigger should be explicit Cloud Mode behavior, not a side effect of shared-session viewer submission. Fresh Cloud Mode prompts already bypass `Input::submit_ai_query` while the ambient model is in `Composing` and call `AmbientAgentViewModel::spawn_agent` directly. Follow-up prompts should follow the same pattern: before generic AI submission and before generic shared-session viewer permission checks, `Input` should detect a disconnected Cloud Mode follow-up composer and emit a dedicated Cloud follow-up event handled by `TerminalView::try_submit_pending_cloud_followup`. Both cases must use this same path: a pane whose initial cloud execution just ended, and a pane restored from ambient conversation history.
Disconnected Cloud Mode panes should also stop representing themselves as shared-session viewers. `SharedSessionStatus::ViewPending`, `ActiveViewer`, and `FinishedViewer` should mean a live or ended collaborative viewer state with shared-session permission semantics. A fresh, restored, or between-executions Cloud Mode pane that has no attached session should instead use `SharedSessionStatus::NotShared` plus the existing Cloud Mode/ambient model state. `connect_session` and `attach_followup_session` should transition into `ViewPending` only when a real shared-session id is available.
The broken path is pane construction. `TerminalView::new` creates `ambient_agent_view_model` only when `is_cloud_mode` is true in `app/src/terminal/view.rs (3003-3030)`. Historical transcript panes are currently created through `MockTerminalManager::create_model` in `app/src/pane_group/mod.rs (5675-5728)` and loaded through `PaneGroup::load_data_into_transcript_viewer` in `app/src/pane_group/mod.rs (3825-3928)`. Those panes can have `ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id)`, but that is a workaround for treating a generic transcript viewer like an ambient session. It still does not give the pane the ambient model or deferred viewer manager needed to submit and attach a cloud follow-up.
`WorkspaceAction::OpenConversationTranscriptViewer` already carries `ambient_agent_task_id`, but the action handler discards it and calls `load_cloud_conversation_into_new_transcript_viewer(conversation_id, ctx)` in `app/src/workspace/view.rs (21677-21698)`. The loader then creates a generic transcript-loading tab in `app/src/workspace/view.rs (3998-4054)`. By the time cloud conversation data is loaded, `PaneGroup` can recover an ambient task id from metadata, but it still mutates a non-cloud transcript viewer rather than creating a resumable cloud viewer.
The desired invariant is that viewing any ambient agent conversation, including a restored completed one, puts the user in a Cloud Mode pane. The fetched conversation history is seeded UI state for scrollback and completed-tombstone presentation, not the server-side agent context for the next follow-up request. Generic transcript-viewer state should remain only for non-ambient conversations and loading/fallback paths.
## Proposed changes
### Preserve ambient task identity through restore loading
Change `Workspace::load_cloud_conversation_into_new_transcript_viewer` to accept `ambient_agent_task_id: Option<AmbientAgentTaskId>`, and pass the actions task id through from `WorkspaceAction::OpenConversationTranscriptViewer`.
When `load_conversation_from_server` returns `CloudConversationData`, resolve the effective task id as:
1. the task id from the action, if present;
2. the task id from cloud conversation metadata, if present;
3. `None`.
Use the effective task id to choose the pane construction path, seed the ambient model, register active ambient views, and decide whether the tombstone can offer cloud `Continue`.
### Route ambient restores into Cloud Mode pane construction
Add a PaneGroup helper that creates a restored ambient Cloud Mode pane when all of these are true:
- `FeatureFlag::HandoffCloudCloud` is enabled;
- an effective ambient task id exists;
- the loaded conversation is a cloud/ambient conversation, not a purely local transcript.
A concrete shape:
- `PaneGroup::create_restored_ambient_cloud_mode_pane(conversation, task_id, resources, initial_size, ctx)`
- `PaneGroup::replace_loading_pane_with_restored_ambient_cloud_mode_pane(loading_pane_id, cloud_conversation, task_id, ctx)`
- or a branch in the cloud-conversation loading callback that replaces the loading pane with a Cloud Mode pane instead of calling the generic transcript restoration path.
The helper should use `terminal::view::ambient_agent::create_cloud_mode_view` or the existing `PaneGroup::create_cloud_mode_terminal` wrapper, then restore the fetched conversation history into that view. This gives the view `ambient_agent_view_model`, the `FollowupSessionReady` subscription, and a deferred viewer manager that can later call `attach_followup_session`.
Do not create a `MockTerminalManager` transcript viewer and then retrofit Cloud Mode behavior onto it. Also do not set `ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id)` for the new path. The pane should be ambient because it is a Cloud Mode pane, not because a transcript-viewer marker carries a task id.
### Seed the Cloud Mode pane with historical UI state
After creating the restored Cloud Mode pane:
- restore the fetched conversation into the terminal view so prior blocks and rich content render as historical UI state;
- insert the completed-conversation tombstone at the end of the restored history;
- call `AmbientAgentViewModel::enter_viewing_existing_session(task_id, ctx)` so the model stores the stable task id and fetches run config metadata;
- if the loaded conversation has a local `AIConversationId`, call `set_conversation_id(Some(id))`;
- register `ActiveAgentViewsModel::register_ambient_session(terminal_view.id(), task_id, ctx)`;
- enter the same agent-view/header/details state as a normal Cloud Mode pane for that task.
This should mirror existing remote-child restoration in `app/src/pane_group/mod.rs (3161-3258)` and the existing ambient Cloud Mode construction path in `app/src/pane_group/mod.rs (3249-3276)`. The restored history is only the panes initial UI state. `submit_cloud_followup` should still send the follow-up prompt and task/session identity through the existing cloud-to-cloud follow-up API rather than replaying the transcript as prompt context.
If the current `AmbientAgentViewModel::Status::AgentRunning` is too strong for a completed task with no live session, introduce or reuse a between-executions state that represents “viewing an existing ambient task with no active session.” The important behavior is that the pane looks like the completed Cloud Mode state produced after a fresh Cloud Mode execution ends and the VM/session is no longer active.
When an ambient shared session ends, owner panes should transition back to this disconnected Cloud Mode follow-up composer state and use `SharedSessionStatus::NotShared`. Non-owner panes should remain read-only ended viewer surfaces and must not expose editable Cloud follow-up input.
### Use one Cloud follow-up submission path
Do not maintain separate follow-up submission paths based on whether the pane was previously attached to a shared session. The post-session-ended case and restored-from-history case should converge before submission:
- the pane is in disconnected Cloud Mode state;
- it has an ambient task id in `AmbientAgentViewModel`;
- the next non-empty prompt is submitted through `TerminalView::try_submit_pending_cloud_followup`;
- `try_submit_pending_cloud_followup` calls `AmbientAgentViewModel::submit_cloud_followup`;
- the ambient model emits `FollowupDispatched` and later `FollowupSessionReady`;
- the deferred Cloud Mode manager attaches the fresh shared session through `attach_followup_session`.
`Input::submit_ai_query` should no longer be the mechanism that routes follow-up prompts through `submit_viewer_ai_query` and `InputEvent::SendAgentPrompt`. That path is still appropriate for a true live shared-session viewer sending a prompt to the sharer, but it is not the semantic model for starting a new cloud follow-up execution.
### Stop using ambient transcript-viewer status as an ambient identity source
Remove `ConversationTranscriptViewerStatus::ViewingAmbientConversation` from the new design. If the variant can be deleted cleanly, replace its call sites with Cloud Mode predicates:
- `TerminalModel::ambient_agent_task_id` should derive ambient identity from `shared_session_source_type` and/or the `AmbientAgentViewModel`, not from transcript-viewer status.
- `TerminalPane::snapshot` should snapshot restored ambient panes as `LeafContents::AmbientAgent`, because they are Cloud Mode panes.
- tab and pane-header ambient indicators should use `is_shared_ambient_agent_session`, `TerminalView::ambient_agent_view_model`, or another explicit Cloud Mode ambient predicate instead of checking transcript-viewer status.
- transcript share-link behavior should remain on true transcript viewers; restored ambient panes should use the Cloud Mode/shared-session share/open behavior available while idle between executions.
It is acceptable to keep `ConversationTranscriptViewerStatus::Loading` and `ViewingLocalConversation` for generic local/non-ambient transcript viewers. The key invariant is that ambient restored conversations do not become read-only because they are marked as transcript viewers.
### Keep generic transcript behavior as the fallback
Continue using the current `MockTerminalManager` transcript path when:
- `HandoffCloudCloud` is disabled;
- no effective task id exists;
- the conversation data is not cloud/ambient;
- the pane is opened on a platform or flow that cannot continue cloud runs.
This preserves `PRODUCT.md` invariants 22-24 and keeps the fix scoped to resumable cloud tasks.
### Align tombstone capability with pane capability
`ConversationEndedTombstoneView::new` currently infers cloud Continue eligibility from task id plus feature flag. Prefer adding a capability argument from `TerminalView::insert_conversation_ended_tombstone`, for example `CloudContinueCapability::Available(AmbientAgentTaskId)` vs `Unavailable`, so the button reflects whether the surrounding view can actually submit a cloud follow-up.
If restored ambient panes are always Cloud Mode panes with an ambient model, the first implementation can keep the existing constructor logic and add a regression test. The capability argument is safer because it prevents a stale task id on a generic transcript viewer from showing a cloud `Continue` action that can only fall back to the generic toast.
## Testing and validation
Map tests to `PRODUCT.md` behavior invariants instead of duplicating product requirements:
- Invariants 1-5 and 22-24: add PaneGroup/workspace tests that opening a completed cloud task with `HandoffCloudCloud` enabled creates a Cloud Mode pane with an `AmbientAgentViewModel` and no ambient transcript-viewer status, while disabled/no-task/local cases use the existing transcript path.
- Invariants 6-12 and 26-28: add terminal/shared-session tests that tombstone Continue in a restored ambient Cloud Mode pane sets `pending_cloud_followup_task_id`, focuses input, submits through `AmbientAgentViewModel`, and does not hit the generic toast path.
- Add an input/terminal test that a disconnected Cloud Mode follow-up prompt emits the dedicated Cloud follow-up event before generic shared-session viewer gating. Cover both restored panes and panes whose initial shared session just ended so they share the same submission path.
- Add a state test that fresh/restored/between-executions Cloud Mode panes with no live session use `SharedSessionStatus::NotShared`, then transition to `ViewPending` only when `connect_session` or `attach_followup_session` starts joining a real session.
- Invariants 13-17 and 25: add tests that the view remains registered for the same ambient task, repeated `FollowupSessionReady` attaches through the deferred manager, and follow-up completion returns the pane to a between-executions Cloud Mode tombstone state.
- Invariants 18-21: extend existing follow-up error tests to cover transcript-originated follow-ups, including prompt restoration before request acceptance and Cloud Mode error state after accepted-but-failed startup.
Targeted checks:
- `cargo nextest run -p warp terminal::view::shared_session::view_impl_test terminal::view_test`
- `cargo nextest run -p warp pane_group::mod_tests ai::agent_conversations_model_tests`
- `cargo check -p warp --features handoff_cloud_cloud`
Use the repo-standard formatting command when preparing the PR; do not run `cargo fmt --all` or file-specific `cargo fmt`.
## Risks and mitigations
### Duplicate transcript restoration
Creating a Cloud Mode view and then loading historical blocks could duplicate content if a follow-up session replays prior blocks. Keep follow-up joins on `SharedSessionInitialLoadMode::AppendFollowupScrollback` and rely on the existing block-id dedupe in `TerminalModel::append_followup_shared_session_scrollback`.
### Completed Cloud Mode state does not currently have a precise model status
`AmbientAgentViewModel::enter_viewing_existing_session` currently sets `Status::AgentRunning`, which may not accurately describe a completed task with no live session. If this causes incorrect setup/progress/footer behavior, add a between-executions/viewing-existing status instead of falling back to transcript-viewer state.
### Read-only transcript state blocks input
`TerminalModel::is_read_only` returns true when `conversation_transcript_viewer_status` is set in `app/src/terminal/model/terminal_model.rs (1534-1554)`. Restored ambient panes should avoid setting transcript-viewer status entirely so cloud follow-up input is not blocked. Generic transcript viewers can keep the existing read-only behavior.
### Manager/view mismatch
Retrofitting a `MockTerminalManager` transcript viewer with cloud follow-up subscriptions would duplicate Cloud Mode setup logic. Replace the loading pane with a deferred shared-session viewer manager for restored ambient conversations instead.
### Authorization and stale task state
The client may think a task is resumable when the server rejects follow-up creation or returns stale no-execution data. Treat the follow-up API as authoritative and surface errors through existing Cloud Mode error/auth/capacity/credits states.
## Parallelization
This fix is small enough to implement sequentially because the key change is one coherent pane-construction path. If split, one agent can own workspace/pane-group creation and restore behavior while another owns tombstone/input/read-only tests. They converge at the restored ambient Cloud Mode pane helper and should avoid simultaneous edits to the same `PaneGroup` creation functions.
+161
View File
@@ -0,0 +1,161 @@
# Remote DiffStateModel
Linear: [APP-4351](https://linear.app/warpdotdev/issue/APP-4351/update-diffstatemodel-api)
## Context
`DiffStateModel` (`app/src/code_review/diff_state.rs`) is a per-repo model that owns a `Repository` handle, fs watcher subscription, diff loading, metadata refresh, and mode selection. It emits `DiffStateModelEvent` with four variants: `CurrentBranchChanged`, `NewDiffsComputed`, `SingleFileUpdated`, `MetadataRefreshed`. `CodeReviewView` subscribes to these events and renders diffs identically regardless of how they were produced.
`WorkingDirectoriesModel` (`app/src/pane_group/working_directories.rs`) stores `diff_state_models: HashMap<PathBuf, ModelHandle<DiffStateModel>>` and lazily creates models via `get_or_create_diff_state_model`. `CodeReviewView` holds a `ModelHandle<DiffStateModel>` obtained from this map.
The remote server protocol (`crates/remote_server/proto/remote_server.proto`) uses length-prefixed protobuf over SSH stdio. Push events flow through `ClientEvent``RemoteServerManager::forward_client_event``RemoteServerManagerEvent` → app-layer subscribers. `ServerModel` (`app/src/remote_server/server_model.rs`) is the daemon-side orchestrator that dispatches `ClientMessage`s and sends `ServerMessage` responses and pushes.
Today `DiffStateModel` only works with local git repositories. To support code review on remote environments (SSH sessions), we need to split the model into local/remote variants behind a wrapper, add proto messages for diff state exchange, build a server-side `GlobalDiffStateModel` that manages diff state per (repo, mode), and implement a client-side `RemoteDiffStateModel` that receives server pushes.
## Proposed changes
### 1. Split DiffStateModel into wrapper + local + remote
Refactor `app/src/code_review/diff_state.rs` into a module directory `app/src/code_review/diff_state/`:
- `mod.rs``DiffStateModel` wrapper holding a `DiffStateBackend` enum (`Local` / `Remote`), delegating every read/write method to the active sub-model.
- `local.rs``LocalDiffStateModel` (renamed from the current `DiffStateModel`), retaining all existing behavior.
- `remote.rs``RemoteDiffStateModel`, initially a no-op stub with defaults for every read method.
The wrapper subscribes to the active sub-model and re-emits events via `forward_event`. `CodeReviewView` subscribes to the wrapper and renders diffs identically regardless of which backend is active. `DiffStateModelEvent` keeps its name — it's shared between local and remote.
**Additional mechanical changes in the split:**
- Wrap `NewDiffsComputed` payload in `Arc`: `Option<GitDiffWithBaseContent>``Option<Arc<GitDiffWithBaseContent>>` for cheap cloning during event forwarding.
- Simplify `DiffState::Loaded` to a unit variant (no inner `GitDiffData`). The diff payload is accessed via `DiffStateModel::get()` or arrives in the event itself.
- Update `WorkingDirectoriesModel`: cache key changes from `HashMap<PathBuf, ...>` to `HashMap<BufferLocation, ...>`, and `get_or_create_diff_state_model` takes `BufferLocation` instead of `PathBuf`. All call sites wrap paths in `BufferLocation::Local(...)`. `BufferLocation` (`app/src/code/buffer_location.rs`) already has `Local(PathBuf)` and `Remote(RemotePath)` variants with `Hash + Eq`.
- All callers in `code_review_view.rs`, `code_review_header/mod.rs`, `right_panel.rs` pass `ctx` to wrapper methods (the wrapper needs `AppContext` to dereference its inner `ModelHandle`).
### 2. Proto messages
Add to `crates/remote_server/proto/remote_server.proto`.
**Client → Server:**
- `GetDiffState { repo_path, mode }` — request/response. The server responds with a `GetDiffStateResponse` (snapshot or error), then pushes subsequent changes. Follows the `NavigatedToDirectory``NavigatedToDirectoryResponse` + `RepoMetadataSnapshot` pattern.
- `UnsubscribeDiffState { repo_path, mode }` — notification (fire-and-forget). Tells the server the client no longer needs updates for this (repo, mode).
- `DiscardFilesRequest { repo_path, files, should_stash, branch_name?, mode }` — request/response. Runs `git restore`/`git stash`/`git rm` on the remote filesystem for the specified files. `files` is a list of `FileStatusInfo { path, status }`. `should_stash` controls whether changes are stashed (recoverable) or discarded. `branch_name` specifies the branch to restore against (absent means HEAD). `mode` identifies which `(repo, mode)` diff state model the server should use — the server looks up the exact model via `DiffModelKey` rather than picking an arbitrary model for the repo.
**Server → Client:**
- `GetDiffStateResponse``oneof result { DiffStateSnapshot snapshot, DiffStateError error }`. Matches the `WriteFileResponse`/`RunCommandResponse` pattern.
- `DiffStateSnapshot` (push) — full state for a (repo, mode). Includes metadata + full `GitDiffData`. Pushed on structural changes (`NewDiffsComputed`).
- `DiffStateMetadataUpdate` (push) — metadata-only update for `MetadataRefreshed` events. Avoids re-serializing the entire diff payload on every 5-second throttled refresh.
- `DiffStateFileDelta` (push) — single-file diff update for `SingleFileUpdated` events. Carries one `FileDiff` + file path + updated metadata. Debounced at 2s on the server.
- `DiscardFilesResponse``oneof result { DiscardFilesSuccess, DiscardFilesError }`. Returned after processing a `DiscardFilesRequest`.
Wire into `ClientMessage.oneof` (field numbers 1820) and `ServerMessage.oneof` (field numbers 1822). Client: `get_diff_state = 18`, `unsubscribe_diff_state = 19`, `discard_files = 20`. Server: `get_diff_state_response = 18`, `diff_state_snapshot = 19`, `diff_state_metadata_update = 20`, `diff_state_file_delta = 21`, `discard_files_response = 22`. Current max field numbers: `ClientMessage` = 17 (`ResolveConflict`), `ServerMessage` = 17 (`ResolveConflictResponse`).
Sub-messages mirror the Rust domain types with two notable divergences:
- `DiffMode` and `GitFileStatus` use `oneof` (with per-variant wrapper messages) instead of the Rust `enum` + struct pattern, since `oneof` maps more faithfully to Rust tagged enums with per-variant data.
- `FileDiff` collapses `FileDiff` + `FileDiffAndContent` into a single message with an `optional string content_at_base` field. The Rust split exists for memory reasons (`!Clone` on the content-carrying variant); on the wire, every context that sends a `FileDiff` also requires base content for editor rendering (`set_base`), so there's no case where the field is absent by design — only absent for binary files or failed `git show`.
Conversion lives in a new `diff_state_proto.rs`, following the `repo_metadata_proto.rs` pattern.
**No `RefreshDiffMetadata` message** — the server pushes metadata changes automatically via its watcher, matching the `RepoMetadata` pattern.
**No `ChangeDiffMode` message** — mode changes are handled client-side: `RemoteDiffStateModel.set_diff_mode()` sends `UnsubscribeDiffState` for the old mode, resets internal state, then sends `GetDiffState` for the new mode (see §5). The server's per-model mode remains immutable (shared across connections), but the client model manages the transition internally.
**Rust wire types** (in a new shared module, e.g. `diff_state_wire.rs` — both client and server need these types):
```rust path=null start=null
/// Wire payload for a full diff state snapshot (after routing).
pub struct DiffStateSnapshotData {
pub metadata: Option<DiffMetadata>,
pub state: DiffState,
pub diffs: Option<GitDiffData>, // present when state is Loaded
}
/// Wire payload for a single-file diff delta (after routing).
pub struct DiffStateFileDeltaData {
pub path: PathBuf,
pub diff: Option<FileDiff>,
pub metadata: Option<DiffMetadata>,
}
```
No new state enum — the existing `DiffState` has the right variants (`NotInRepository`, `Loading`, `Error(String)`, `Loaded`). After §1's changes, `Loaded` is a unit variant (no inner data); the diff payload is carried separately in `DiffStateSnapshotData.diffs`. The proto `oneof state` mirrors the variants directly.
**Wire ↔ event type bridging.** The wire `FileDiff` includes `content_at_base` (the file content at HEAD or merge-base), which the Rust side splits into `FileDiff` + `FileDiffAndContent`. On receipt, the `diff_state_proto.rs` conversion layer reconstructs `FileDiffAndContent { file_diff, content_at_head: proto.content_at_base }` from each wire `FileDiff`. The `RemoteDiffStateModel` wraps the full payload in `Arc` before emitting `NewDiffsComputed(Some(Arc::new(...)))`. This means remote code review editors receive base content eagerly — the same as local — and can call `set_base()` immediately without a separate RPC.
### 3. Server-side GlobalDiffStateModel
New file: `app/src/remote_server/diff_state_tracker.rs`.
```rust path=null start=null
#[derive(Hash, Eq, PartialEq, Clone)]
struct DiffModelKey {
repo_path: StandardizedPath,
mode: DiffMode,
}
pub struct GlobalDiffStateModel {
states: HashMap<DiffModelKey, ModelHandle<LocalDiffStateModel>>,
/// key → connections: used for push fan-out and orphan detection.
key_to_connections: HashMap<DiffModelKey, HashSet<ConnectionId>>,
}
```
`DiffModelKey` uses `StandardizedPath` (not `RepositoryIdentifier`) because the server daemon only manages local-to-the-remote-host repositories — the `Remote` variant of `RepositoryIdentifier` is never used on the server side. This matches the existing `ServerModel` convention where per-connection state is keyed on `StandardizedPath` (e.g. `snapshot_sent_roots_by_connection`).
**Per-(repo, mode) models with immutable mode.** The server keys models on `(repo_path, mode)`.
**Lifecycle:**
1. `GetDiffState` arrives as a request. `GlobalDiffStateModel` looks up or creates a `LocalDiffStateModel` for the key. If already loaded, responds immediately. If loading, uses `ctx.spawn` to respond once `NewDiffsComputed` fires. Reuses `Repository` handles from `DetectedRepositories` (already detected by prior `NavigatedToDirectory`). If no repository has been detected yet (e.g. `GetDiffState` arrives before `NavigatedToDirectory`), responds with `DiffStateError` — the client retries after `NavigatedToDirectory` completes.
2. After responding, subsequent model events are pushed to subscribed connections only via `send_to_diff_state_subscribers(key, msg)`, which looks up `connections_by_key[key]`. Targeted sends avoid broadcasting large diff payloads (~500KB2MB).
3. `UnsubscribeDiffState` calls `unsubscribe_connection` for the specific key. If no subscribers remain, the model is dropped.
4. `remove_connection(conn_id)` iterates `key_to_connections` to find all keys the connection belongs to and calls `unsubscribe_connection` for each, dropping orphaned models.
**Event → push mapping:**
- `NewDiffsComputed` → full `DiffStateSnapshot`
- `MetadataRefreshed` → `DiffStateMetadataUpdate` (metadata only, no diffs)
- `CurrentBranchChanged` → `DiffStateMetadataUpdate` (metadata only — diffs for the new branch haven't been computed yet; `NewDiffsComputed` follows with actual diffs)
- `SingleFileUpdated` → `DiffStateFileDelta` (debounced at 2s)
### 4. RemoteDiffStateModel implementation
Fill in the no-op stub in `diff_state/remote.rs` (created in §1) to:
- Hold `repo_id: RepositoryIdentifier` (always `Remote` variant), `mode: DiffMode` (mutable), `state: DiffState`, `metadata: Option<DiffMetadata>`.
- Apply incoming `DiffStateSnapshotData`, `DiffStateMetadataUpdate`, and `DiffStateFileDeltaData` from server pushes.
- Reconstruct `FileDiffAndContent { file_diff, content_at_head }` from wire `FileDiff` (extracting `content_at_base` → `content_at_head`) and wrap `GitDiffData` → `Arc<GitDiffWithBaseContent>` before emitting events.
- Emit `DiffStateModelEvent` variants matching the server push mapping (§3).
- Own the subscribe/unsubscribe lifecycle for mode changes via `set_diff_mode()` (see §5).
`DiffMode` is mutable on the client-side `RemoteDiffStateModel`, matching `LocalDiffStateModel`'s existing pattern where `set_diff_mode` mutates the mode field in place and triggers a reload. The server-side model remains immutable (keyed on `(repo_path, mode)` and shared across connections), but that constraint doesn't apply to the per-client remote model. This keeps the wrapper's delegation symmetric between `Local` and `Remote` backends, and avoids the need to destroy/recreate the model handle on mode changes (which would require re-wiring event subscriptions and introduces subscribe-before-request race conditions).
**Required read API surface** (defined in the wrapper's delegation interface):
- Core: `get()`, `diff_mode()`, `get_current_branch_name()`, `get_main_branch_name()`, `get_stats_for_current_mode()`, `get_uncommitted_stats()`, `has_head()`
- Git operations (stubs for v1 — `GitOperationsInCodeReview` won't be enabled for remote): `is_git_operation_blocked()`, `pr_info()`, `is_pr_info_refreshing()`, `is_on_main_branch()`, `unpushed_commits()`, `upstream_ref()`, `upstream_differs_from_main()`
- Mutations: `set_diff_mode()`, `load_diffs_for_current_repo()`, `set_code_review_metadata_refresh_enabled()`, `discard_files()`, `refresh_metadata_and_pr_info()`
For v1, mutation methods that are local-only (`load_diffs_for_current_repo`, `refresh_metadata_and_pr_info`, `set_code_review_metadata_refresh_enabled`) remain no-ops on `RemoteDiffStateModel` — the server drives all state.
### 5. Mode changes and unsubscribe
`RemoteDiffStateModel.set_diff_mode()` handles mode transitions internally, mirroring how `LocalDiffStateModel.set_diff_mode()` mutates mode and triggers a reload:
1. Sends `UnsubscribeDiffState { repo_path, mode: old_mode }` to the server.
2. Updates `self.mode` to the new mode.
3. Resets internal state: `self.state = DiffState::Loading`, clears `self.diffs` and `self.metadata`.
4. Emits `NewDiffsComputed(None)` so the view shows a loading spinner.
5. Sends `GetDiffState { repo_path, mode: new_mode }` to the server.
6. Server responds → model applies the snapshot, transitions to `Loaded`, emits `NewDiffsComputed(Some(...))`.
Since the `ModelHandle` never changes, the wrapper's event subscription (set up once at construction) remains valid across mode changes — no re-wiring needed.
**Unsubscribe cases:** code review pane close, mode change, repo change (cycling), connection drop, `drop_unused_diff_state_models` (tab close).
### 6. New ClientEvent / RemoteServerManagerEvent variants
`ClientEvent` carries raw proto-derived data (`StandardizedPath`, `DiffMode`). `forward_client_event` in `RemoteServerManager` attaches `host_id`, constructs `RepositoryIdentifier::Remote(...)`, and emits the corresponding manager event. This follows the `RepoMetadataSnapshotReceived` pattern.
Three new variants each for `ClientEvent` and `RemoteServerManagerEvent`:
- `DiffStateSnapshotReceived`
- `DiffStateMetadataUpdateReceived`
- `DiffStateFileDeltaReceived`
`push_message_to_event` in `RemoteServerClient` maps the new `ServerMessage` variants to `ClientEvent` variants.
### 7. WorkingDirectoriesModel integration
After §1's cache key migration, `get_or_create_diff_state_model` accepts `BufferLocation` and the map uses `HashMap<BufferLocation, ModelHandle<DiffStateModel>>`. When a `BufferLocation::Remote(...)` is passed, `DiffStateModel::new` (the wrapper constructor) creates a `RemoteDiffStateModel` in `Loading` state, subscribes to its events, and sends `GetDiffState` to the server — mirroring how the `Local` branch creates and subscribes to `LocalDiffStateModel` today.
+90
View File
@@ -0,0 +1,90 @@
# Cloud-to-cloud handoff PR 3 tech spec
## Context
PR 3 adds the first user-visible cloud-to-cloud follow-up entrypoint. PR 1 added the disabled `HandoffCloudCloud` flag, typed follow-up API client, and execution-aware task accessors. PR 2 added model-level follow-up orchestration: `AmbientAgentViewModel::submit_cloud_followup` submits a prompt, waits for a fresh session, and emits `FollowupSessionReady`, while `create_cloud_mode_view` already routes that event to `viewer::TerminalManager::attach_followup_session` in `app/src/terminal/view/ambient_agent/mod.rs (69-82)`. This PR wires that orchestration into the terminal UI, shared-session end paths, task liveness model, replay filtering, and targeted tests.
The tombstone UI remains the generic `ConversationEndedTombstoneView`, but it now optionally receives an ambient `task_id`, builds a gated desktop `Continue` cloud action, and keeps the existing local/desktop actions in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs (176-262, 486-678)`. The view enriches display data from `AmbientAgentTask` and hides local continuation for non-Oz harnesses so third-party harness runs do not offer an unsupported local fork.
Ambient session end handling is split by ownership. Generic `TerminalView::on_session_share_ended` still performs broad session cleanup, but under CloudModeSetupV2 it can insert one tombstone for non-owned ambient viewer sessions before ending the share. Dedicated `on_ambient_agent_execution_ended` and `on_ambient_agent_session_ended` route through `handle_non_running_ambient_agent_task`, mark the task execution ended, refresh the details panel, and either enable owned follow-up input directly or insert a single tracked tombstone for viewer-style sessions in `app/src/terminal/view/shared_session/view_impl.rs (681-879)`. `viewer::TerminalManager::ambient_session_ended` remains the narrow path for execution boundaries and does not mark the pane as a finished viewer.
Terminal input submission still assumes an active shared-session network. `Input::submit_viewer_ai_query` freezes the input, collects context attachments, and emits `InputEvent::SendAgentPrompt` in `app/src/terminal/input.rs (12400-12545)`. `TerminalView::handle_input_event` forwards that as `TerminalViewEvent::SendAgentPrompt` in `app/src/terminal/view.rs (19655-19666)`, and the viewer manager sends it to the current `Network` in `app/src/terminal/shared_session/viewer/terminal_manager.rs (1389-1402)`. Between cloud executions there is intentionally no current network, so PR 3 needs a separate follow-up submission route.
Cloud Mode setup-v2 already has UI to show startup progress and errors from the ambient model. This PR extends `AmbientAgentViewModel` with `pending_followup_prompt`, `should_show_followup_progress`, and `optimistically_rendered_user_queries` so follow-up setup can show progress without duplicating prompts after replay in `app/src/terminal/view/ambient_agent/model.rs (137-179, 532-578)`. `CloudModeInitialUserQuery` is now paired with `CloudModeFollowupUserQuery`, both backed by the shared `render_user_query` styling in `app/src/terminal/view/ambient_agent/block/query.rs`.
## Goals
Show a cloud “Continue” entrypoint on eligible ambient Cloud Mode tombstones behind `HandoffCloudCloud`.
Keep “Continue locally” available and unchanged when the feature flag is off.
Use the existing terminal input/editor for the follow-up prompt instead of embedding a separate tombstone editor.
Route follow-up submission to `AmbientAgentViewModel::submit_cloud_followup`, not to the ended shared-session `Network`.
Reuse the setup-v2 loading/error UI while waiting for the new execution session.
Render the submitted follow-up prompt optimistically while setup is in progress.
Preserve the same terminal pane, local conversation, stable run/task ID, and hotswap attach path.
Avoid replaying already-rendered follow-up prompts into the existing conversation transcript.
## Non-goals
No first-class attachments support for cloud follow-up prompts unless it falls out naturally from the existing input path. It is acceptable for PR 3 to submit text-only follow-ups and leave file attachment support for a follow-up.
No changes to the server follow-up API.
No rollout enablement for `HandoffCloudCloud`.
No details-panel redesign or per-execution history UI.
No change to normal non-ambient shared-session ended behavior.
## Proposed changes
### Ambient execution-ended tombstone insertion
`TerminalView::on_ambient_agent_execution_ended(ctx)` now delegates to `handle_non_running_ambient_agent_task` without calling the full generic shared-session cleanup. The method keeps the terminal view/model, input, ambient view model, pane configuration, and shareable object alive for follow-up hotswap. `on_ambient_agent_session_ended` uses the same helper for task-liveness updates that arrive outside the live shared-session event path.
The helper marks the task execution ended in `AgentConversationsModel`, refreshes the details panel, and then gates UI updates on `HandoffCloudCloud`, `CloudModeSetupV2`, absence of an existing tombstone, and absence of a pending follow-up. Owned ambient panes do not receive a tombstone; if no live shared session remains, they call `enable_owned_cloud_followup_input(task_id, ctx)` so the user can keep typing in the existing input. Non-owned viewer panes insert the tracked tombstone.
The implementation uses a single tracked tombstone per terminal view via `conversation_ended_tombstone_view_id`. `insert_conversation_ended_tombstone` is idempotent and `remove_conversation_ended_tombstone` removes the card when the user starts a cloud follow-up from it. This avoids repeated cards during task state churn and keeps the UI focused on the current resumable boundary.
### Tombstone Continue action
`ConversationEndedTombstoneView` on desktop now creates an optional `Continue` cloud button when the tombstone has an ambient `task_id` and `FeatureFlag::HandoffCloudCloud` is enabled. Rendering additionally requires AI to be enabled and desktop-only compilation. The terminal-view subscription validates that the current ambient view model still owns the clicked `task_id` before entering follow-up compose mode.
The tombstone action is `ContinueInCloud { task_id }`. It records `AgentManagementTelemetryEvent::TombstoneContinueInCloud`, emits `ConversationEndedTombstoneEvent::ContinueInCloud`, and lets `TerminalView::start_cloud_followup_from_tombstone` remove the tombstone, focus the existing input, and set `pending_cloud_followup_task_id`.
Keep “Continue locally” visible for Oz/plain conversations. When both actions are visible, the cloud `Continue` button renders first, followed by `Continue locally`. For non-Oz harnesses, local continuation is hidden because those runs cannot be forked into a local Warp conversation. With `HandoffCloudCloud` disabled, the cloud button is not created.
### Follow-up input mode and submission route
`TerminalView` owns the follow-up compose state with `pending_cloud_followup_task_id: Option<AmbientAgentTaskId>`. Tombstone clicks and owned execution end paths call `reset_after_cloud_followup_submission`, set agent input mode, update pane configuration, and focus the existing input. This keeps the tombstone as a reveal/focus entrypoint rather than an editor.
When `InputEvent::SendAgentPrompt` arrives, `try_submit_pending_cloud_followup` intercepts it before the normal `TerminalViewEvent::SendAgentPrompt` path. It validates the feature flag, ambient model, and task ID, then calls `AmbientAgentViewModel::submit_cloud_followup(prompt, ctx)`. On success it resets the input after submission and returns without emitting to the ended shared-session network. On empty prompts it keeps the compose route active enough to restore agent input. On validation failure it restores the prompt into the input, clears pending follow-up state, focuses input, and shows an error toast.
Slash commands like `/fork` and `/fork-and-compact` keep the existing local behavior from `Input::submit_viewer_ai_query`; the follow-up route handles normal non-empty agent prompts.
### Loading UI and optimistic prompt rendering
PR 2 already sets `Status::WaitingForSession { kind: Followup }` and emits `FollowupDispatched`. PR 3 reuses that state to render setup-v2 loading UI while polling between executions after a follow-up is submitted.
`CloudModeFollowupUserQuery` renders the submitted follow-up prompt using the same `render_user_query` styling as `CloudModeInitialUserQuery` in `app/src/terminal/view/ambient_agent/block/query.rs`. It is inserted on `FollowupDispatched`, not on `DispatchedAgent`, so initial-run behavior remains unchanged.
The ambient view model exposes pending follow-up state through `pending_followup_prompt`, `should_show_followup_progress`, and optimistic query tracking. `FollowupDispatched` records the prompt once, inserts `CloudModeFollowupUserQuery`, and the model tracks rendered prompts in `optimistically_rendered_user_queries` so the same prompt is not rendered again during shared-session replay. Terminal states that end setup clear pending/progress state so rejected submissions do not permanently append optimistic UI.
### Error, retry, replay, and state cleanup
API or polling errors reuse the existing `AmbientAgentViewModelEvent::Failed`, auth, quota, and capacity events so the setup screen can show the same error UI as initial Cloud Mode. If submission fails before the model accepts it, `restore_followup_prompt_after_failed_submission` restores the prompt into the input, re-enters agent input mode, and focuses the input for retry.
If a follow-up is accepted but fails before a session becomes ready, the local conversation stays with the same task/run ID and the ambient status moves to the existing error/auth/cancelled UI states. Retry continues through the same pending follow-up/input path rather than allocating a new local conversation.
When `FollowupSessionReady` fires, follow-up compose/input state and pending optimistic state are cleared, and the existing `FollowupSessionReady -> attach_followup_session` hotswap path attaches the new shared session.
The shared-session replay controller adds `should_skip_current_replayed_response` and `should_skip_replayed_response_for_existing_conversation` to avoid duplicating a response that is already represented in the local conversation when replaying the new execution.
### Telemetry
Add tombstone cloud-follow-up click telemetry near the existing tombstone telemetry in `AgentManagementTelemetryEvent`. The implemented event is `TombstoneContinueInCloud { task_id }`, serialized with the stable task ID. Submission/session-ready/failure outcomes continue to rely on the existing ambient task and follow-up lifecycle telemetry rather than adding separate PR 3-specific events.
## End-to-end flow
1. A Cloud Mode execution ends and the viewer manager receives `SessionEnded`.
2. `ambient_session_ended` records the ended session ID and calls the ambient execution-ended terminal-view method.
3. The terminal view marks task execution ended, refreshes task/details state, and either enables owned follow-up input directly or inserts one tracked tombstone while keeping the pane/input resumable.
4. For non-owned viewer sessions, the user clicks “Continue” on the tombstone.
5. The terminal removes the tombstone, focuses the existing input, and marks the next normal agent prompt as a cloud follow-up.
6. The user submits a prompt.
7. The input/view routes the prompt to `AmbientAgentViewModel::submit_cloud_followup`.
8. The view inserts one optimistic follow-up user query, records it as rendered, and shows setup-v2 loading UI.
9. The model submits the follow-up API request and polls for a fresh session.
10. `FollowupSessionReady` reaches `create_cloud_mode_view`, which calls `attach_followup_session`.
11. The viewer manager joins the new shared session in append mode, and new output streams into the same terminal pane.
## Testing and validation
Unit tests cover the shipped seams: task active/joinable helpers and conversation display status in `app/src/ai/agent_conversations_model_tests.rs`; cloud follow-up compose restoration in `app/src/terminal/view_test.rs`; tombstone insertion/removal, owned follow-up input, stale task rejection, and task end handling in `app/src/terminal/view/shared_session/view_impl_test.rs`; and ambient session-end/network behavior plus replay handling in `app/src/terminal/shared_session/viewer/event_loop_test.rs`.
Remaining useful coverage is tombstone rendering for button visibility with `HandoffCloudCloud` on/off, with and without `task_id`, AI enabled/disabled, and local continuation hidden for non-Oz harnesses.
Ambient view coverage verifies that `FollowupDispatched` inserts/renders an optimistic follow-up prompt separately from `DispatchedAgent`, records rendered prompts, and clears/re-enables follow-up state for retry.
Viewer manager/event-loop coverage verifies that ambient `SessionEnded` inserts or enables follow-up UI without setting `SharedSessionStatus::FinishedViewer`, without sending prompts through a stale network, and without duplicating replayed responses.
Manual validation:
- with `HandoffCloudCloud` disabled, complete a Cloud Mode run and verify the tombstone is unchanged;
- with the flag enabled, complete a Cloud Mode run, click Continue, submit a prompt, verify setup UI appears, and verify a fresh shared session attaches in the same pane;
- repeat once to catch stale session IDs, duplicate tombstones, and subscription leaks;
- verify “Continue locally” still forks locally from the tombstone;
- verify a normal shared-session viewer still becomes read-only/finished when its session ends.
Targeted validation for this PR is `cargo check -p warp --features handoff_cloud_cloud`, focused ambient model/spawn tests from PR 2, and the new tombstone/input/viewer-manager tests. Before opening or updating the PR, follow repo rules for formatting and clippy; do not use `cargo fmt --all` or file-specific `cargo fmt`.
## Risks and mitigations
### Prompt routed to stale network
The largest correctness risk is accidentally sending the follow-up prompt through `TerminalViewEvent::SendAgentPrompt` to a missing or ended `Network`. Mitigate by making follow-up compose mode intercept submission before the viewer-manager network path.
### Tombstone insertion regresses generic viewer teardown
Generic `on_session_share_ended` has important cleanup for ordinary shared sessions, but it is too broad for resumable ambient executions. Mitigate by routing execution boundaries through dedicated ambient methods and keeping ordinary shared-session teardown behavior in `on_session_share_ended`.
### Optimistic prompt duplication
The follow-up prompt or response could appear once as optimistic/local UI and again from replayed shared-session scrollback. Mitigate by tracking `optimistically_rendered_user_queries` in the ambient model and using shared-session replay skip state for already-present conversation responses.
### Retry state drift
Failures between API acceptance and session readiness can leave input frozen or the tombstone hidden. Mitigate by centralizing cleanup on ambient model failure/cancel/auth events and ensuring the tombstone remains available.
### Stale tombstone state
A tombstone can become stale if task state changes while the view is idle. Mitigate by tracking only one tombstone ID, validating the clicked `task_id` against the current ambient model before composing, and removing the tombstone when cloud follow-up starts.
## Definition of done
With `HandoffCloudCloud` off, tombstone and shared-session behavior are unchanged.
With the flag on, eligible ambient Cloud Mode tombstones show a cloud Continue action while preserving Continue locally for Oz/plain conversations.
Clicking Continue focuses/reveals the existing terminal input and submitting a normal prompt calls `AmbientAgentViewModel::submit_cloud_followup`.
The follow-up prompt does not go through the ended shared-session network.
Setup-v2 loading/error UI appears while the follow-up session is starting, and already-rendered replay content is not duplicated.
An optimistic follow-up user query renders during setup without reusing initial-run dispatch UI.
When the new session is ready, the existing `FollowupSessionReady` hotswap path attaches it to the same pane.
Targeted tests and `cargo check -p warp --features handoff_cloud_cloud` pass.
+50
View File
@@ -0,0 +1,50 @@
# APP-4365: Use Queued Query UI for Oz Cloud Mode Queries
## Summary
When a user submits an initial or follow-up Oz cloud mode query, Warp should immediately show the same queued-query UI used for third-party cloud agents instead of inserting a bespoke optimistic user-query block. Setup-command rich content should continue to appear unchanged, and the real user query from the cloud session transcript should render normally once it arrives.
## Problem
Oz cloud mode currently uses a different pending-query presentation than third-party cloud agents. The bespoke optimistic query block requires special handling to hide the later real user-query element, making cloud mode behavior harder to reason about and causing Oz and third-party agent startup states to feel inconsistent.
## Goals
- Use one queued-query visual pattern for cloud submissions that are waiting for the cloud session to produce the real transcript.
- Keep setup-command rich content behavior unchanged.
- Let the real user query element render normally when it arrives from the cloud session transcript.
- Apply the behavior to both initial Oz cloud runs and Oz cloud follow-up runs.
## Non-goals
- Redesigning the queued-query UI.
- Changing third-party cloud agent queued-query behavior.
- Changing setup-command grouping, ordering, expansion, or collapse behavior.
- Changing the cloud submission API, follow-up API, or agent execution lifecycle.
- Adding new user controls to the queued-query card.
## Figma
Figma: none provided
Use the existing in-app third-party cloud agent queued-query UI as the reference.
## Behavior
1. When a user submits an initial Oz cloud mode query and the submission is accepted by Warp, the terminal immediately shows a queued-query UI item for that submitted prompt.
2. When a user submits an Oz cloud mode follow-up query after a cloud execution has ended and the submission is accepted by Warp, the terminal immediately shows a queued-query UI item for that submitted follow-up prompt.
3. The queued-query item for Oz uses the same visual pattern as third-party cloud agents in cloud mode:
- The submitted prompt is shown as the user-authored query.
- The item communicates that the query is queued or waiting.
- The item does not show dismiss or "send now" controls when those controls are absent from the third-party cloud queued-query pattern.
- The item uses the same user identity/avatar treatment as the third-party cloud queued-query pattern.
4. The queued-query item preserves the displayed prompt text the user expects to see. If the user submitted a cloud query through a mode prefix such as `/plan` or `/orchestrate`, the queued-query item shows the user-facing prompt form consistently with the rest of Warp's query UI.
5. Warp does not insert the bespoke Oz optimistic user-query block for initial Oz cloud mode queries.
6. Warp does not insert the bespoke Oz optimistic user-query block for Oz cloud follow-up queries.
7. Setup-command rich content remains unchanged. Any setup-command intro text, setup-command blocks, setup-command ordering, visibility, collapse state, and transitions continue to behave as they did before this feature.
8. The queued-query item does not replace setup-command rich content. If setup commands are executed while the cloud run is starting, the user sees both the queued-query state and the normal setup-command rich content in the same relative flow where pending query and setup progress are shown today.
9. The queued-query item remains visible while Warp is waiting for the cloud execution or follow-up session to become ready and no real transcript item for that submitted prompt is available yet.
10. The queued-query item remains visible after the shared session attaches if the real shared-session transcript has not yet delivered the submitted user query. Session readiness, setup-command output, progress updates, agent status updates, or generic agent output are not sufficient reasons to remove the queued-query item.
11. When the real shared-session transcript delivers the actual submitted user query, the queued-query item is removed or otherwise replaced so the user sees the query exactly once in the final transcript.
12. The real user query element from the cloud session transcript is not hidden merely because Warp previously showed a queued-query item for the same submitted prompt.
13. If the replayed or attached cloud transcript includes the submitted user query, that real user query renders using the normal transcript user-query presentation for Oz conversations.
14. If cloud session attach or replay delivers the real user query before the queued-query item has visibly rendered, Warp may skip showing the queued-query item, but the user must not see both a queued item and a duplicate real user query for the same submitted prompt at rest.
15. If the cloud submission fails before Warp accepts it, Warp should not leave behind a queued-query item for a query that was not actually queued. The user's prompt should remain available for retry according to the existing failed-submission behavior.
16. If the cloud submission is accepted but the run later fails, is cancelled, requires authentication, or hits another startup error before a real transcript item appears, the queued-query item follows the same lifecycle as the third-party cloud queued-query UI for that state.
17. Authentication, cancellation, capacity, quota, and startup error UI remains unchanged except for the absence of the bespoke Oz optimistic user-query block.
18. Starting a new Oz cloud run from an empty cloud compose state shows at most one queued-query item for the accepted initial prompt.
19. Starting an Oz cloud follow-up from a tombstone or other follow-up entrypoint shows at most one queued-query item for the accepted follow-up prompt.
20. Repeated lifecycle updates while the cloud run is starting do not insert duplicate queued-query items for the same accepted prompt.
21. If a user leaves and re-enters the relevant agent view while the cloud query is still waiting, the queued-query item remains associated with the same conversation context and does not appear in unrelated conversations.
22. Exiting the agent view or changing panes does not convert the queued-query item into a bespoke optimistic query block.
23. Queued-query UI for Oz does not affect the content or visibility of prior terminal output, prior agent responses, existing tombstones, or already-rendered setup-command blocks.
24. When the cloud run becomes live and begins streaming agent output, the transition from queued state to transcript state should feel continuous: the prompt is not lost, duplicated, or visually reordered around the first agent response.
25. The behavior is consistent between initial and follow-up Oz cloud queries. A user should not need to learn one pending-query presentation for the first cloud prompt and another for subsequent cloud prompts.
26. The behavior is consistent between Oz and third-party cloud agents wherever both are waiting for a cloud session to produce the real transcript. Any intentional differences should be limited to agent identity, iconography, or existing agent-specific transcript rendering, not the pending-query pattern.
+93
View File
@@ -0,0 +1,93 @@
# APP-4365: Use Queued Query UI for Oz Cloud Mode Queries — Tech Spec
## Context
`PRODUCT.md` defines the user-visible target: initial and follow-up Oz cloud mode prompts should use the same queued-query UI as third-party cloud agents, setup-command rich content should remain unchanged, and the real transcript query should render normally when it arrives.
Cloud mode panes are deferred shared-session viewers. `create_cloud_mode_view` creates a deferred viewer manager, subscribes it to `AmbientAgentViewModel`, and connects or appends a session on `SessionReady` / `FollowupSessionReady` in `app/src/terminal/view/ambient_agent/mod.rs (42-105)`. `is_cloud_agent_pre_first_exchange` identifies the setup interval after a session is ready but before the first exchange arrives, including the third-party harness-start escape hatch in `app/src/terminal/view/ambient_agent/mod.rs (110-162)`.
`AmbientAgentViewModel` owns cloud run state. It tracks `Status::WaitingForSession { kind: InitialRun | Followup }`, the selected harness, task/session IDs, pending follow-up prompt, and today also tracks Oz optimistic-query state via `has_inserted_cloud_mode_user_query_block` and `optimistically_rendered_user_queries` in `app/src/terminal/view/ambient_agent/model.rs (64-145)`. Initial cloud runs call `spawn_internal`, store the request, transition to `WaitingForSession`, and emit `DispatchedAgent` in `app/src/terminal/view/ambient_agent/model.rs (660-679)`. Follow-up runs call `submit_cloud_followup`, set `pending_followup_prompt`, transition to `WaitingForSession`, and emit `FollowupDispatched` in `app/src/terminal/view/ambient_agent/model.rs (492-529)`.
The queued-query visual pattern already exists as `PendingUserQueryBlock`: it renders the prompt with user avatar, dimmed text, and a `Queued` badge, with optional close/send-now controls in `app/src/ai/blocklist/block/pending_user_query_block.rs (16-175)`. `TerminalView::insert_cloud_mode_queued_user_query_block` inserts that block without buttons and without a queued callback, specifically for cloud mode lifecycle-owned prompts, in `app/src/terminal/view/pending_user_query.rs (76-91)`. It is inserted as rich content with `RichContentMetadata::PendingUserQuery` and `PinToBottom` in `app/src/terminal/view/pending_user_query.rs (28-74)` and `app/src/terminal/view/rich_content.rs (183-244)`.
The current event handling creates divergent UI. On `DispatchedAgent`, third-party harnesses rebuild the display prompt with `display_user_query_with_mode` and call `insert_cloud_mode_queued_user_query_block`, while Oz creates `CloudModeInitialUserQuery`, inserts it as rich content, sets `has_inserted_cloud_mode_user_query_block`, and records the stripped prompt as optimistic UI in `app/src/terminal/view/ambient_agent/view_impl.rs (129-189)`. On `FollowupDispatched`, Oz creates `CloudModeFollowupUserQuery`, inserts it, and records the optimistic prompt in `app/src/terminal/view/ambient_agent/view_impl.rs (200-229)`.
The bespoke optimistic views live in `app/src/terminal/view/ambient_agent/block/query.rs (20-159)`. They share normal AI query rendering and show a `Failed` label when the ambient model has an error. They are re-exported through `app/src/terminal/view/ambient_agent/block.rs (1-8)`.
The duplicate-hiding logic lives in the AI block renderer. `should_hide_ai_block_query_and_header` hides a real AI block query/header for the first cloud exchange or any recorded optimistic follow-up prompt while the viewer is live and not receiving replay in `app/src/ai/blocklist/block/view_impl.rs (99-123)`. `AIBlock::render` asks the ambient model for `has_inserted_cloud_mode_user_query_block` and `has_optimistic_user_query` before deciding whether to render the actual query/header in `app/src/ai/blocklist/block/view_impl.rs (842-907)`. Tests currently assert this hiding behavior in `app/src/ai/blocklist/block/view_impl/cloud_mode_setup_tests.rs (1-37)`.
Setup-command rich content is independent of the optimistic prompt blocks. `maybe_insert_setup_command_blocks` gates on CloudModeSetupV2 and `is_cloud_agent_pre_first_exchange`, then inserts the setup intro and setup-command block before the real terminal block in `app/src/terminal/view/ambient_agent/view_impl.rs (373-453)`. When the first Oz exchange is appended, `handle_ai_history_model_event` turns off `is_executing_oz_environment_startup_commands` before inserting the real AI block in `app/src/terminal/view.rs (5086-5159)`.
Follow-up submission is already routed away from the stale shared-session network. `try_submit_pending_cloud_followup` intercepts `InputEvent::SendAgentPrompt`, validates the task, calls `submit_cloud_followup`, and resets the input in `app/src/terminal/view.rs (19735-19794)`. If not intercepted, `TerminalViewEvent::SendAgentPrompt` still goes to the viewer network in `app/src/terminal/shared_session/viewer/terminal_manager.rs (1323-1398)`. Existing follow-up and tombstone tests cover this route in `app/src/terminal/view_test.rs (552-576)` and `app/src/terminal/view/shared_session/view_impl_test.rs (638-805)`.
## Proposed changes
### Use one cloud queued-query insertion path
Update `TerminalView::handle_ambient_agent_event` so both Oz and third-party cloud runs use `insert_cloud_mode_queued_user_query_block` while waiting for the real cloud transcript.
For `DispatchedAgent`:
- Keep the existing viewer short-circuit and `CloudModeSetupV2` gate.
- Remove the `is_third_party_harness()` branch that sends Oz down the bespoke optimistic path.
- Build the display prompt from `AmbientAgentViewModel::request()` using the existing `display_user_query_with_mode(request.mode, &request.prompt)` helper, matching the current third-party path and satisfying `PRODUCT.md` Behavior 4.
- Insert the queued-query block when the display prompt is non-empty.
- Do not call `set_has_inserted_cloud_mode_user_query_block` or `record_optimistic_user_query`.
For `FollowupDispatched`:
- Keep the conversation-status update to `ConversationStatus::InProgress`.
- Read `pending_followup_prompt()` and call `insert_cloud_mode_queued_user_query_block(prompt, ctx)` instead of creating `CloudModeFollowupUserQuery`.
- Do not record the prompt as optimistic UI.
This intentionally reuses `PendingUserQueryBlock` rather than `QueuedQueryModel`. `QueuedQueryModel` in `app/src/ai/blocklist/queued_query.rs (1-67)` is not currently wired into the terminal rich-content path, while `insert_cloud_mode_queued_user_query_block` is the existing third-party cloud UI surface the product spec names as the reference.
### Remove the queued item only when the real transcript user query arrives
Add cloud-specific removal at the point the real Oz AI exchange with a renderable user query is appended. In `handle_ai_history_model_event`, after turning off `is_executing_oz_environment_startup_commands` for ambient sessions and before inserting the `AIBlock`, call `remove_pending_user_query_block(ctx)` only when:
- `CloudModeSetupV2` is enabled,
- the terminal is an ambient agent session,
- the appended exchange belongs to the visible root task that will produce an AI block,
- the exchange contains an input that will render a user query for the submitted prompt, and
- `pending_user_query_view_id` is set.
The key invariant is that the queued item survives session attach and any intermediate shared-session output until the actual user-query element from the real shared-session response exists. `SessionReady`, `FollowupSessionReady`, progress updates, setup-command blocks, harness-start transitions, and generic output without a renderable user query must not remove the queued item.
The removal should not depend on prompt string matching unless the existing exchange/input model exposes a cheap exact display-query comparison. The cloud queued-query helper owns a single lifecycle-managed pending item, and the first visible real user-query exchange for that ambient conversation is the replacement surface. This avoids retaining a stale queued item while also avoiding the old real-query hiding logic. If the exchange is filtered out by `blocklist_filter::should_show_task_in_blocklist`, or if it does not contain a renderable user query, leave the pending item untouched until a later visible user-query exchange, failure, cancellation, auth event, or other terminal lifecycle event handles it.
Keep the existing error/auth/cancel cleanup at the top of `handle_ambient_agent_event` in `app/src/terminal/view/ambient_agent/view_impl.rs (111-126)`. That cleanup already removes lifecycle-owned cloud queued items and should continue to apply to Oz and third-party runs. Do not remove the queued item on `SessionReady` or `FollowupSessionReady`; those events mean a shared session exists, not that the real user-query transcript has rendered.
### Delete Oz optimistic-query rendering and tracking
Remove `CloudModeInitialUserQuery`, `CloudModeFollowupUserQuery`, and the private `render_user_query` helper from `app/src/terminal/view/ambient_agent/block/query.rs`. If that leaves the file empty, remove `mod query;` and `pub use query::*;` from `app/src/terminal/view/ambient_agent/block.rs`.
Remove `AmbientAgentViewModel` fields and methods that only support optimistic query hiding:
- `has_inserted_cloud_mode_user_query_block`
- `optimistically_rendered_user_queries`
- `has_inserted_cloud_mode_user_query_block()`
- `set_has_inserted_cloud_mode_user_query_block(...)`
- `record_optimistic_user_query(...)`
- `has_optimistic_user_query(...)`
Also remove their initialization and reset logic in `new` and `reset_for_new_cloud_prompt`.
Remove `should_hide_ai_block_query_and_header` from `app/src/ai/blocklist/block/view_impl.rs`, delete the ambient-model lookup in `AIBlock::render`, and let the existing `query_and_index` path render the real query/header normally. This implements `PRODUCT.md` Behavior 11-14 directly: the queued rich content is the transient placeholder, and the AI block is the real transcript representation.
### Preserve setup-command rich content
Do not change `maybe_insert_setup_command_blocks`, `CloudModeSetupTextBlock`, `CloudModeSetupCommandBlock`, `is_cloud_agent_pre_first_exchange`, or the block-list startup-command flags. The queued-query change should be limited to which pending prompt rich content is inserted and when it is removed. This preserves `PRODUCT.md` Behavior 7-8 and avoids changing the setup-command grouping/order behavior already covered by CloudModeSetupV2.
### Update comments and names where they become stale
Update comments on `insert_cloud_mode_queued_user_query_block` from “non-oz Cloud Mode run” to “Cloud Mode run waiting for the real transcript” so it accurately covers Oz and third-party runs. Update comments in `pending_user_query_view_id` only if needed; the field remains shared by normal `/queue` prompts and lifecycle-owned cloud queued prompts, but cloud insertion still uses the callback-free helper.
No new feature flag is needed. Initial-run behavior remains under `CloudModeSetupV2`, and cloud follow-up behavior remains reachable only through the existing `HandoffCloudCloud` follow-up entrypoints.
## End-to-end flow
1. User submits an initial Oz cloud prompt.
2. `AmbientAgentViewModel::spawn_internal` stores the stripped request and emits `DispatchedAgent`.
3. `TerminalView::handle_ambient_agent_event` reconstructs the display prompt and inserts a callback-free `PendingUserQueryBlock`.
4. Setup commands, if any, continue through `maybe_insert_setup_command_blocks`.
5. When the real shared-session response appends an Oz exchange with a renderable user query, `handle_ai_history_model_event` removes the queued-query rich content and inserts the normal `AIBlock`.
6. `AIBlock::render` renders the real query/header because the optimistic-query hiding gate is gone.
7. For follow-up prompts, `try_submit_pending_cloud_followup` calls `submit_cloud_followup`, `FollowupDispatched` inserts the same queued-query block, and the next appended exchange that contains the actual follow-up user query replaces it the same way.
## Testing and validation
Unit tests should cover the product invariants without depending on pixel rendering:
- Add or update a terminal view test that enables `CloudModeSetupV2`, dispatches an Oz initial cloud run, and asserts that `pending_user_query_view_id` is set and the corresponding rich content metadata is `PendingUserQuery`. This covers Behavior 1, 3, 5, 18, 20, and 25.
- Add the same assertion for a non-Oz harness to ensure the existing third-party path is unchanged. This covers Behavior 3 and 26.
- Add a follow-up test around `FollowupDispatched` or `try_submit_pending_cloud_followup` that asserts a queued-query block is inserted instead of an optimistic follow-up view. This covers Behavior 2, 6, 19, 20, and 25.
- Add a history-model append test for an ambient cloud conversation that starts with a queued-query block, appends a visible exchange containing a renderable user query, and asserts the pending block is removed while an AI block remains. This covers Behavior 9-14 and 24.
- Add a negative history/session test that attaches a session, emits progress/setup-command/generic output, or appends an exchange without a renderable user query, and asserts the queued-query block remains visible. This covers Behavior 10.
- Remove or rewrite `app/src/ai/blocklist/block/view_impl/cloud_mode_setup_tests.rs (1-37)`. The old assertions should no longer hold; if a lightweight replacement is useful, assert that normal AI block query/header rendering is not suppressed for cloud exchanges.
- Keep existing setup-command tests or add a regression assertion that `maybe_insert_setup_command_blocks` still inserts setup text and command blocks while a queued-query block exists and does not remove that queued item. This covers Behavior 7-8, 10, and 23.
- Keep existing follow-up route tests in `app/src/terminal/view_test.rs (552-576)` and `app/src/terminal/view/shared_session/view_impl_test.rs (638-805)` passing; extend them only if useful to assert queued-query insertion after `submit_cloud_followup`.
Targeted validation commands:
- `cargo test -p warp cloud_mode_setup_tests`
- `cargo test -p warp pending_cloud_followup`
- `cargo test -p warp append_followup`
- `cargo check -p warp --features handoff_cloud_cloud`
Before opening or updating a PR, follow repository rules for formatting and clippy. Do not use `cargo fmt --all` or file-specific `cargo fmt`.
Manual validation:
- With CloudModeSetupV2 enabled, submit an initial Oz cloud prompt and verify the queued-query item appears immediately, remains visible after session attach and while setup-command rich content runs, and is replaced only when the real transcript user query appears.
- Submit a follow-up from an eligible cloud tombstone or owned follow-up input and verify the same queued-query item appears and is replaced only by the real follow-up transcript query.
- Repeat the same initial run with a third-party harness to confirm its queued-query behavior is unchanged.
- Test failure/auth/cancel during startup and confirm the queued item follows the existing third-party lifecycle and no bespoke `Failed` optimistic-query block remains.
## Risks and mitigations
### Removing the queued item too early
`SessionReady`, setup-command output, progress events, or an exchange without a renderable user query can arrive before the real user-query transcript is rendered. Removing the queued item there would create a visible gap. Mitigate by removing only when a visible exchange contains the actual user query from the shared-session transcript.
### Removing unrelated `/queue` prompts
`pending_user_query_view_id` is shared by normal queued prompts and cloud lifecycle prompts. Mitigate by only using the new `AppendedExchange` removal inside ambient cloud-session handling, and only after a visible cloud exchange with a renderable user query is about to render.
### Prompt display mismatch for `/plan` and `/orchestrate`
The spawn request stores the stripped prompt and separate mode. Mitigate by keeping the existing `display_user_query_with_mode` reconstruction already used by third-party cloud runs.
### Setup-command regressions
Setup-command insertion is adjacent to the query UI in the same event flow. Mitigate by not touching `maybe_insert_setup_command_blocks` or startup-command flags, and by adding a regression check that setup blocks still render alongside the queued item.
### Stale optimistic-query code paths
Leaving optimistic tracking in place could continue hiding real transcript queries. Mitigate by deleting the model fields/methods and the AI block hiding helper in the same change that switches insertion to queued-query UI.
## Parallelization
Parallel implementation is not beneficial for this change. The work is tightly coupled across one event handler, one model cleanup, one AI-block render gate, and a small set of targeted tests; splitting it across agents would increase merge conflicts more than it would reduce wall-clock time.
@@ -0,0 +1,46 @@
# Increment 1: entry schema and merge builder
## Context
This increment adds the normalized projection API behind existing UI code. `AgentConversationsModel` already stores tasks and conversations separately in `app/src/ai/agent_conversations_model.rs (844-1901)`, and `ConversationOrTask` currently provides display helpers over borrowed raw data in `app/src/ai/agent_conversations_model.rs (399-813)`. The migration should add the new API without removing or migrating those call sites yet.
The builder must understand metadata-only conversations. `AIConversationMetadata` can represent local persisted conversations or cloud-only metadata in `app/src/ai/blocklist/history_model.rs (49-157)`. `merge_cloud_conversation_metadata` creates a local `AIConversationId` for new cloud-only metadata and records the server token in `server_token_to_conversation_id` in `app/src/ai/blocklist/history_model/conversation_loader.rs (334-453)`.
## Proposed changes
Add a module near `agent_conversations_model.rs`, for example `app/src/ai/agent_conversations_model/entry.rs` if splitting the file is practical, or keep the first pass in `agent_conversations_model.rs` to reduce churn. Define:
- `AgentConversationEntry`
- `AgentConversationEntryId`
- `AgentConversationIdentity`
- `AgentConversationDisplayData`
- `AgentConversationProvenance`
- `AgentConversationBackingData`
- `AgentConversationCapabilities`
Add an internal builder that consumes current model state and `AppContext`. The builder should:
1. build a map from server token to local conversation id from history metadata and loaded conversations;
2. build a map from run id to local conversation id using `conversation_id_for_agent_id`;
3. create one entry for each `AmbientAgentTask`, keyed by `AgentConversationEntryId::AmbientRun(task.run_id())`;
4. attach a matching local conversation id by run id first, then server token;
5. attach server token from the task, matching conversation metadata, or loaded conversation;
6. create `Conversation` entries for metadata/local conversations not already attached to an ambient run.
The display-data derivation should reuse existing `ConversationOrTask` behavior where possible during this increment, but the new type should not expose `ConversationOrTask` publicly. It is acceptable for the builder to use temporary private helpers to avoid duplicating all display logic in the first PR.
Capabilities should be conservative in this increment. `can_open` should mean the entry has at least one of: open ambient run id, local conversation id, or server token. `can_share`, `can_delete`, and `can_fork_locally` can mirror current behavior but should remain data booleans, not actions.
Add a new read API:
```rust
pub fn get_entries(
&self,
filters: &AgentManagementFilters,
app: &AppContext,
) -> Vec<AgentConversationEntry>
```
Returning a `Vec` is acceptable for the first pass because entries are owned snapshots and list sizes are small. Keep the existing `get_tasks_and_conversations` API for current UI.
## Testing and validation
Add unit tests in `app/src/ai/agent_conversations_model_tests.rs` for builder identity and dedupe behavior:
- task-only entry uses `AmbientRun` id and `AmbientRun` provenance;
- local-only entry uses `Conversation` id and `LocalInteractive` provenance;
- cloud metadata-only entry uses `Conversation` id and `CloudSyncedConversation` provenance with `has_cloud_data = true` and `has_loaded_conversation = false`;
- task plus local conversation matched by run id produces one `AmbientRun` entry with both ids attached;
- task plus local conversation matched by server token produces one `AmbientRun` entry with both ids attached;
- unrelated task and conversation produce two entries;
- child-agent cloud metadata skipped today by metadata merge remains skipped from entries.
Run the focused test module after implementation. This increment should not change rendered UI, so existing conversation list and management view behavior should remain unchanged.
## Risks and mitigations
### Duplicates from incomplete indexing
Build token and run-id indices before constructing entries. Keep the “ambient run owns row identity” rule centralized in one builder function.
### Too much display duplication
It is fine to temporarily delegate to private helpers based on `ConversationOrTask` as long as the public API is normalized. Increment 4 should remove the old helper path.
@@ -0,0 +1,50 @@
# Increment 2: navigation resolver and conversation list migration
## Context
The conversation list currently caches `ConversationOrTaskId`s in `ConversationListViewModel` and filters out task rows whose `get_session_status()` is not available in `app/src/workspace/view/conversation_list/view_model.rs (31-183)`. Rendering checks `conversation.get_open_action(None, app)` for cursor/clickability in `app/src/workspace/view/conversation_list/item.rs (377-407)`, and activation recomputes the action in `app/src/workspace/view/conversation_list/view.rs (535-548)`.
This can fail when a visible task row shadows a local conversation row but lacks fresh session/conversation fields. Increment 2 replaces that path with normalized entries and a dynamic navigation resolver.
## Proposed changes
Add an entry navigation resolver near `AgentConversationsModel`, for example:
```rust
pub enum AgentConversationNavigationSubject {
Entry(AgentConversationEntryId),
ServerToken(ServerConversationToken),
}
pub fn resolve_open_action(
subject: AgentConversationNavigationSubject,
restore_layout: Option<RestoreConversationLayout>,
app: &AppContext,
) -> Option<WorkspaceAction>
```
The resolver should re-read current state at click time. For `Entry`, resolve the latest `AgentConversationEntry` or equivalent identity refs from `AgentConversationsModel`. For `ServerToken`, resolve through `BlocklistAIHistoryModel::find_conversation_id_by_server_token` and fall back to transcript loading where the caller supports it.
Default resolver order for listed entries:
1. if an ambient run id is attached and `ActiveAgentViewsModel` has an open ambient session, focus/open that tab;
2. if a local conversation id is attached and currently open, focus it;
3. if the ambient run has an active execution with parseable `session_id`, open ambient shared session;
4. if a local conversation id is attached, restore/navigate to the local conversation with the requested layout;
5. if a server token is attached, open the cloud transcript viewer;
6. otherwise return `None`.
The resolver may initially return existing `WorkspaceAction` variants. If focusing an already-open ambient session still relies on `WorkspaceAction::OpenAmbientAgentSession` plus workspace fallback, keep that behavior but ensure the resolver prefers the open ambient identity before transcript fallback.
Migrate `ConversationListViewModel` to cache `AgentConversationEntryId` and `ConversationEntry { id, highlight_indices }`. It should source entries from `AgentConversationsModel::get_entries` with the same personal/all status defaults currently used by the conversation list. Do not filter out completed cloud entries simply because `get_session_status()` is unavailable; filter based on normalized `capabilities.can_open`.
Migrate `render_item` props to take `AgentConversationEntry` or a lightweight view data struct instead of `ConversationOrTask`. The leading icon can continue to use existing helper behavior if Increment 1 exposes enough display data; otherwise add a normalized icon helper that consumes `AgentConversationEntry`.
Migrate click/Enter to call `resolve_open_action(Entry(entry.id), None, ctx)`.
## Testing and validation
Add unit tests for the resolver:
- task row with matching local conversation but missing task `conversation_id` restores local conversation;
- task row with `session_link` but no parseable `session_id` does not claim session-open capability and falls back to local/server token when available;
- active ambient session is preferred over transcript opening;
- active local conversation is preferred over restoring into a new tab;
- server-token-only navigation can open transcript when no local id is known.
Add conversation-list view-model tests if existing harness support is sufficient:
- cloud metadata-only entries appear when openable by token;
- stale/unavailable session status does not hide a restorable local conversation attached to a task;
- search still matches titles from normalized display data.
Manual validation:
- open a local cloud-mode conversation that also has a task row and verify clicking the list item focuses/restores the local conversation even if the task has no active session;
- open a live cloud task and verify clicking the list item focuses/joins the live session;
- open a completed cloud run and verify clicking opens/restores transcript/local conversation consistently.
## Risks and mitigations
### Workspace action gaps
The existing workspace actions may not express “focus open ambient session by task id” directly. If needed, add a small focused action or keep using `OpenAmbientAgentSession` with the workspaces `find_tab_with_ambient_agent_conversation` fallback.
### UI state churn
Changing list item IDs can reset hover/selection state. Use `AgentConversationEntryId` as a stable key and preserve row state maps by that key.
@@ -0,0 +1,45 @@
# Increment 3: Agent Management and details migration
## Context
Agent Management currently calls `get_tasks_and_conversations` and then maps each `ConversationOrTask` into card state, artifacts, action buttons, copy links, and details-panel data in `app/src/ai/agent_management/view.rs (938-1357)`. Cards use `get_open_action(Some(NewTab), app)` for clickability in `app/src/ai/agent_management/view.rs (1710-1755)` and dispatch it from `AgentManagementViewAction::OpenSession` in `app/src/ai/agent_management/view.rs (2324-2379)`.
This surface also owns filtering by status, source, environment, harness, creator, created date, and artifacts. Those filters should move to normalized entries so they are based on the same merged identity/display data that drives navigation.
## Proposed changes
Replace the management lists `ManagementCardItemId` task/conversation split with `AgentConversationEntryId`. Card construction should use `AgentConversationEntry` fields directly:
- title from `display.title`;
- status from `display.status`;
- source from `display.source`;
- environment from `display.environment_id`;
- harness from `display.harness`;
- artifacts from `display.artifacts`;
- creator from `display.creator`;
- request usage and runtime from `display`.
Move filtering to the entry builder or add an entry-specific filter function. Prefer one filtering path that can be shared by the conversation list and Agent Management, with owner-specific behavior parameterized by `AgentManagementFilters`.
Replace card click and details-panel open actions with `resolve_open_action(Entry(entry.id), Some(RestoreConversationLayout::NewTab), ctx)`.
Replace copy-link derivation with a sibling resolver:
```rust
pub fn resolve_copy_link(
subject: AgentConversationNavigationSubject,
app: &AppContext,
) -> Option<String>
```
This should share identity resolution with `resolve_open_action` and avoid a separate `link_preference()` policy. Link policy can prefer a live session URL for active executions and otherwise use the server conversation token when available.
Update details panel construction so both task-backed and conversation-backed entries go through one normalized path. Keep raw task-only fields where they are truly run-specific, but they should come from attached run identity rather than from a separate card variant.
## Testing and validation
Add unit tests for entry filtering:
- stale raw `InProgress` task with terminal matching conversation filters into Done/Failed according to `AgentRunDisplayStatus`;
- environment filter includes local/cloud conversation entries as “None” and filters task environments correctly;
- harness filter handles task harness, local Oz conversation, and metadata-only cloud conversation;
- artifact filter works for artifacts sourced from task, loaded conversation, and metadata.
Add tests for copy-link resolver:
- active joinable session returns session link;
- non-active cloud-backed entry returns conversation link;
- local-only unsynced conversation returns no link;
- task with no token but attached local synced conversation returns conversation link.
Manual validation:
- Agent Management card click and details “Open” use the same destination as the conversation list for the same logical run;
- copy-link button and card click no longer disagree for completed cloud runs;
- details panel displays one coherent entry when task and local conversation both exist.
## Risks and mitigations
### Details panel field regressions
Some fields are genuinely task/run-specific. Keep `AgentConversationIdentity.ambient_run_id` available and fetch raw task data only for fields not represented in `display`.
### Filter behavior changes
Normalized filtering may intentionally move stale task rows between status buckets. Preserve current behavior only where it matches the derived display status policy.
@@ -0,0 +1,43 @@
# Increment 4: cleanup and hardening
## Context
After conversation list and Agent Management migrate to `AgentConversationEntry`, `ConversationOrTask` should no longer be the public model consumed by list/navigation/details surfaces. The old `link_preference()` helper in `app/src/ai/agent_conversations_model.rs (624-660)` should stop being a source of truth for open and copy-link behavior.
This increment removes transitional APIs, hardens event invalidation, and adds regression coverage around the originally inconsistent cases.
## Proposed changes
Remove or narrow public access to:
- `ConversationOrTask`
- `ConversationOrTaskId` usage in list/navigation surfaces
- `ConversationOrTask::get_open_action`
- `ConversationOrTask::session_or_conversation_link`
- `ConversationOrTask::link_preference`
If some internal helpers remain useful, make them private to the entry builder and rename them so they cannot be mistaken for the public entry API.
Audit all `get_open_action`, `session_or_conversation_link`, and `get_session_status` call sites. Remaining navigation should go through `resolve_open_action`, and remaining copy-link behavior should go through `resolve_copy_link`.
Review event handling in `AgentConversationsModel`:
- task updates should invalidate/re-emit entry updates;
- conversation status updates should refresh derived status and capabilities;
- server token assignment should update merged identity;
- cloud metadata merge should update entries;
- active view open/close/focus should update active/open capabilities without requiring stale nav data.
If existing `AgentConversationsModelEvent` variants are too ambiguous, add a normalized event such as:
```rust
pub enum AgentConversationsModelEvent {
EntriesChanged,
EntryDisplayDataChanged { id: AgentConversationEntryId },
EntryArtifactsChanged { id: AgentConversationEntryId },
}
```
Only do this if it reduces caller complexity; avoid event churn if all migrated consumers can simply rebuild from `get_entries`.
Update comments and docs in the model to describe the new ownership boundary: raw task/conversation caches are source data, while `AgentConversationEntry` is the UI/navigation projection.
## Testing and validation
Add regression tests named around the fixed behaviors:
- task shadows local conversation but missing task token still opens via local conversation;
- stale active execution no longer forces session open when no parseable session id exists;
- completed cloud run with token remains openable even without session link;
- copy-link and open resolver use consistent source priority;
- metadata-only cloud conversation can be opened by server token without a loaded `AIConversation`;
- server token assignment after an entry is first built updates identity/copy-link behavior.
Run all focused tests touched during increments 1-3 plus a targeted compile/check. Before review, run repository-required formatting and linting commands.
## Risks and mitigations
### Hidden call sites
Use grep for removed method names and old ID types. Keep this increment small and mechanical where possible.
### Event overengineering
Prefer simple rebuild-on-event behavior until performance requires finer invalidation. The entry list is small enough that correctness is more important than micro-optimizing derived state.
+93
View File
@@ -0,0 +1,93 @@
# Agent conversation entry normalization migration
## Context
Linear: https://linear.app/warpdotdev/issue/APP-4382/normalize-agent-conversation-entries-for-list-and-navigation-surfaces
`AgentConversationsModel` currently exposes `ConversationOrTask`, a wrapper over either `AmbientAgentTask` or `ConversationMetadata`, to list, details, filtering, and navigation surfaces in `app/src/ai/agent_conversations_model.rs (399-813)`. The wrapper centralizes several display helpers, but still encodes important behavior as “task vs conversation” decisions. The most problematic example is `link_preference()` and `get_open_action()` in `app/src/ai/agent_conversations_model.rs (624-813)`: task rows choose between session, conversation transcript, or no action from cached task fields, while local conversation rows restore/navigate from `ConversationNavigationData`.
The model also intentionally hides local conversation rows when a task appears to represent the same logical run in `app/src/ai/agent_conversations_model.rs (1382-1497)`. That shadowing is useful for preserving cloud-run affordances, but it means the visible task row can discard better local-conversation navigation data. A task row with a stale or incomplete `session_id`, `session_link`, `conversation_id`, or `is_sandbox_running` can therefore be unopenable even when the hidden conversation representation is restorable.
The underlying sources are already separate. `BlocklistAIHistoryModel` owns loaded `AIConversation` contents in `conversations_by_id` and lightweight historical/cloud metadata in `all_conversations_metadata` in `app/src/ai/blocklist/history_model.rs (49-214)`. Cloud metadata can exist without a loaded conversation via `AIConversationMetadata` and `merge_cloud_conversation_metadata` in `app/src/ai/blocklist/history_model/conversation_loader.rs (334-453)`, while `load_conversation_data` loads content only on demand in `app/src/ai/blocklist/history_model/conversation_loader.rs (145-236)`. `ActiveAgentViewsModel` separately tracks which local conversations and ambient sessions are currently open in `app/src/ai/active_agent_views_model.rs (45-531)`.
This migration introduces a projection-layer `AgentConversationEntry` for list/navigation/detail surfaces. It should not replace `AIConversation`, `BlocklistAIHistoryModel`, or transcript content APIs. Its job is to merge provenance and lightweight display data into one logical row so UI surfaces no longer choose between task and conversation representations.
## Proposed changes
### Target model boundary
Add a normalized entry API to `AgentConversationsModel` while keeping raw task and conversation storage as implementation details:
```rust
pub struct AgentConversationEntry {
pub id: AgentConversationEntryId,
pub identity: AgentConversationIdentity,
pub display: AgentConversationDisplayData,
pub provenance: AgentConversationProvenance,
pub backing: AgentConversationBackingData,
pub capabilities: AgentConversationCapabilities,
}
```
`AgentConversationEntry` is a projection. It carries owned IDs and display snapshots, not borrowed references to `AmbientAgentTask` or `AIConversation`. Callers that need transcript contents still go through `BlocklistAIHistoryModel` and loader APIs.
Use local/client identities for listed entries:
```rust
pub enum AgentConversationEntryId {
AmbientRun(AmbientAgentTaskId),
Conversation(AIConversationId),
}
```
Do not use `ServerConversationToken` as a normal list identity. Server tokens are global fetch/navigation handles; cloud-only metadata rows already receive a local `AIConversationId` when metadata is merged.
The entry identity should preserve every known reference:
```rust
pub struct AgentConversationIdentity {
pub local_conversation_id: Option<AIConversationId>,
pub ambient_run_id: Option<AmbientAgentTaskId>,
pub server_conversation_token: Option<ServerConversationToken>,
pub parent_conversation_id: Option<AIConversationId>,
pub parent_run_id: Option<AmbientAgentTaskId>,
}
```
Keep provenance semantic and move loadability into backing:
```rust
pub enum AgentConversationProvenance {
LocalInteractive,
AmbientRun,
CloudSyncedConversation,
}
pub struct AgentConversationBackingData {
pub has_loaded_conversation: bool,
pub has_local_persisted_data: bool,
pub has_cloud_data: bool,
pub has_ambient_run: bool,
}
```
`AgentConversationDisplayData` should centralize list/detail fields currently read from `ConversationOrTask`: title, initial query, created/updated timestamps, `AgentRunDisplayStatus`, creator, working directory, source, environment, harness, request usage, run time, and artifacts. The display status should continue to use the derived `AgentRunDisplayStatus` algorithm instead of raw `AmbientAgentTaskState` or raw `ConversationStatus`.
`AgentConversationCapabilities` should expose eligibility booleans for UI affordances such as open, copy link, share, delete, fork locally, continue locally, and cancel. It must not store the final `WorkspaceAction`. Open actions should be resolved dynamically from entry identity at click time.
### Increment sequence
Increment 1, `INCREMENT-1-entry-schema-and-builder.md`, adds the entry schema, merge builder, and tests without migrating UI. It introduces `AgentConversationsModel::get_entries(...)` or equivalent behind existing code paths.
Increment 2, `INCREMENT-2-navigation-and-conversation-list.md`, adds a dynamic navigation resolver and migrates the conversation list to normalized entries. This is the first user-visible behavior fix for task rows that shadow restorable conversations.
Increment 3, `INCREMENT-3-agent-management-and-details.md`, migrates Agent Management cards, filters, action buttons, and details panel data to normalized entries.
Increment 4, `INCREMENT-4-cleanup-and-hardening.md`, removes or narrows `ConversationOrTask`, deletes `link_preference()` as a source of truth, and adds regression coverage for previously inconsistent entrypoints.
## End-to-end flow
1. `AgentConversationsModel` keeps its existing task and conversation caches.
2. The entry builder indexes tasks by stable run id, conversations by local id, and metadata by server token.
3. For each ambient run, the builder creates one `AmbientRun` entry and attaches matching local conversation/server-token data when available.
4. For each local/cloud metadata conversation not already attached to a run, the builder creates one `Conversation` entry.
5. List/detail surfaces render from `AgentConversationEntry`.
6. On click, the navigation resolver re-reads current task, history, active-view, and workspace state before producing a `WorkspaceAction`.
## Testing and validation
The migration should be implemented as stacked PRs. Each increment spec lists its focused test coverage. Across the full migration, add tests for:
- local conversation only
- cloud metadata only with `has_local_data = false`
- task only with active joinable session
- task only with terminal state and server token
- task plus local conversation by run id
- task plus local conversation by server token
- task with stale in-progress state but terminal local conversation status
- task with `session_link` but no parseable `session_id`
- existing open local conversation focus
- existing open ambient session focus
- copied link and open action using the same resolved identity data
Before opening PRs, run the focused `agent_conversations_model` tests for each increment and a targeted app check. Follow repo PR workflow before review.
## Risks and mitigations
### Merge identity regressions
Wrong merge rules can duplicate rows or hide rows. Mitigate by writing the merge tests before migrating UI and keeping merge precedence explicit: ambient run owns the UI identity when present, but local conversation/server token refs remain attached.
### Recreating stale navigation in a new type
If entries cache `WorkspaceAction`, they will reproduce the current bug. Store identities and capabilities only; resolve final actions at click time.
### Overreaching into transcript content
`AgentConversationEntry` should not expose exchanges, block data, or content mutation methods. Keep transcript operations in `BlocklistAIHistoryModel` and loader paths.
### Incomplete event invalidation
Entries depend on task updates, history events, metadata updates, and active view changes. Initially prefer rebuilding entries on any relevant `AgentConversationsModelEvent` instead of maintaining fine-grained derived caches.
## Parallelization
Increment 1 should be implemented first and mostly sequentially because later increments depend on the entry schema. After that, Increment 2 and Increment 3 can be implemented on separate stacked branches if the entry API is stable. Increment 4 should wait until both UI migrations land.
+247
View File
@@ -0,0 +1,247 @@
# APP-4386 — SSH Remote Server Install Fallback (wget + SCP)
Linear: [APP-4386](https://linear.app/warpdotdev/issue/APP-4386)
## Context
When a user SSHes into a remote host, the client installs the remote server binary by piping `install_remote_server.sh` through `bash -s` on the remote. The script unconditionally uses `curl` (line 43) to download the tarball. On minimal hosts (Alpine, BusyBox, stripped Docker images), `curl` is absent → `bash: line 43: curl: command not found` (exit 127), and the install fails with no recovery path.
Other remote-development editors solve this with a multi-tier fallback strategy: try `curl` on the remote → fall back to `wget` → fall back to downloading locally and uploading via SCP. Warp currently has a single tier: curl only, no fallback.
### Relevant code
- `crates/remote_server/src/install_remote_server.sh` — the install script; line 43 is the sole `curl` invocation
- `crates/remote_server/src/setup.rs:385-414``INSTALL_SCRIPT_TEMPLATE` loaded via `include_str!`; `install_script()` substitutes placeholders (`{download_base_url}`, `{channel}`, `{install_dir}`, `{binary_name}`, `{version_query}`, `{version_suffix}`)
- `crates/remote_server/src/setup.rs:416-441``download_url()` and `download_channel()` construct the full CDN URL
- `app/src/remote_server/ssh_transport.rs:194-217``SshTransport::install_binary()` runs the script via `run_ssh_script` and surfaces success/failure
- `crates/remote_server/src/transport.rs:117-127``RemoteTransport::install_binary` trait method; returns `Result<(), String>`
- `crates/remote_server/src/ssh.rs:95-155``run_ssh_command` and `run_ssh_script` utilities
- `crates/remote_server/src/manager.rs:596-646``RemoteServerManager::install_binary` orchestrates the install, emits `SetupStateChanged` and `BinaryInstallComplete`
- `crates/remote_server/src/setup.rs:202-237``RemotePlatform`, `RemoteOs`, `RemoteArch` — already detected before install via `detect_platform`
## Proposed changes
Two phases, both in this PR.
### Phase 1: wget fallback in the shell script
Modify `install_remote_server.sh` to detect which HTTP client is available and use whichever is present. The download URL construction stays identical — only the download command changes.
Replace the current `curl` invocation (lines 43-44):
```bash
curl -fSL "{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}" \
-o "$tmpdir/oz.tar.gz"
```
With a detection block:
```bash
url="{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}"
if command -v curl >/dev/null 2>&1; then
curl -fSL "$url" -o "$tmpdir/oz.tar.gz"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmpdir/oz.tar.gz" "$url"
else
echo "error: neither curl nor wget is available" >&2
exit 3
fi
```
The exit code for "no HTTP client" is shared as a constant in `setup.rs` and injected into the script via placeholder substitution:
```rust
/// Exit code the install script uses when neither curl nor wget is
/// available on the remote host. The Rust side matches on this to
/// trigger the SCP upload fallback.
pub const NO_HTTP_CLIENT_EXIT_CODE: i32 = 3;
```
The script template uses `exit {no_http_client_exit_code}` instead of a hardcoded `exit 3`, and `install_script()` substitutes it alongside the existing placeholders.
Key details:
- `command -v` is POSIX-compliant and works on BusyBox `sh`, `dash`, `bash`, and `zsh`. Preferred over `which` (non-POSIX, absent on some minimal systems) and `type` (output format varies across shells).
- Exit code 3 is the next unused code after exit 1 (no binary in tarball) and exit 2 (unsupported arch/OS).
- `wget -q -O` matches the semantics of `curl -fSL -o`: quiet output, write to a specific file, follow redirects (wget follows by default, up to 20 hops). The `-f` (fail on HTTP errors) has no direct wget equivalent, but wget exits non-zero on 4xx/5xx by default.
- The `url` variable is extracted to avoid duplicating the long URL string between the curl and wget branches.
- The `{placeholder}` substitution from `setup.rs` is unchanged — no Rust changes needed for Phase 1 beyond the new constant and placeholder.
### Phase 2: SCP upload fallback in Rust
When the install script exits with code 3 (no HTTP client), the client downloads the tarball locally and uploads it to the remote via `scp` through the existing ControlMaster socket.
This requires changes across three layers:
#### 2a. New `download_url()` and `install_tarball_path()` public helpers in `setup.rs`
Expose the download URL construction so the Rust-side SCP fallback can download the same tarball the shell script would have fetched.
```rust
/// Returns the full download URL for the remote server tarball,
/// parameterized by the remote platform.
pub fn download_tarball_url(platform: &RemotePlatform) -> String {
format!(
"{}?package=tar&os={}&arch={}&channel={}{}",
download_url(),
platform.os.as_str(),
platform.arch.as_str(),
download_channel(),
version_query(),
)
}
/// Returns the remote path where the tarball should be uploaded
/// before the extraction script runs.
pub fn remote_tarball_staging_path() -> String {
format!("{}/oz-upload.tar.gz", remote_server_dir())
}
```
Also extract a `version_query()` helper (currently inlined in `install_script()`) so both the shell script and the Rust download path use the same query string.
#### 2b. New `scp_upload` utility in `ssh.rs`
Add an `scp` helper that uploads a local file to the remote through the ControlMaster socket:
```rust
/// Upload a local file to the remote host via `scp`, reusing the
/// ControlMaster socket for authentication. Returns `Ok(())` on
/// success or an error describing the failure.
pub async fn scp_upload(
socket_path: &Path,
local_path: &Path,
remote_path: &str,
timeout: Duration,
) -> Result<()> {
async {
Command::new("scp")
.arg("-o").arg(format!("ControlPath={}", socket_path.display()))
.arg("-o").arg("ControlMaster=no")
.arg("-o").arg("ConnectTimeout=15")
.arg(local_path.as_os_str())
.arg(format!("placeholder@placeholder:{remote_path}"))
.kill_on_drop(true)
.output()
.await
}
.with_timeout(timeout)
.await
.map_err(|_| anyhow!("scp timed out after {timeout:?}"))?
.map_err(|e| anyhow!("scp failed to execute: {e}"))
.and_then(|output| {
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!("scp failed (exit {:?}): {stderr}", output.status.code()))
}
})
}
```
Key details:
- Reuses the same ControlMaster socket (`-o ControlPath=...`) that all other SSH operations use — no re-authentication needed.
- `placeholder@placeholder` matches the convention in `ssh_args()` (`ssh.rs:26`): the ControlMaster socket already has the real user/host baked in, so the CLI arguments are placeholders.
- `ControlMaster=no` ensures scp joins the existing master session rather than trying to become one.
- Timeout uses `INSTALL_TIMEOUT` (60s) from the caller since the upload replaces the download step.
#### 2c. Shared extraction logic — single script with a tarball-path argument
Rather than maintaining two scripts with duplicated extraction code, refactor `install_remote_server.sh` so the download and extraction phases are cleanly separated within the same file. The script accepts an optional `$1` argument: a path to an already-uploaded tarball. When provided, the script skips the download phase entirely and extracts from that path. When omitted, it runs the curl/wget download as before.
```bash
if [ -n "$1" ]; then
# SCP fallback: tarball already uploaded by the client.
tarball_src="$1"
mv "$tarball_src" "$tmpdir/oz.tar.gz"
else
# Normal path: download via curl or wget.
url="{download_base_url}?package=tar&os=$os_name&arch=$arch_name&channel={channel}{version_query}"
if command -v curl >/dev/null 2>&1; then
curl -fSL "$url" -o "$tmpdir/oz.tar.gz"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmpdir/oz.tar.gz" "$url"
else
echo "error: neither curl nor wget is available" >&2
exit {no_http_client_exit_code}
fi
fi
# Shared extraction tail (unchanged from today's lines 45-50).
tar -xzf "$tmpdir/oz.tar.gz" -C "$tmpdir"
bin=$(find "$tmpdir" -type f -name 'oz*' ! -name '*.tar.gz' | head -n1)
if [ -z "$bin" ]; then echo "no binary found in tarball" >&2; exit 1; fi
chmod +x "$bin"
mv "$bin" "$install_dir/{binary_name}{version_suffix}"
```
The SCP fallback in Rust invokes the same script with the staging path as `$1` via `run_ssh_script` by passing `bash -s -- <staging_path>` (or equivalently prepending the argument to the script). This eliminates code duplication and ensures any future extraction changes (e.g. checksum verification) apply to both paths.
The `{staging_path}` used by the SCP fallback is the expanded form of `remote_tarball_staging_path()`.
#### 2d. Modify `SshTransport::install_binary` to orchestrate the fallback
Change `install_binary` in `ssh_transport.rs` from a single `run_ssh_script` call to a two-step flow:
1. Run the existing install script (now with wget fallback from Phase 1).
2. If the script exits with code 3, fall back to SCP:
a. Build the download URL using `download_tarball_url` with the `RemotePlatform` already detected by `detect_platform` (called earlier in the setup flow).
b. Download the tarball locally using the system's HTTP client (reqwest or a `curl` subprocess on the local machine — the local machine is guaranteed to have internet access).
c. Upload via `scp_upload` to the staging path.
d. Run the extraction-only script via `run_ssh_script`.
To support this, `SshTransport` needs access to the `RemotePlatform` detected earlier. Add it as a field set during construction or via a setter called by the controller after `detect_platform` succeeds (it's already called before `install_binary` in the setup flow at `manager.rs:500-530`).
```rust
fn install_binary(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> {
let socket_path = self.socket_path.clone();
let platform = self.platform.clone();
Box::pin(async move {
let script = remote_server::setup::install_script();
match remote_server::ssh::run_ssh_script(
&socket_path, &script, remote_server::setup::INSTALL_TIMEOUT,
).await {
Ok(output) if output.status.success() => Ok(()),
Ok(output) if output.status.code() == Some(remote_server::setup::NO_HTTP_CLIENT_EXIT_CODE) => {
// No HTTP client on remote — fall back to local download + SCP.
log::info!("Remote has no curl/wget, falling back to SCP upload");
let Some(platform) = platform else {
return Err("SCP fallback requires platform detection".into());
};
scp_install_fallback(&socket_path, &platform).await
}
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("install script failed (exit {code}): {stderr}"))
}
Err(e) => Err(format!("{e:#}")),
}
})
}
```
The `scp_install_fallback` helper:
1. Constructs the URL via `download_tarball_url(&platform)`.
2. Creates a local temp directory (`tempfile::tempdir()`) and downloads the tarball into it via a local `curl` subprocess. The temp directory is cleaned up automatically when the `TempDir` guard drops (including on early-return errors).
3. Calls `scp_upload` to transfer the local tarball to the remote staging path.
4. Re-invokes the install script with the staging path as `$1` so the shared extraction tail runs.
5. `TempDir` drop handles cleanup.
#### Error handling and timeout budget
The SCP fallback uses a separate, longer timeout:
```rust
/// Timeout for the SCP upload fallback path (local download + SCP + extraction).
/// Longer than `INSTALL_TIMEOUT` because SCP transfers the ~30-50 MB tarball
/// over the user's SSH link, which is typically slower than the remote host's
/// direct internet connection. On a 1 MB/s link, upload alone takes 30-50s.
pub const SCP_INSTALL_TIMEOUT: Duration = Duration::from_secs(120);
```
The standard `INSTALL_TIMEOUT` (60s) is sufficient for the curl/wget path because the remote host downloads directly from the CDN. The SCP path adds a local download step (~5s) plus an SCP upload that depends entirely on the SSH link bandwidth — embedded devices, VPNs, and high-latency connections can easily exceed 60s for a ~30-50 MB transfer. Each sub-step (`scp_upload`, `run_ssh_script` for extraction) uses the full `SCP_INSTALL_TIMEOUT` individually to avoid splitting a single budget across steps, which would require coordination logic for diminishing remaining time.
- If the SCP upload or extraction fails, the error surfaces the same way as a normal install failure — `BinaryInstallComplete { result: Err(_) }` — and the session falls back to ControlMaster warpification.
## Testing and validation
### Unit tests (`setup_tests.rs`)
- **`install_script_contains_wget_fallback`** — Assert that `install_script()` output contains `command -v curl`, `command -v wget`, and the `exit {no_http_client_exit_code}` sentinel. This is the main guardrail against accidentally regressing the fallback logic during future script edits.
- **`download_tarball_url_formats_correctly`** — Test `download_tarball_url` for each `(RemoteOs, RemoteArch)` combination. Catches URL construction drift between the shell script placeholders and the Rust-side URL builder, which would cause the SCP fallback to download the wrong artifact.
### Integration / manual testing
- **No curl, has wget**: SSH into a Docker container with `apt-get remove curl` / Alpine with only `wget`. Verify install succeeds via wget path.
- **No curl, no wget**: SSH into a minimal BusyBox container with neither. Verify the script exits with `NO_HTTP_CLIENT_EXIT_CODE`, the client downloads locally, SCPs the tarball, and the extraction script installs successfully.
- **Happy path unchanged**: SSH into a standard Ubuntu/Debian host. Verify curl is still used (check logs for absence of "falling back" message).
## Risks and mitigations
- **BusyBox `wget` differences**: BusyBox's `wget` is a stripped-down implementation that lacks some GNU wget flags. The flags used (`-q -O`) are supported by BusyBox wget. Notably, BusyBox wget does NOT support `--connect-timeout` — we omit it and rely on the outer SSH timeout (60s) instead.
- **SCP deprecation**: OpenSSH has been moving toward SFTP as the default transfer protocol. Modern `scp` (OpenSSH 9.0+) uses the SFTP protocol under the hood by default, so this isn't a practical concern. If a host has a very old SSH that lacks SCP, it almost certainly has curl or wget.
- **Local download assumes curl on client**: The local machine (macOS, Linux desktop, or Windows with WSL) is expected to have `curl`. macOS ships curl, and it's near-universal on Linux desktops.
## Parallelization
This is a small, focused change. Sub-agents are not beneficial — the shell script change (Phase 1) and the Rust SCP fallback (Phase 2) are tightly coupled and should be in the same PR.
+25
View File
@@ -0,0 +1,25 @@
# Handoff Environment Selection: PWD-Based Overlap at Activation
Linear: [APP-4427](https://linear.app/warpdotdev/issue/APP-4427)
## Summary
When a user enters `&` handoff compose mode or uses `/handoff`, auto-select the cloud environment that matches the repo they're currently working in — at activation time, not after pressing Enter. This replaces the current two-layer system where the environment visibly shifts after the handoff is dispatched.
Figma: none provided
## Behavior
1. When the user types `&` or runs `/handoff` (no query), the system checks the terminal's current working directory for a git repo (walk up to `.git`, read `origin` remote URL, parse `<owner>/<repo>`).
2. If a git repo is found and at least one cloud environment's `github_repos` contains that repo, select the environment with the most overlap (breaking ties by most-recently-used). The user can still override this from the environment dropdown.
3. If no git repo is found, or no environment matches the repo, fall back to the existing default: saved `last_selected_environment_id` setting, then most-recently-used environment.
4. The environment chip in the footer reflects the selected environment immediately (or near-immediately after the async git check completes).
5. When the user presses Enter, the compose state's current `selected_environment_id` is passed directly to the new cloud pane. The environment does not change during the transition from compose mode to the cloud pane.
6. For `/handoff query` (auto-submit path), the environment is `None` (compose state isn't active), so the cloud pane falls back to its own default selection.
7. The pwd-based check uses the same `find_git_root` / `git_origin_url` / `parse_github_repo` / `pick_handoff_overlap_env` utilities as the existing touched-workspace pipeline — just scoped to a single directory instead of the full conversation history.
8. The async git check should complete quickly (single local git command with the existing 5-second timeout). If the check hasn't completed when the user presses Enter, the current selection (from the fallback defaults) is used.
+43
View File
@@ -0,0 +1,43 @@
# Handoff Environment Selection: PWD-Based Overlap at Activation — Tech Spec
Product spec: `specs/APP-4427/PRODUCT.md`
Linear: [APP-4427](https://linear.app/warpdotdev/issue/APP-4427)
## Context
Environment auto-selection for `&` handoff has two layers:
- **Layer 1** (activation time): `start_pwd_environment_overlap` in `input.rs:3769` spawns an async task that resolves the pwd's git repo and calls `pick_handoff_overlap_env` to select the best environment. `EnvironmentSelector::ensure_default_selection` (`environment_selector.rs:372`) also runs synchronously to pick a saved/MRU fallback.
- **Layer 2** (post-dispatch): After pressing Enter, an async task in `workspace/view.rs:13422-13488` re-resolves the pwd repo and overwrites the environment on the new cloud pane model.
Layer 2 causes a visible environment flicker after the handoff pane opens. The compose state already has the correct environment by the time Enter is pressed — layer 2 is redundant.
Key files:
- `app/src/terminal/input/handoff_compose.rs``HandoffComposeState` model
- `app/src/terminal/input.rs:3739``activate_cloud_handoff_compose`
- `app/src/terminal/input.rs:3769``start_pwd_environment_overlap` (layer 1)
- `app/src/terminal/input.rs:3973``maybe_launch_cloud_handoff_request` (Enter dispatch)
- `app/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs:372``ensure_default_selection`
- `app/src/workspace/view.rs:13319``complete_local_to_cloud_handoff_open`
- `app/src/workspace/view.rs:13450-13464` — post-dispatch overlap overwrite (to remove)
- `app/src/workspace/action.rs:493``OpenLocalToCloudHandoffPane` action
## Proposed changes
### 1. Rename action field and pass compose state's selection
Rename `explicit_environment_id``environment_id` on `OpenLocalToCloudHandoffPane` (`workspace/action.rs:498`). In `maybe_launch_cloud_handoff_request` (`input.rs:3991`), pass `selected_environment_id().cloned()` from the compose state instead of `explicit_environment_id()`. This carries the pwd-overlap result (or MRU fallback) to the new cloud pane.
### 2. Remove async env overwrite in `complete_local_to_cloud_handoff_open`
Remove the `resolve_repo_for_path` call inside the async task (`workspace/view.rs:13435-13442`), the `pwd_repo` plumbing through the tuple, and the env overwrite block in the completion handler (`view.rs:13450-13464`). The `source_pwd` capture (`view.rs:13420`) is also removed. The workspace derivation and snapshot upload continue unchanged.
### 3. Clean up `PendingHandoff.explicit_environment_id`
Remove the `explicit_environment_id` field from `PendingHandoff`, the `pending_handoff_has_explicit_environment()` method on `AmbientAgentViewModel` (`model.rs:549`), and the guard in `EnvironmentSelectorTarget::ensure_default_environment_id` (`environment_selector.rs:84-87`). The guard is no longer needed: the env is set on the model before the `EnvironmentSelector` is created, so `ensure_default_selection` already short-circuits at line 374 (`if current_selection.is_some() { return; }`).
### 4. Update all dispatch and handler sites
Rename `explicit_environment_id``environment_id` at all sites that construct or destructure the action, and update intermediate function signatures (`start_local_to_cloud_handoff`, `complete_local_to_cloud_handoff_open`, `start_fresh_cloud_launch`, `restore_source_handoff_draft`, `restore_cloud_handoff_draft`).
## Testing and validation
### Manual
- In a terminal whose pwd is inside a repo that matches exactly one environment: type `&`, observe the matching environment in the chip. Press Enter — the cloud pane opens with the same environment, no shift.
- Same setup but explicitly pick a different environment from the dropdown, press Enter — the explicit choice is preserved.
- In a directory outside any git repo: type `&`, observe fallback to saved/MRU default. Press Enter — same env, no shift.
- `/handoff query` from a repo-matching directory: cloud pane opens with its own default selection (compose state isn't active in this path).
## Parallelization
This is a small, tightly coupled change across a few files in a single call chain. A single agent should implement it sequentially.
+73
View File
@@ -0,0 +1,73 @@
# APP-4457 — Shared-session scrollback restore optional active block
Linear: [APP-4457](https://linear.app/warpdotdev/issue/APP-4457/fix-shared-session-scrollback-restore-crash-when-active-block-is)
Sentry: [7475296168](https://warpdotdev.sentry.io/issues/7475296168/?alert_rule_id=15217488&alert_type=issue&notification_uuid=93ccd349-0267-4363-90d3-925a1c8ca43f&project=5701177&referrer=slack)
## Context
Shared-session scrollback restoration crashed because the viewer restore path inferred that the final serialized block was always the unfinished active prompt block. That contract is too strong: the sharer serializes only blocks that pass shared-session visibility rules, so the active block can be absent when it is hidden or otherwise not scrollback-eligible.
The producer side lives in `app/src/terminal/shared_session/mod.rs`. `SharedSessionScrollbackType` starts the serialized range at the selected block or active block (`mod.rs:178`), and `to_scrollback` filters each block through `Block::is_scrollback_block_for_shared_session` before serializing it (`mod.rs:199`). That filter is implemented in `app/src/terminal/model/block.rs:1506`; it excludes hidden/restored blocks and therefore does not guarantee the active block will be emitted.
The consumer side lives in `app/src/terminal/model/blocks.rs`. `BlockList::load_shared_session_scrollback` handles initial viewer restore (`blocks.rs:711`), and `BlockList::append_followup_shared_session_scrollback` handles cloud/follow-up session append (`blocks.rs:742`). Both paths need the same scrollback shape so initial joins and follow-up joins do not diverge.
The affected Sentry issue points at a debug assertion in the old restore shape: after `split_last()`, the final item was asserted to be unfinished. When the final serialized item was a completed block, that assertion failed instead of restoring completed history and leaving the viewer with a valid active block.
## Proposed changes
Treat a shared-session scrollback snapshot as completed historical blocks plus an optional active prompt block. The active block is present only when the final serialized block is unfinished (`completed_ts.is_none()`). All other serialized blocks are historical blocks and must be completed before they are restored.
Add a small private parser near the restore code:
- `SharedSessionScrollbackBlocks` in `app/src/terminal/model/blocks.rs:548`
- `completed_blocks: &[SerializedBlock]`
- `active_block: Option<&SerializedBlock>`
The parser calls `split_last()` only to classify the final item. If the final item has `completed_ts.is_none()`, it becomes the optional active block and the prefix becomes completed history. Otherwise the full slice is completed history and `active_block` is `None`.
Update `load_shared_session_scrollback` to:
1. Finish any pre-existing unfinished local active block before restore.
2. Restore each parsed completed block only if it has both `start_ts` and `completed_ts`.
3. Restore the optional active block when present.
4. Otherwise call `ensure_active_block_after_shared_session_scrollback` to create a fresh post-bootstrap active block if the current active block is finished.
Update `append_followup_shared_session_scrollback` to use the same parsed shape while preserving its existing duplicate-block behavior:
1. Skip completed blocks whose IDs already exist in the viewer model.
2. Finish the current active block before restoring new completed blocks or a new active block.
3. Skip duplicate active blocks by ID.
4. If the follow-up snapshot has no active block, keep the current unfinished active block when one exists; otherwise create a fresh hidden active block.
Update producer/consumer comments so they describe the actual contract: active prompt state is included only when the active block is scrollback-eligible, not unconditionally.
## Testing and validation
Add regression coverage for both restore paths:
- `app/src/terminal/shared_session/mod_tests.rs:304` — initial restore with a completed final serialized block restores all completed blocks and creates a fresh hidden active block.
- `app/src/terminal/shared_session/viewer/event_loop_tests.rs:284` — follow-up append with a completed final serialized block preserves duplicate skipping, appends new completed history, and leaves a fresh hidden active block.
Keep existing active-block-present tests unchanged so normal live-sharing behavior remains covered:
- `test_loading_scrollback`
- `test_loading_scrollback_in_alt_screen`
- `test_append_followup_scrollback_skips_duplicates`
Validation commands:
- `cargo nextest run -p warp --lib test_loading_scrollback`
- `cargo nextest run -p warp --lib test_append_followup_scrollback`
- `git --no-pager diff --check -- app/src/terminal/model/blocks.rs app/src/terminal/model/block.rs app/src/terminal/shared_session/mod.rs app/src/terminal/shared_session/mod_tests.rs app/src/terminal/shared_session/viewer/event_loop_tests.rs specs/APP-4457/TECH.md`
Do not use `cargo fmt --all` or file-specific `cargo fmt`. If formatting is required before review, use the repo-standard `cargo fmt`.
## Parallelization
Sub-agents are not useful for this change. The implementation is small and tightly coupled across one restore parser, two restore paths, nearby contract comments, and focused tests. Parallel edits would introduce coordination overhead and risk conflicting changes in the same files. Validation is also fast enough to run sequentially.
## Risks and mitigations
The main risk is accidentally changing the active-block-present path used by normal live sharing. Keep that path covered by the existing `test_loading_scrollback` and follow-up duplicate tests, and make the new parser classify an active block solely by the persisted completion state.
Follow-up append has a second risk: completed history from the original snapshot may be replayed during follow-up attach. Preserve ID-based duplicate skipping for completed and active blocks so the follow-up path appends only newly observed blocks.
+50
View File
@@ -0,0 +1,50 @@
# Context
APP-4459 changes Cloud Mode setup-v2 failures so they render as the conversation-ended tombstone and hide input, instead of showing the failure in the agent message bar.
Today `AmbientAgentViewModel::handle_spawn_error` stores `Status::Failed { error_message }` and emits `AmbientAgentViewModelEvent::Failed` in `app/src/terminal/view/ambient_agent/model.rs:1318`.
`TerminalView::handle_ambient_agent_event` handles `Failed` by removing the queued prompt block, updating the active cloud conversation status, refreshing the details panel, and notifying in `app/src/terminal/view/ambient_agent/view_impl.rs:176`.
Setup-v2 failures currently reach `BlocklistAIStatusBar::render_cloud_mode_setup_terminal_message`, which renders `ambient_agent_model.error_message()` as a red message bar in `app/src/ai/blocklist/block/status_bar.rs:917`.
The existing tombstone insertion path is `TerminalView::insert_conversation_ended_tombstone` in `app/src/terminal/view/shared_session/view_impl.rs:1669`. It sets `conversation_ended_tombstone_view_id`.
Input hiding already keys off that tombstone ID in `TerminalView::is_input_box_visible` in `app/src/terminal/view.rs:7216`, so no separate input-hiding state is needed.
`ConversationEndedTombstoneView` already enriches from `AmbientAgentTask` and uses `task.status_message.message` when `task.state.is_failure_like()` in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:140`. This is the right source for task-backed failures, especially third-party harness runs where the local `AIConversation` is only a UI vehicle.
`TaskStatusMessage` now carries optional `error_code` data in `app/src/ai/ambient_agents/task.rs:520`, with `environment_setup_failed` identifying setup-command failures that should not offer continue actions.
Setup failures can happen before an `AmbientAgentTask` exists but still have an active local `AIConversation`. The `Failed` handler writes the error into that conversation before inserting the tombstone, so the tombstone can read conversation status.
# Proposed changes
Keep this setup-v2 only. Setup-v1 uses the old full-screen/status-footer failure UI.
In `TerminalView::handle_ambient_agent_event`:
- Keep the existing `Failed` status update and details-panel refresh.
- When `FeatureFlag::CloudModeSetupV2.is_enabled()`, insert the tombstone after updating status.
- Avoid duplicate insertion by respecting `conversation_ended_tombstone_view_id`.
In `conversation_output_status_from_conversation`:
- Keep finished root-exchange statuses as the preferred source for success, cancelled, and exchange-backed errors.
- If there is no finished root exchange and `conversation.status()` is `ConversationStatus::Error`, convert `conversation.status_error_message()` into `AmbientConversationStatus::Error`.
- Use `RenderableAIError::Other` with `will_attempt_resume: false` and `waiting_for_network: false` for that fallback.
In `ConversationEndedTombstoneView`:
- Read error display data from conversation status.
- Mark display data as error when either `conversation.status().is_error()` or conversation output status has an error.
- Add `hide_continue_actions` to suppress both Continue locally and Continue in cloud.
- For pre-task setup failures, detect an error conversation with no task ID and no transcript, then set title to `Cloud agent failed to start`, clear credits, and hide continue actions.
- Keep async task enrichment, and let task failure metadata override the conversation error when available.
- When task enrichment sees failure-like state with `TaskStatusMessage.error_code == environment_setup_failed`, hide continue actions.
- Keep existing finished-exchange/conversation-derived errors for normal Oz tombstones.
In `BlocklistAIStatusBar::render_cloud_mode_setup_terminal_message`, remove or gate only the `ambient_agent_model.error_message()` branch. Keep the GitHub auth and cancelled branches unchanged.
In `TaskStatusMessage`:
- Add optional `error_code` with serde support for `errorCode`.
- Add `is_environment_setup_failure()` helper for tombstone display decisions.
Error source order should be:
1. `AmbientAgentTask.status_message.message` for task-backed failures.
2. Conversation status error, including pre-task setup failures.
# Testing and validation
Add focused Rust coverage:
- setup-v2 `AmbientAgentViewModelEvent::Failed` inserts one tombstone and removes the queued prompt block.
- `TerminalView::is_input_box_visible` returns false after the failure tombstone is inserted.
- duplicate `Failed` events still keep one tombstone rich-content view.
- pre-task failure tombstone uses `Cloud agent failed to start`, hides credits, and hides continue actions.
- task-backed failure tombstone renders `AmbientAgentTask.status_message.message`.
- task-backed environment setup failures hide continue actions via `TaskStatusMessage.error_code`.
- setup-v2 status bar no longer renders the generic failure message branch.
+28
View File
@@ -0,0 +1,28 @@
# APP-4460: Suppress Cloud Mode setup input sync during follow-up setup commands
## Context
APP-4460 covers Cloud Mode follow-up executions that run setup commands before the next agent exchange. The desired behavior is to keep the follow-up input visible and interactive for the viewer, but prevent the ambient-agent sharer's setup-command text from syncing into the viewer's input while that setup phase is active.
Cloud Mode panes are backed by `AmbientAgentViewModel`. Initial runs call `spawn_internal`, set `Status::WaitingForSession { kind: InitialRun }`, and emit `DispatchedAgent` in `app/src/terminal/view/ambient_agent/model.rs (1224-1248)`. Follow-ups call `submit_cloud_followup`, set `pending_followup_prompt`, transition to `Status::WaitingForSession { kind: Followup }`, and emit `FollowupDispatched` in `app/src/terminal/view/ambient_agent/model.rs (898-923)`. `TerminalView::handle_ambient_agent_event` responds to `FollowupDispatched` by starting a new setup-command group when `CloudModeSetupV2` is enabled in `app/src/terminal/view/ambient_agent/view_impl.rs (168-176)`.
Setup-command visibility and lifetime live in `SetupCommandState` in `app/src/terminal/view/ambient_agent/block/setup_command_text.rs (23-91)`. The state has a current group, tracks whether that group has executed at least one setup command, and records the currently running group. `maybe_insert_setup_command_blocks` marks `did_execute_a_setup_command` when the first startup command block appears and inserts the setup summary and per-command rich content in `app/src/terminal/view/ambient_agent/view_impl.rs (373-453)`. The group is finished and collapsed when the first Oz exchange arrives or a third-party harness command starts.
Viewer input updates from the ambient sharer arrive through the shared-session CRDT path. `NetworkEvent::InputUpdated` is handled in `app/src/terminal/shared_session/viewer/terminal_manager.rs`, and previously applied remote operations directly with `Input::process_remote_edits`. There is already broad startup suppression for `ambient_agent::is_cloud_agent_pre_first_exchange`, but follow-up setup groups can continue after the viewer has an ambient model and visible input, so the setup command text can still be applied to the viewer input unless the receiver suppresses it.
## Proposed changes
Keep the input visibility behavior unchanged for Cloud Mode setup-v2 follow-ups: the viewer input should remain visible and interactive while setup commands run.
Add a narrow setup-v2 sync predicate on `TerminalView` that checks:
- `FeatureFlag::CloudModeSetupV2` is enabled;
- the pane has an `AmbientAgentViewModel`;
- the ambient model's current setup-command group is a non-initial follow-up group;
- that current group is still running.
Derive input-sync suppression from the running follow-up setup group rather than from whether a setup command block has rendered, so the first setup command is suppressed even if its shared-session input update arrives before `maybe_insert_setup_command_blocks` marks the group as having executed a command.
Route viewer shared-session input updates through `TerminalView::apply_viewer_shared_session_input_update`. That method should return without applying CRDT operations while the narrow predicate is true, and otherwise call `Input::process_remote_edits` as before. This receiver-side suppression keeps local viewer drafts intact during the follow-up setup phase and automatically resumes normal shared-session input sync once the setup group finishes.
Do not change setup-command rendering, setup-command grouping, follow-up submission routing, tombstone behavior, or broad pre-first-exchange suppression. The fix is specific to shared-session input editor updates from the ambient-agent sharer during an active follow-up setup-command group.
## Testing and validation
Add unit coverage next to existing Cloud Mode terminal-view tests. The key regression test should:
- enable `AgentView`, `CloudMode`, `CloudModeSetupV2`, and `HandoffCloudCloud`;
- create a Cloud Mode terminal;
- seed an existing ambient task with `enter_viewing_existing_session`;
- start a follow-up setup-command group by handling `FollowupDispatched`;
- assert `is_input_box_visible` remains `true`;
- assert incoming shared-session input operations are not applied while the setup group is running, including before the first setup command block has rendered;
- finish the setup group and assert incoming shared-session input operations apply again.
Run the focused test by name with `cargo nextest run -p warp -E 'test(<new test name>)'`. Run `cargo fmt --manifest-path /workspace/warp/Cargo.toml -p warp --check` and a targeted `cargo clippy --manifest-path /workspace/warp/Cargo.toml -p warp --tests -- -D warnings` before updating the PR.
## Parallelization
Parallelization is not beneficial for this revision. The implementation is a small, tightly coupled change across one setup-state helper, one shared-session input receiver path, and one regression test; splitting it across agents would add coordination overhead and merge risk without reducing wall-clock time.
+108
View File
@@ -0,0 +1,108 @@
# Cloud agent tombstone and followup input behavior — Product Spec
Linear: APP-4483. Figma: none.
## Summary
When `FeatureFlag::HandoffCloudCloud` is enabled, make the idle-state UI for cloud agent conversations consistent and permission-aware. The decision to show the conversation-ended tombstone, the inline followup input, and any continue CTA should depend on two product concepts:
- Which harness produced the conversation: Oz vs. a third-party harness such as Claude Code.
- Whether the current user has edit access to the underlying AI conversation object.
Oz conversations should feel directly resumable when the user can edit the conversation. Third-party harness conversations should always preserve the terminal transcript boundary with a tombstone when no execution is active, because their continuation flow is different and they cannot be forked into a local Oz conversation.
## Problem
Today the tombstone/input behavior is scattered across session-sharing end handling, ambient task end handling, tombstone CTA rendering, and followup input state. The primary gate is currently whether the current user appears to own the ambient task. That is not the correct product model for shared cloud conversations: access should follow the underlying conversation permissions, not just task creator identity.
This leads to inconsistent outcomes when `FeatureFlag::HandoffCloudCloud` is enabled:
- A user with edit access to a shared Oz conversation may not get the inline followup input if they are not the task creator.
- A user who can only view an Oz conversation can see continuation affordances that imply they can mutate the original conversation.
- Third-party harness conversations can be treated like Oz conversations even though local forking is unsupported for those harnesses.
## Goals
- Use edit access to the underlying AI conversation as the product source of truth for whether the current user may continue the original cloud conversation.
- For Oz conversations:
- Hide the tombstone and show the followup input when the current user can edit the underlying conversation.
- Show the tombstone and the existing local continuation CTA when the current user can only view the underlying conversation.
- For third-party harness conversations:
- Always show the tombstone when there is no active execution.
- Show a cloud continuation CTA only when the current user can edit the underlying conversation.
- Never show a fork-local CTA.
- Keep active executions unchanged: while the cloud execution is live, the user should see the active shared/cloud session rather than an ended-state tombstone.
## Non-goals
- Implement fork-and-continue for third-party harness conversations.
- Change tombstone metadata, credits, artifacts, runtime, or error presentation.
- Change server-side authorization rules. The client should reflect permissions, but the server remains authoritative for followup submission.
- Redesign the tombstone UI layout beyond which CTA is present.
- Change behavior when `FeatureFlag::HandoffCloudCloud` is disabled.
## Product concepts
### Conversation edit access
“Edit access” means the current user has at least edit-level access to the underlying AI conversation object. This should be derived from the conversations server permissions, not from ambient task ownership.
If the client has not yet loaded permissions, it should avoid presenting mutation affordances that might be wrong. The safe default is to treat access as view-only until edit access is known.
### Harness
Harness should come from the conversation/task metadata already used to identify Oz vs. non-Oz runs. `Oz` is the only harness eligible for local fork continuation. Any other known harness, including Claude Code, Codex, Gemini, or future third-party harnesses, should follow the third-party behavior.
If the harness is unknown while metadata is still loading, the safe default is to show the tombstone and hide mutation CTAs until the harness and permissions are known.
### Active execution
This spec applies when the cloud agent conversation has no active execution. If an execution is currently active, the live session UI remains the source of truth and the ended-state tombstone should not be inserted.
## Behavior
### Oz harness
If the conversation was produced by Oz and there is no active execution:
- User has edit access:
- Tombstone: hidden.
- Followup input: shown and editable.
- CTA: none, because the input is already available.
- Submitting the input continues the same cloud conversation.
- User has view access only:
- Tombstone: shown.
- Followup input: hidden or non-editable.
- CTA: show `Continue locally`.
- Clicking the CTA forks the cloud conversation into a local Warp conversation and continues locally, without mutating the original shared conversation.
### Third-party harness
If the conversation was produced by a third-party harness and there is no active execution:
- User has edit access:
- Tombstone: shown.
- Followup input: hidden until the user explicitly chooses to continue.
- CTA: show `Continue`.
- Clicking `Continue` starts the third-party cloud followup flow for the same conversation/run.
- User has view access only:
- Tombstone: shown.
- Followup input: hidden or non-editable.
- CTA: none.
- The user can inspect the transcript but cannot continue or fork it from this UI.
### Behavior invariants
- B1: Oz + edit access + no active execution: no tombstone, show followup input.
- B2: Oz + view-only access + no active execution: show tombstone with `Continue locally`.
- B3: Third-party + edit access + no active execution: show tombstone with `Continue`.
- B4: Third-party + view-only access + no active execution: show tombstone with no continue CTA.
- B5: Any harness + active execution: no ended-state tombstone; keep live execution UI.
- B6: Unknown harness or unknown access + no active execution: show tombstone with no mutation CTA.
### UI state table
| Harness | Conversation access | Execution state | Tombstone | Followup input | CTA | Result |
| --- | --- | --- | --- | --- | --- | --- |
| Oz | Edit | Active execution | Hidden | Hidden while execution is active | None | User watches or interacts with the live cloud execution UI. |
| Oz | View only | Active execution | Hidden | Hidden while execution is active | None | User watches the live cloud execution UI without ended-state affordances. |
| Third-party | Edit | Active execution | Hidden | Hidden while execution is active | None | User watches or interacts with the live third-party cloud execution UI. |
| Third-party | View only | Active execution | Hidden | Hidden while execution is active | None | User watches the live third-party cloud execution UI without ended-state affordances. |
| Oz | Edit | No active execution | Hidden | Shown and editable | None | User submits a followup that continues the same cloud conversation. |
| Oz | View only | No active execution | Shown | Hidden or non-editable | `Continue locally` | User forks into a local Warp conversation before continuing. |
| Third-party | Edit | No active execution | Shown | Hidden until continuation starts | `Continue` | User continues via the existing third-party cloud followup execution flow. |
| Third-party | View only | No active execution | Shown | Hidden or non-editable | None | User can inspect the transcript but cannot continue from this UI. |
| Unknown | Any or unknown | No active execution | Shown | Hidden or non-editable | None | Client waits for metadata before showing mutation affordances. |
## Copy
Preferred CTA copy:
- Oz, view-only: `Continue locally`
- Third-party, edit access: `Continue`
Tooltips can clarify the distinction:
- Oz local continuation CTA: “Fork this conversation into a local Warp session.”
- Third-party continue CTA: “Continue this cloud conversation.”
Avoid showing “Continue locally” for third-party harnesses, because local continuation is unsupported and misleading.
## Edge cases
- Metadata not loaded: show the tombstone and hide continue CTAs until both harness and editability are known.
- Permissions change while viewing: recompute the tombstone/input state from the latest conversation permissions. If edit access is revoked, hide the followup input and remove any continue CTA that would mutate the original conversation.
- Conversation has task metadata but no conversation metadata: show the tombstone and hide mutation CTAs until conversation metadata is available, unless there is an existing server-confirmed editability signal.
- Followup submission fails due to server-side permission denial: keep the user in the ended-state UI, restore their draft if possible, and show a concise error toast.
- Third-party harness later gains local fork support: this spec should be revisited; until then, third-party harnesses must not show fork-local affordances.
## Success criteria
- An Oz cloud conversation where the current user has edit access ends with an editable followup input and no tombstone.
- An Oz cloud conversation where the current user only has view access ends with a tombstone containing `Continue locally`, and no inline followup input.
- A Claude Code cloud conversation where the current user has edit access ends with a tombstone containing `Continue`, and no fork-local CTA.
- A Claude Code cloud conversation where the current user only has view access ends with a tombstone and no continue CTA.
- Ambient task creator identity is no longer the product source of truth for showing the followup input; conversation edit access is.
- Active executions do not show the ended-state tombstone regardless of harness or permissions.
- Existing behavior remains unchanged when `FeatureFlag::HandoffCloudCloud` is disabled.
## Resolved decisions
- Keep the existing `Continue locally` copy for the Oz view-only local continuation CTA.
- Third-party `Continue` uses the existing third-party followup execution flow. This spec should not introduce a new modal, inline input mode, or alternate continuation concept.
- For view-only third-party conversations, the absence of a CTA is sufficient; no explanatory subtitle is required.
+127
View File
@@ -0,0 +1,127 @@
# Cloud agent tombstone and followup input behavior — Tech Spec
Product spec: `specs/APP-4483/PRODUCT.md`
## Context
`PRODUCT.md` defines behavior invariants B1B6. This technical spec implements those invariants when `FeatureFlag::HandoffCloudCloud` is enabled, while preserving existing behavior when that flag is disabled.
The current UI decision points are split across three places:
- `app/src/terminal/view/shared_session/view_impl.rs:729` computes `viewed_ambient_task_id_owned_by_current_user` and uses task creator ownership to decide whether `on_session_share_ended` inserts a tombstone.
- `app/src/terminal/view/shared_session/view_impl.rs:779` enables the followup input only when the current user owns the ambient task.
- `app/src/terminal/view/shared_session/view_impl.rs:811` repeats the same ownership model for `handle_non_running_ambient_agent_task`.
There is a second delayed path for already-loaded ambient tasks:
- `app/src/terminal/view.rs:7123` checks whether a non-running shared ambient task should get a tombstone.
- `app/src/terminal/view.rs:7149` again uses `owned_ambient_agent_task_id` plus `HandoffCloudCloud` to decide whether to show the input instead.
The tombstone currently makes an independent CTA decision:
- `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:207` creates a `Continue` cloud button whenever a task id exists and `HandoffCloudCloud` is enabled.
- `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:221` creates a `Continue locally` button whenever a conversation id exists.
- `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:473` hides local continuation for known non-Oz harnesses, but treats unknown harness metadata as local-continuable.
The data needed for the new product model is already present:
- `ServerAIConversationMetadata` contains `harness` and `permissions` in `app/src/ai/agent/conversation.rs:3849`.
- `AIAgentHarness` distinguishes Oz, Claude Code, Gemini, Codex, and Unknown in `app/src/ai/agent/conversation.rs:3821`.
- `BlocklistAIHistoryModel::get_server_conversation_metadata` already looks up loaded conversation metadata with a fallback to cached conversation metadata in `app/src/ai/blocklist/history_model.rs:1986`.
- `AgentConversationsModel::fetch_ambient_agent_tasks_and_cloud_convo_metadata` fetches ambient tasks and cloud conversation metadata together, including additional metadata for task conversation IDs missing from the first metadata response, in `app/src/ai/agent_conversations_model.rs:675`.
- `AmbientAgentTask` exposes `conversation_id`, active execution state, and whether cloud followup submission is allowed in `app/src/ai/ambient_agents/task.rs:317`.
- `SharingAccessLevel` is ordered as View < Edit < Full in `crates/warp_server_client/src/drive/sharing.rs:8`.
There is an object-access precedent, but it is tied to loaded Warp Drive objects:
- `CloudViewModel::access_level` defaults missing objects to view access in `app/src/cloud_object/model/view.rs:173`.
- `CloudViewModel::object_access_level` grants full access for personal/team-space objects, applies link and guest ACLs for shared-space objects, and upgrades creator access to edit in `app/src/cloud_object/model/view.rs:181`.
The APP-4483 implementation should reuse the same permission semantics where possible, but must operate directly on `ServerAIConversationMetadata.permissions` because AI conversation metadata is not a `CloudObject`.
## Proposed changes
### 1. Add a single continuation UI state resolver
Add a small helper module under `app/src/terminal/view/shared_session/`, for example `cloud_conversation_continuation.rs`, and use it from all tombstone/followup decision paths.
Suggested core types:
- `ContinuationHarness`: `Oz`, `ThirdParty`, or `Unknown`.
- `ConversationAccess`: `Edit`, `ViewOnly`, or `Unknown`.
- `TombstoneCta`: `ContinueLocally`, `ContinueInCloud { task_id }`, or none.
- `CloudConversationContinuationUiState`, containing:
- whether the ended-state tombstone should be present;
- whether the inline cloud followup input should be enabled;
- the task id to use for cloud followup input submission, if any;
- the tombstone CTA to render, if any.
The resolver should accept the terminal view id, optional ambient task id, whether a live shared session is still active, and `AppContext`. It should derive:
- active execution from the ambient task when present;
- harness from `ServerAIConversationMetadata.harness`, not from tombstone display metadata;
- edit access from `ServerAIConversationMetadata.permissions`;
- unknown state when task or conversation metadata is unavailable.
Mapping:
- B1: Oz + edit + no active execution returns no tombstone and followup input enabled for that task.
- B2: Oz + view-only + no active execution returns tombstone with `ContinueLocally`.
- B3: third-party + edit + no active execution returns tombstone with `ContinueInCloud`.
- B4: third-party + view-only + no active execution returns tombstone with no CTA.
- B5: any harness/access + active execution or live shared session returns no ended-state tombstone and no followup input transition.
- B6: unknown harness or unknown access + no active execution returns tombstone with no CTA.
When `FeatureFlag::HandoffCloudCloud` is disabled, keep the existing pre-APP-4483 behavior path: ended ambient sessions may show the tombstone, but the permission-aware cloud followup state should not be used.
### 2. Resolve conversation metadata by server token
Add a `BlocklistAIHistoryModel` helper that returns `ServerAIConversationMetadata` by `ServerConversationToken`, for example:
- check `server_token_to_conversation_id`;
- if found, reuse `get_server_conversation_metadata`;
- otherwise scan `all_conversations_metadata` for matching `server_conversation_token`;
- return `None` if metadata is not loaded.
This avoids forcing callers to manufacture or resolve an `AIConversationId` before they can inspect task-linked conversation permissions. `AmbientAgentTask::conversation_id` returns the token string at `app/src/ai/ambient_agents/task.rs:317`, so the resolver can construct a `ServerConversationToken` from that value and ask the history model for metadata.
If metadata is missing, do not fetch synchronously from the UI resolver. Treat the state as B6 and let the existing `AgentConversationsModel` fetch path populate metadata asynchronously.
### 3. Compute edit access from `ServerPermissions`
Add a pure helper near the resolver or in a small permission utility that computes the current user's effective `SharingAccessLevel` from `ServerAIConversationMetadata`.
Rules:
- If the current user is missing or logged out, start at `SharingAccessLevel::View`.
- If `permissions.space` is `Owner::User` for the current user, return at least `Full`.
- If `permissions.space` is `Owner::Team` and the team appears in `UserWorkspaces::team_from_uid_across_all_workspaces`, return at least `Full`.
- Apply `permissions.anyone_link_sharing` as a baseline when present.
- Apply user guest ACLs when `ServerGuestSubject::User { firebase_uid }` matches the current user.
- Apply team guest ACLs when the current user belongs to the guest team according to `UserWorkspaces`.
- Ignore pending-user ACLs for this UI decision.
- If `metadata.creator_uid` matches the current user, upgrade to at least `Edit`, matching the creator fallback in `CloudViewModel::object_access_level`.
- Return `ConversationAccess::Edit` for `Edit` or `Full`; return `ViewOnly` for `View`.
If team membership data is not loaded and access is only knowable through a team owner/guest ACL, do not assume edit access. The safe UI state remains view-only/unknown until workspace metadata is available.
### 4. Replace ownership-based UI branching
Replace the creator-owned task gate in these call sites with the resolver:
- `on_session_share_ended` in `app/src/terminal/view/shared_session/view_impl.rs:727`.
- `handle_non_running_ambient_agent_task` in `app/src/terminal/view/shared_session/view_impl.rs:811`.
- `maybe_insert_tombstone_for_non_running_shared_ambient_task` in `app/src/terminal/view.rs:7123`.
Add a shared method on `TerminalView`, for example `refresh_non_running_cloud_agent_continuation_ui`, that:
- exits early for disabled `CloudModeSetupV2`, active replay, existing pending cloud followup submission, or disabled `HandoffCloudCloud`;
- asks the resolver for the current state;
- removes an existing tombstone when the new state is B1 and enables the cloud followup input;
- inserts or updates the tombstone when the new state is B2, B3, B4, or B6;
- keeps input selectable/read-only for viewers when no continuation input should be shown.
`enable_owned_cloud_followup_input` should be renamed or wrapped with a permission-neutral name, such as `enable_cloud_followup_input`, because it will now be used for users with edit access who may not be the task creator.
Update `try_submit_pending_cloud_followup` in `app/src/terminal/view.rs:20026` so it does not fall back to `owned_ambient_agent_task_id`. Submission should use an explicit `pending_cloud_followup_task_id` or a task id that the resolver stored when it enabled the inline input. This prevents creator ownership from remaining a hidden permission bypass.
### 5. Make tombstone CTAs data-driven
Change `ConversationEndedTombstoneView::new` to accept a CTA decision from the resolver rather than constructing both buttons from `task_id` and `conversation_id`.
The tombstone should render:
- `Continue locally` only for `TombstoneCta::ContinueLocally`.
- `Continue` only for `TombstoneCta::ContinueInCloud { task_id }`.
- no button when the CTA is absent.
Keep `ConversationEndedTombstoneEvent::ContinueInCloud` and `start_cloud_followup_from_tombstone` for B3. Keep `ContinueLocally` behavior for B2. Keep `TombstoneDisplayData::enrich_from_task` for display metadata only; it should no longer decide CTA visibility after an async task fetch.
This removes the current mismatch where `Continue` is shown for any task id and `Continue locally` is shown for unknown harness metadata.
### 6. Recompute when metadata changes
Permissions and metadata can arrive after the tombstone is first inserted. Recompute the continuation UI state when:
- `AgentConversationsModelEvent::TasksUpdated` updates task active-execution state or task conversation id;
- `AgentConversationsModelEvent::ConversationsLoaded` merges cloud conversation metadata;
- `BlocklistAIHistoryEvent::UpdatedConversationMetadata` updates server metadata for a live conversation.
If recomputation transitions:
- from B6 to B1, remove the tombstone and enable the input;
- from B6 to B2/B3/B4, update or reinsert the tombstone with the correct CTA;
- from B1 to a non-edit state, clear/disable the input and show the tombstone state for the latest access.
Use the resolver as the only source of truth for these transitions.
## Testing and validation
Add focused unit tests for the resolver and update existing shared-session tests so validation maps directly to `PRODUCT.md` B1B6:
- B1: Oz metadata + edit access + ended execution produces no tombstone, editable followup input, and cloud submission uses the same task id even when task creator is someone else.
- B2: Oz metadata + view-only access produces a tombstone with `Continue locally`, no inline followup input, and no cloud CTA.
- B3: Claude Code/Codex/Gemini metadata + edit access produces a tombstone with `Continue`, and clicking it enters the existing cloud followup flow.
- B4: third-party metadata + view-only access produces a tombstone with no continue CTA.
- B5: active execution or live shared session does not insert an ended-state tombstone regardless of harness/access.
- B6: missing metadata, unknown harness, or unknown access produces a tombstone with no mutation CTA.
Permission helper tests should cover:
- user owner;
- team owner with current-user team membership;
- user guest view vs edit;
- team guest view vs edit;
- link sharing view only;
- creator fallback to edit;
- missing current user defaults to non-edit.
Update or replace creator-based assertions in `app/src/terminal/view/shared_session/view_impl_tests.rs`, especially the tests currently named around “owned” ambient sessions. Add tombstone CTA tests in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs` once CTA state is data-driven.
Suggested targeted commands:
- `cargo test -p warp --lib terminal::view::shared_session::view_impl_tests`
- `cargo test -p warp --lib terminal::view::shared_session::conversation_ended_tombstone_view_tests`
- `cargo test -p warp --lib terminal::view::shared_session::cloud_conversation_continuation`
Before PR/update, run the repository-required format and clippy checks from the PR workflow. Do not use `cargo fmt --all` or file-specific `cargo fmt`.
## Parallelization
Do not split this implementation across sub-agents. The changes are tightly coupled across one UI state resolver, terminal view lifecycle transitions, tombstone CTA rendering, and existing shared-session tests. Parallel edits would likely touch the same files and increase merge overhead more than they reduce wall-clock time.
+51
View File
@@ -0,0 +1,51 @@
# APP-4486: Use server forking endpoint for local forks
## Context
When a user forks a conversation (via `/fork`, context menu, conversation details, etc.), the forked conversation is only created locally. It has no `server_conversation_token` until the user sends a new query and receives a `StreamInit` response. This means the forked conversation isn't immediately stored in the cloud and doesn't have a server-side identity.
The server-side fork endpoint already exists and is used by the local-to-cloud handoff flow. The goal is to also call it during regular forking so forked conversations immediately get cloud storage and server tokens.
### Relevant code
**Server endpoint**: `../warp-server/logic/ai_conversation_fork.go``ForkConversation()` copies GCS data, creates a new metadata row, returns a new conversation ID. No changes needed.
**Client API**: `app/src/server/server_api/ai.rs:942-946``AIClient::fork_conversation()` calls `POST /agent/conversations/{id}/fork`. No changes needed.
**Handoff fork flow (pattern to follow)**: `app/src/workspace/view.rs:13356-13431``start_local_to_cloud_handoff` calls `ai_client.fork_conversation()` first, then on success calls `complete_local_to_cloud_handoff_open` which creates the local fork and binds the server token via `set_server_conversation_token_for_conversation`.
**Regular fork flow (code to change)**: `app/src/workspace/view.rs:11718-11941``fork_ai_conversation()` loads conversation data (async), then synchronously creates a local fork via `history_model.fork_conversation()` or `fork_conversation_at_exchange()`, and restores it into a pane. Both local fork methods set `server_conversation_token: None` (`history_model.rs:1155`, `history_model.rs:1312`).
**Fork entry points**: All funnel through `WorkspaceAction::ForkAIConversation``fork_ai_conversation()` (`view.rs:22030`). `ContinueConversationLocally` also calls it (`view.rs:22042`).
**Cloud storage check**: `PrivacySettings::as_ref(ctx).is_cloud_conversation_storage_enabled` (`settings/privacy.rs:153`).
## Proposed changes
Modify `fork_ai_conversation` in `app/src/workspace/view.rs` to call the server-side fork endpoint before creating the local fork, following the handoff pattern.
### Approach: server fork first, then local fork
1. After the existing async conversation data load completes, check two conditions:
- `PrivacySettings::as_ref(ctx).is_cloud_conversation_storage_enabled` is true
- The source conversation has a `server_conversation_token`
2. If both are met, spawn `ai_client.fork_conversation()` with the source's server token and the source conversation's title.
3. On success, proceed with the existing local fork logic, then bind the returned `forked_conversation_id` to the local fork via `history_model.set_server_conversation_token_for_conversation()`.
4. On failure, log a warning and fall through to the existing local-only fork — no user-visible impact.
5. If the conditions in step 1 are not met, skip the server call and go straight to the existing local fork logic.
### Implementation detail
Extract the existing local-fork-and-restore logic into a helper (e.g. `complete_fork_ai_conversation`) that takes an optional `server_forked_conversation_id: Option<String>`. The outer `fork_ai_conversation` method handles the conditional async server call and then delegates to this helper regardless of the outcome.
`preserve_task_ids` stays `false` for regular forks — unlike handoff, no cloud agent will execute against the fork, so the local fork can mint new task IDs freely.
## Testing and validation
- **Unit tests**: Add a test in `history_model_tests.rs` verifying that when `server_forked_conversation_id` is provided, the forked conversation's `server_conversation_token` is set to that value.
- **Manual verification**: Fork a conversation with cloud storage enabled and confirm the forked conversation appears in the cloud conversation list immediately (without sending a new query). Fork again with cloud storage disabled and confirm the fork still works locally without errors.
- **Error path**: Temporarily force the server fork to fail (e.g. use an invalid conversation ID) and verify the local fork still succeeds with a warning logged.
## Parallelization
This is a single-file change (~50 lines of new logic in `workspace/view.rs`). Parallelization via child agents would not provide meaningful benefit.
+46
View File
@@ -0,0 +1,46 @@
# APP-4549: Disable the Feedback Bundled Skill
Linear: [APP-4549](https://linear.app/warpdotdev/issue/APP-4549/add-setting-to-disable-the-feedback-skill)
Figma: none provided
## Summary
Add a user-visible setting that controls whether Warps built-in `feedback` bundled skill is available to Oz in the app. The setting should default to enabled so existing behavior is unchanged, while giving users a clear opt-out when they do not want Oz to use Warps in-app feedback filing workflow.
## Problem
The bundled `feedback` skill is useful for turning rough Warp product feedback into filed GitHub issues, but it is not appropriate for every user or workspace. Users need a way to disable that built-in skill without disabling all bundled skills, all skills, or Oz entirely.
## Goals
- Let users disable only Warps built-in `feedback` bundled skill.
- Preserve current behavior by default.
- Make the setting discoverable from the existing Agent settings surface.
- Ensure disabling the skill prevents Oz from discovering or using that built-in skill in future app interactions.
## Non-goals
- Removing the `feedback` skill files from the shipped app bundle.
- Disabling user-created home or project skills that happen to be named `feedback`.
- Disabling other bundled skills.
- Changing the feedback skills instructions, issue filing behavior, or target repository.
- Adding organization-level policy controls for bundled skills.
## Behavior
1. Warp exposes a setting for the built-in `feedback` bundled skill in the existing Agent settings UI.
2. The setting defaults to enabled for all users. Existing users should see no behavior change until they turn it off.
3. When enabled, the built-in `feedback` bundled skill is available exactly as it is today:
- It can appear in skill selection surfaces that include bundled skills.
- It can be advertised to Oz as an available bundled skill.
- Oz can read and invoke the bundled skill when the normal skill-triggering conditions apply.
4. When disabled, the built-in `feedback` bundled skill is unavailable in the app:
- It does not appear in skill selection surfaces that include bundled skills.
- It is not advertised to Oz in the available-skills context.
- If a stale or explicit reference attempts to read the bundled `feedback` skill, Warp treats it as unavailable and does not expose the skill content.
5. Toggling the setting affects subsequent skill discovery and subsequent Oz requests. A user should not need to restart Warp for future requests to stop including the built-in `feedback` skill.
6. The setting controls only Warps built-in bundled skill with the bundled ID `feedback`.
7. User-created skills are unaffected:
- A home skill named `feedback` remains available according to normal home-skill rules.
- A project skill named `feedback` remains available according to normal project-skill rules.
- A user-created skill with feedback-related instructions remains readable and invokable if it is otherwise in scope.
8. Other bundled skills are unaffected. Disabling the feedback skill must not hide or disable bundled skills such as settings, PR comments, MCP, Figma, or any future bundled skills.
9. The setting is independent of global AI enablement:
- If global AI is disabled, the setting may render disabled like nearby Agent settings.
- The stored value is still preserved while global AI is disabled.
- Re-enabling global AI restores the feedback skill according to the stored setting value.
10. The setting should be represented in user-editable settings using a stable, descriptive key so users can configure it outside the UI when settings-file support is enabled.
11. The setting should follow existing settings sync behavior for user-level Agent preferences, so a users choice can carry across devices when settings sync is enabled.
12. The UI label and description should make the scope clear: the toggle controls Warps built-in feedback skill, not all feedback mechanisms and not all skills.
13. Search within settings should find the toggle with terms like “feedback,” “skill,” “bundled skill,” and “agent.”
14. Turning the setting off should not delete any app resource, modify any user skill files, or change git state.
15. If the setting cannot be read, Warp should fall back to the default enabled behavior rather than unexpectedly removing the skill.
+79
View File
@@ -0,0 +1,79 @@
# APP-4549: Tech Spec — Feedback Bundled Skill Setting
Linear: [APP-4549](https://linear.app/warpdotdev/issue/APP-4549/add-setting-to-disable-the-feedback-skill)
## Context
`PRODUCT.md` defines the user-visible behavior: add a default-on setting that disables only Warps built-in `feedback` bundled skill.
The relevant code already has a central bundled-skill activation path:
- `app/src/ai/skills/skill_manager.rs (20-47)``BundledSkillActivation` models whether a bundled skill is active.
- `app/src/ai/skills/skill_manager.rs (361-389)` — bundled skills are loaded from app resources and assigned an activation condition.
- `app/src/ai/skills/skill_manager.rs (591-600)``activation_for_bundled_skill` currently makes most bundled skills, including `feedback`, always active.
- `app/src/ai/skills/skill_manager.rs (185-201)``get_skills_for_working_directory` appends bundled skills whose activation condition is enabled.
- `app/src/ai/skills/skill_utils.rs (94-121)``list_skills_if_changed` sends available skill descriptors to the agent when the list changes.
- `app/src/ai/blocklist/action_model/execute/read_skill.rs (36-63)``read_skill` resolves `SkillReference::BundledSkillId` through `SkillManager::skill_by_reference`.
- `app/src/settings/ai.rs (715-1464)``AISettings` defines user-level Agent settings, including public TOML-backed settings under `agents.warp_agent.*`.
- `app/src/settings_view/ai_page.rs (6024-6223)` — the Agent settings “Other” widget renders related Agent toggles using shared helpers.
- `app/src/bin/generate_settings_schema.rs (146-199)` and `script/prepare_bundled_resources (132-159)` — public settings with `toml_path` are included in the generated settings schema bundled with app resources.
Bundled skills are copied into the application bundle from `resources/bundled` by `script/prepare_bundled_resources:48`. This change should not try to remove the feedback skill from the bundle at build time; it should make the skill inactive at runtime.
## Proposed changes
1. Add a public AI setting in `app/src/settings/ai.rs`.
- Suggested field: `feedback_bundled_skill_enabled`.
- Suggested generated setting type: `FeedbackBundledSkillEnabled`.
- Type: `bool`.
- Default: `true`.
- Supported platforms: `SupportedPlatforms::ALL`.
- Sync: `SyncToCloud::Globally(RespectUserSyncSetting::Yes)`.
- Private: `false`.
- Suggested TOML path: `agents.warp_agent.other.feedback_bundled_skill_enabled`.
- Description: “Whether Warps built-in feedback skill is available to the Warp Agent.”
2. Extend bundled skill activation in `app/src/ai/skills/skill_manager.rs`.
- Add a `BundledSkillActivation` variant for settings-backed feedback activation, for example `FeedbackSkillSetting`.
- Update `BundledSkillActivation::is_enabled` to consult `AISettings::as_ref(ctx).feedback_bundled_skill_enabled` for that variant.
- Update `activation_for_bundled_skill` so `skill_id == "feedback"` uses that variant.
- Keep `modify-settings` on `RequiresFile` and all unrelated bundled skills on their existing activation behavior.
3. Add an activation-aware lookup for bundled skill reads.
- Preserve raw lookup behavior where the UI needs historical metadata for already-rendered outputs.
- Add a method such as `skill_by_reference_if_active(&self, reference: &SkillReference, ctx: &AppContext) -> Option<&ParsedSkill>`, or update `ReadSkillExecutor` to pattern-match bundled references and call `active_bundled_skill(id, ctx)`.
- Use the activation-aware path in `ReadSkillExecutor` so `read_skill` cannot expose `@warp-skill:feedback` content when the setting is disabled.
- Path-based user skills should continue to use `skills_by_path` and should not be affected by the feedback bundled-skill setting.
4. Add the UI toggle in `app/src/settings_view/ai_page.rs`.
- Import the generated `FeedbackBundledSkillEnabled` setting type.
- Add a `SwitchStateHandle` to `OtherAIWidget`.
- Add `AISettingsPageAction::ToggleFeedbackBundledSkill`.
- Handle the action by toggling `AISettings.feedback_bundled_skill_enabled` and notifying the view.
- Render the toggle in the existing Agent “Other” section using `render_ai_setting_toggle`.
- Suggested label: “Enable built-in feedback skill”.
- Suggested description: “Let Oz use Warps built-in skill for turning Warp product feedback into GitHub issues.”
- Update `OtherAIWidget::search_terms` to include feedback, skill, and bundled skill.
5. Schema and resources.
- No manual schema file changes should be necessary. The setting should appear in generated schema output through the existing settings inventory path.
- Normal resource preparation should continue to copy the `feedback` skill file into the bundle.
6. Keep the implementation narrow.
- Do not alter `resources/bundled/skills/feedback/SKILL.md`.
- Do not add broad bundled-skill allow/deny lists.
- Do not change skill deduplication, provider precedence, or user-created skill scoping.
## Testing and validation
Map tests to the product behavior in `PRODUCT.md`:
1. Product behavior 2 and 3: add a skill manager test showing `feedback` is active by default and included in `get_skills_for_working_directory` when bundled skills are enabled.
2. Product behavior 4: add a skill manager test that sets `feedback_bundled_skill_enabled` to `false` and verifies `feedback` is excluded from returned bundled skill descriptors.
3. Product behavior 8: in the same test or a companion test, verify another bundled skill remains included when feedback is disabled.
4. Product behavior 7: verify path-based skills named `feedback` are still returned according to existing home/project skill scope rules when the bundled feedback skill is disabled.
5. Product behavior 4: add or update `read_skill_tests` so `ReadSkillExecutor` returns an error for `SkillReference::BundledSkillId("feedback")` when the setting is disabled.
6. Product behavior 3 and 8: add a read-skill test showing an enabled bundled skill can still be read.
7. Product behavior 9, 12, and 13: update `ai_page_tests` if the existing settings page tests assert widget search/filtering or rendered action coverage for the “Other” widget.
8. Run `cargo fmt`.
9. Run targeted tests:
- `cargo test -p warp skill_manager_tests`
- `cargo test -p warp read_skill_tests`
- `cargo test -p warp ai_page_tests`
Adjust exact package/test filters if local test names differ.
10. If this is prepared for PR review, follow repo guidance and run the required formatting and clippy checks before opening or updating a PR.
## Risks and mitigations
- Stale skill context could still reference `@warp-skill:feedback`. Mitigation: guard direct `read_skill` execution with the same activation state used by skill listing.
- The setting could accidentally disable user-authored skills named `feedback`. Mitigation: check only the bundled skill ID, not parsed skill name or path-based references.
- Other bundled skills could regress if activation is generalized too broadly. Mitigation: add tests that feedback is disabled while another bundled skill remains active.
- Settings UI copy could imply all feedback mechanisms are disabled. Mitigation: label and description should explicitly say “built-in feedback skill.”
## Parallelization
Do not parallelize the implementation. The setting definition, activation logic, direct read enforcement, and UI toggle are tightly coupled and touch overlapping files, so a single implementer should make the code changes in one checkout on branch `safia/app-4549-add-setting-to-disable-the-feedback-skill`.
Validation can be parallelized after implementation if desired:
- Agent A: local execution in `/Users/captainsafia/code/warp`, same branch, owns `skill_manager_tests` and `read_skill_tests`.
- Agent B: local execution in a separate worktree, for example `/Users/captainsafia/code/warp-app-4549-ui-tests` on branch `safia/app-4549-ui-validation`, owns `ai_page_tests` and settings UI review.
If using the optional validation split, Agent B should not modify source files unless asked; it should report failures and suggested fixes back to the main branch owner. The final PR should land as a single branch/PR from `safia/app-4549-add-setting-to-disable-the-feedback-skill`.
+64
View File
@@ -0,0 +1,64 @@
# Queued Prompts in Cloud Mode Setup
Linear: APP-4562. Builds on the regular Agent Mode queued-prompts panel from [`specs/REMOTE-1543/PRODUCT.md`](../REMOTE-1543/PRODUCT.md), extending it to Cloud Mode runs.
Figma: none provided.
## Summary
Extend the multi-prompt queued-prompts panel to Cloud Mode runs so the initial cloud prompt and any follow-ups queued during environment setup render in the same panel as regular Agent Mode queued prompts, with the initial prompt rendered as a locked first row. Subsequent queued rows fire automatically as the cloud agent finishes each exchange.
## Problem
Today in Cloud Mode setup, the user's submitted prompt appears as a separate "pending user query" indicator block, and pressing Enter while the cloud environment is setting up does nothing — the prompt is dropped. Queued prompts also do not drain for cloud runs, because the local "response finished" signal that drives draining does not fire when the response is being streamed by a remote cloud agent. As a result, users cannot queue follow-up work on a cloud run.
## Goals
- Render the initial Cloud Mode prompt as a row in the regular queued-prompts panel instead of as the legacy pending-user-query block.
- Let users queue any number of follow-up prompts while the cloud environment is setting up, and have them auto-fire in order once the cloud agent is live.
- Keep the panel's row interactions (drag-to-reorder, edit, delete) for follow-up rows, while preventing them on the initial row that the cloud agent has already accepted.
- Gate everything behind a new `QueuedPromptsV2` feature flag, dogfood-only.
## Non-goals
- Changing the regular Agent Mode queued-prompts panel behavior described in `specs/REMOTE-1543/PRODUCT.md`.
- Persisting the cloud-mode queue across app restarts.
- Letting users edit, delete, or reorder the initial prompt after it has been dispatched to the cloud.
- Exposing this behavior to non-dogfood builds.
## Behavior
### Feature gating
1. All behavior described below is gated on the `QueuedPromptsV2` feature flag. When the flag is off, Cloud Mode setup behaves exactly as it does today: initial prompts render as the legacy pending-user-query block, submitting a prompt while the environment is setting up is a no-op, and queued prompts do not drain for cloud runs.
2. The `QueuedPromptsV2` flag transitively enables the regular `QueueSlashCommand` feature. When V2 is on, every existing regular Agent Mode queued-prompts surface (the auto-queue toggle, `/queue` slash command, queue panel) is also available.
### Initial cloud-mode prompt
3. When the user submits the initial prompt in a Cloud Mode pane, the prompt appears as the first row of the queued-prompts panel for that conversation. The panel renders in the same position relative to the V2 cloud-mode composing input that it renders in for regular Agent Mode (above the input editor, inside the centered V2 layout).
4. The initial cloud-mode prompt row is *locked*:
- Its drag handle is rendered in a visually disabled state and does not respond to drag gestures.
- Its edit (pencil) and delete (trash) icon-buttons remain rendered on hover and render with the same naked styling as their interactive counterparts on other rows — no greyed-out background or text. They are not clickable.
- Hovering the drag handle or either icon-button shows the same short tooltip — "The first cloud-mode prompt cannot be changed." — explaining why no interaction is possible.
- The static preview text renders identically to other rows.
5. The locked row's preview text is the prompt as the user typed it, including any `/plan`, `/orchestrate`, or other prefix the user included (matching today's pending-user-query block treatment).
6. When the cloud agent picks up the prompt — i.e. the first real exchange shows up in the conversation transcript, or the harness reports that the command has started (Oz harnesses use the harness-command-started signal; oz local-to-cloud handoff uses the first appended exchange) — the locked row is removed from the panel. After removal, the second row (if any) becomes the next row to fire, but is still considered a follow-up, not the initial prompt.
7. If the cloud run fails before the prompt is picked up (failure, cancellation, GitHub-auth required, snapshot upload failure), the locked row is removed from the panel at the same moment the legacy pending-user-query block would be removed today. Any follow-up rows queued behind it remain in the panel, available for review, edit, deletion, or reordering, exactly like regular queued rows.
### Submission during environment setup
8. Whenever the cloud pane is an ambient-agent pane that is not currently composing and not currently running, submitting the input editor queues the prompt instead of doing nothing. The queued prompt appears as a new row in the panel, after the locked initial row. In practice this covers every pre-run cloud state where the user can reach the editor — `WaitingForSession`, `Failed`, `Cancelled`, `NeedsGithubAuth`, and `Setup` (the last is normally unreachable because the first-time-setup modal owns the focus, but the predicate matches it for completeness).
9. Follow-up rows queued during setup are *not* locked. They support the same interactions as regular Agent Mode queued rows: drag-to-reorder among themselves, hover-revealed edit and delete buttons, and so on.
10. The locked initial row stays pinned at index 0 regardless of how follow-up rows are reordered. Dragging another row above the locked row is not possible — the panel keeps the locked row at the top.
11. Submitting an empty prompt does not append a new row (existing trim-and-skip behavior).
12. Submitting in shell mode is unaffected — the shell command runs in the terminal as today, regardless of whether the cloud agent is setting up.
### Drain behavior (after the initial prompt is picked up)
13. Once the locked initial row has been removed (per §6), the panel behaves as the regular Agent Mode queued-prompts panel: every time the active cloud conversation finishes an exchange cleanly, the first remaining row is removed from the panel and submitted as a follow-up prompt to the same cloud conversation.
14. The queued prompt is submitted through the same path that user-initiated cloud follow-ups use — it reaches the cloud agent, not the local agent controller. From the user's perspective, an auto-fired queued prompt is indistinguishable from a prompt the user typed and submitted manually after the agent finished.
15. When the active cloud conversation finishes for a non-clean reason (error, cancellation, cancellation during requested command execution), auto-fire pauses immediately. The queue is not flushed:
- If the input editor is currently empty, the first remaining queued row is removed from the panel and its text is placed in the input editor. The user can edit and re-submit it manually.
- If the input editor is non-empty, no rows are removed and the input is not modified.
- In both cases, remaining queued rows beyond the first stay intact.
16. Auto-fire resumes naturally the next time the active cloud conversation completes an exchange cleanly — from that completion onward, the queue resumes draining from the top.
### Conversation lifecycle interactions
17. The queued-prompts panel is owned by the terminal view and implicitly scoped to whichever conversation is currently active in that view. Switching to a different conversation goes through agent-view exit (which clears the queue) before re-entering for the new conversation, so the panel always reflects the active conversation and never carries follow-up rows across conversation switches.
18. Exiting the cloud pane, closing the tab, or removing the conversation discards the queue (including any locked initial row).
19. The collapsed/expanded state of the panel, the row-level edit state, and reorder behavior all match the regular Agent Mode queued-prompts panel for follow-up rows.
### Telemetry
20. Existing queued-prompts panel telemetry (edit committed, row deleted, row reordered, panel collapse toggled) continues to fire for follow-up rows. The locked initial row does not emit edit/delete/reorder events because those interactions are disabled.
+105
View File
@@ -0,0 +1,105 @@
# Queued Prompts in Cloud Mode Setup — Tech Spec
See `specs/APP-4562/PRODUCT.md` for user-visible behavior. This document covers the implementation that supports that behavior, layered on top of the regular Agent Mode queued-prompts panel introduced in `specs/REMOTE-1543/`.
## Context
The regular queued-prompts panel (`specs/REMOTE-1543/`) is a terminal-owned queue that appears between the warping indicator and the input editor in `TerminalView`. The queue is per-`TerminalView` and implicitly scoped to whichever conversation owns the agent view — entries are wiped on agent-view exit and on `ClearedConversationsInTerminalView`, so it never holds rows for more than one conversation at a time. Its data model lives in `app/src/ai/blocklist/queued_query.rs`, its view lives in `app/src/ai/blocklist/queued_prompts_panel.rs`, and the trigger/drain glue lives in `app/src/terminal/input.rs` and `app/src/terminal/view.rs`. Cloud Mode is currently outside that surface — see `specs/REMOTE-1543/PRODUCT.md (13, 30, 62)` and the panel's `should_render` gate at `app/src/ai/blocklist/queued_prompts_panel.rs`.
For Cloud Mode today:
- The initial submitted cloud prompt is shown as a legacy pending-user-query block inserted by `TerminalView::insert_cloud_mode_queued_user_query_block` (`app/src/terminal/view/pending_user_query.rs:90`), called from `app/src/terminal/view/ambient_agent/view_impl.rs:173` (`DispatchedAgent`) and `:212` (`FollowupDispatched`).
- Pressing Enter while the cloud environment is still setting up is suppressed by `Input::should_block_cloud_mode_setup_submission` (`app/src/terminal/input.rs:6575`), short-circuited at `app/src/terminal/input.rs:12617`.
- Queued prompts drain off `BlocklistAIControllerEvent::FinishedReceivingOutput` in `TerminalView::handle_ai_controller_event` (`app/src/terminal/view.rs:4930`). That event does not fire for a cloud-mode pane because the response stream lives on the cloud side, so subsequent queued prompts never fire.
- `QueuedQueryOrigin::InitialCloudMode` is already defined at `app/src/ai/blocklist/queued_query.rs:24` but is currently unused — this spec wires it up.
## Proposed changes
### 1. Feature flag `QueuedPromptsV2`
Add a compile-time + runtime feature flag.
- `app/Cargo.toml`: add `queued_prompts_v2 = ["queue_slash_command"]` under `[features]`. The cargo dependency means enabling V2 transitively enables the existing queue feature, so every existing `FeatureFlag::QueueSlashCommand.is_enabled()` site still works without modification. Do not add to `default`.
- `crates/warp_features/src/lib.rs`: add `QueuedPromptsV2` to the `FeatureFlag` enum, alongside the existing `QueueSlashCommand` entry. Add the variant to `DOGFOOD_FLAGS`.
- `app/src/features.rs:432-433`: register the runtime flag under `#[cfg(feature = "queued_prompts_v2")]`.
All cloud-mode-aware sites described below gate on `FeatureFlag::QueuedPromptsV2.is_enabled()` directly.
### 2. `QueuedQueryOrigin::InitialCloudMode` is now load-bearing
`QueuedQueryModel` (`app/src/ai/blocklist/queued_query.rs`) gains origin-aware no-ops so the locked-row contract is enforced at the model layer, not just the panel. The shared check lives on `QueuedQuery::is_locked()` so every mutator can ask the same question. Currently a row is locked iff its origin is `InitialCloudMode`; lifecycle code removes it explicitly via `QueuedQueryModel::remove_initial_cloud_mode_row`.
- `pop_front(ctx)`: returns `None` if the head is locked. The non-clean drain path in `TerminalView::drain_queued_prompts` reaches `pop_front` whenever a server-pushed `UpdatedConversationStatus → Error/Cancelled` arrives, so this gate prevents an in-flight status transition from clobbering the locked initial Cloud Mode row before the matching ambient-agent cleanup event has run.
- `pop_for_autofire(edit_text_override, ctx)`: returns `None` if the first row is locked. The cloud-setup lifecycle removes that row via §5, not autofire.
- `remove_by_id(query_id, ctx)`: no-op if the target row is locked. Lifecycle code uses `remove_initial_cloud_mode_row` instead.
- `reorder(source_id, target_index, ctx)`: no-op if `source_id` is locked, or if `target_index == 0` would displace a locked row currently at the head.
- `enter_edit_mode(query_id, ctx)`: no-op if the target row is locked.
- Add `remove_initial_cloud_mode_row(ctx)` that removes the first row of the queue if and only if its origin is `InitialCloudMode`. Used by §5.
All of these signatures are conversation-id-free because the model owns a single `Vec<QueuedQuery>` per terminal view — see the Context section. This division means the panel UI in §3 only needs to *render* the lock; it does not need to gate handler dispatch, because the model rejects forbidden mutations even if a click somehow gets through.
### 3. Panel: lock the InitialCloudMode row visually
In `app/src/ai/blocklist/queued_prompts_panel.rs`:
- All locked-row hover affordances share a single tooltip constant `INITIAL_CLOUD_MODE_PROMPT_TOOLTIP = "The first cloud-mode prompt cannot be changed."` (`app/src/ai/blocklist/queued_prompts_panel.rs:44`) so the drag handle, edit button, and delete button all surface the same short explanation.
- In the row rendering inside `render` (`app/src/ai/blocklist/queued_prompts_panel.rs:597`), when the rendered query's `origin()` is `QueuedQueryOrigin::InitialCloudMode`:
- Render the drag handle in a visually disabled state without wrapping the row in `Draggable`, and show the shared tooltip on hover.
- Keep the edit and delete `ActionButton`s revealed on hover and call `set_disabled(true)` on each so the click handler is gated and the disabled tooltip is surfaced. Pair that with `with_disabled_theme(NakedTheme)` so the disabled state reuses the regular naked appearance instead of picking up the default greyed-out `DisabledTheme` fill/text. Both buttons reuse the shared tooltip.
- Static preview text renders identically to other rows.
- `should_render` (`app/src/ai/blocklist/queued_prompts_panel.rs:547`) is unchanged. Because the cargo feature transitively enables `queue_slash_command`, the existing `FeatureFlag::QueueSlashCommand.is_enabled()` check passes when V2 is on.
- Wire the panel into the V2 cloud-mode composing input in `Input::render_cloud_mode_v2_composing_input` (`app/src/terminal/input/agent.rs:345`). Render the panel as a sibling above the input card, inside the same `ConstrainedBox` constrained to `CLOUD_MODE_V2_MAX_WIDTH` (`app/src/terminal/input/agent.rs:34`). The non-V2 placement at `app/src/terminal/input/agent.rs:324` is unchanged.
### 4. Route initial + follow-up cloud-mode prompts into the queue
Branch both existing `insert_cloud_mode_queued_user_query_block` call sites on `FeatureFlag::QueuedPromptsV2.is_enabled()`:
- `app/src/terminal/view/ambient_agent/view_impl.rs` initial `DispatchedAgent`.
- `app/src/terminal/view/ambient_agent/view_impl.rs` `FollowupDispatched`.
When V2 is on, call `TerminalView::enqueue_initial_cloud_mode_prompt(prompt, ctx)` which delegates to `enqueue_prompt` with `QueuedQueryOrigin::InitialCloudMode`. `enqueue_prompt` keeps `BlocklistAIContextModel::selected_conversation_id` as a *gate* — it bails when no conversation is selected so prompts are not stranded outside the agent view — but the queue itself is not keyed by the conversation id. If the gate fails, fall back to `insert_cloud_mode_queued_user_query_block` so the visual indicator is never lost.
The `FollowupDispatched` path also enqueues an `InitialCloudMode` row. Every cloud-side dispatch — whether the first execution or a follow-up — produces a locked row that the lifecycle events in §5 retire when the cloud agent picks up the prompt.
### 5. Mirror legacy block-removal sites onto the panel row
Introduce `TerminalView::remove_cloud_mode_queue_row(&mut self, ctx)` (`app/src/terminal/view/pending_user_query.rs`) that calls `QueuedQueryModel::remove_initial_cloud_mode_row` from §2. The helper is a no-op when V2 is off because no `InitialCloudMode` row was ever appended.
Affected sites:
- `app/src/terminal/view/ambient_agent/view_impl.rs:114-127``should_clean_up_pending_cloud_query` covers `HarnessCommandStarted`, `NeedsGithubAuth`, `Cancelled`, `HandoffSnapshotUploadFailed`, and `Failed` when `!CloudModeSetupV2.is_enabled()`. Both the legacy pending-user-query block and the V2 queue row are removed on the *same* condition so the two surfaces cannot diverge.
- `app/src/terminal/view.rs:5452``remove_pending_cloud_mode_query_if_exchange_has_renderable_user_query`, called from `AppendedExchange` at `app/src/terminal/view.rs:5813`. The pending-block removal stays scoped to the legacy `CloudMode` kind so unrelated `PendingUserQueryKind::QueuedPrompt` blocks (the `/queue` slash-command surface) are not torn down under V2; the queue-row removal is V2-gated independently and is a no-op when the V2 row is not present.
- `app/src/terminal/view.rs:5613-5620` — oz local-to-cloud handoff first `AppendedExchange`.
### 6. Allow submission while environment is setting up (queue instead of block)
`Input::should_block_cloud_mode_setup_submission` currently short-circuits Enter to a no-op when the cloud pane is in `WaitingForSession` / `Failed` / `Cancelled` / `NeedsGithubAuth`. This stays untouched. The new path is purely additive and gated.
Add `Input::maybe_queue_input_during_cloud_setup(ctx)` next to `maybe_queue_input_for_in_progress_conversation`. It:
1. **Hard-gates** on `FeatureFlag::QueuedPromptsV2.is_enabled()` as the very first check; returns false immediately if the flag is off, without inspecting any other state.
2. Returns false unless the cloud-pane predicate `is_ambient_agent() && !is_configuring_ambient_agent() && !is_agent_running()` holds (same predicate as today's block check).
3. Gates on `BlocklistAIContextModel::selected_conversation_id` being `Some` so we don't queue while the agent view is closed; the conversation id itself is not used to key the queue (see Context).
4. Reads and trims the editor buffer; returns false if empty.
5. Clears the editor buffer and pending attachments.
6. Appends a row with `QueuedQueryOrigin::AutoQueueToggle` to the single per-view queue.
7. Returns true so the submit handler short-circuits.
Call `maybe_queue_input_during_cloud_setup` in the submit handler immediately alongside `maybe_queue_input_for_in_progress_conversation`, before `should_block_cloud_mode_setup_submission` is evaluated. When V2 is off, the new helper returns false and the existing block check still short-circuits submission to a no-op exactly as today.
### 7. Drain via conversation-status path, submit via cloud follow-up path
Two pieces change for cloud-mode draining to work end-to-end.
#### 7a. Detect finish via the conversation-status path
Add a second drain entry point inside `TerminalView::handle_ai_history_model_event`, in the `BlocklistAIHistoryEvent::UpdatedConversationStatus` arm:
- Gate on `FeatureFlag::QueuedPromptsV2.is_enabled()` and `self.is_ambient_agent_session(ctx)`.
- Detect a transition from in-progress/blocked to a terminal status using a `last_observed_conversation_status: HashMap<AIConversationId, ConversationStatus>` field on `TerminalView`. The map is cleared on `ClearedConversationsInTerminalView`, and individual entries are removed in the `RemoveConversation` / `DeletedConversation` handlers. The per-conversation status map remains keyed by conversation id even though the queue is not — see Context — because we have to distinguish transitions per conversation when multiple conversations have lived in the same terminal view.
- Translate the terminal status into a `FinishReason` via an exhaustive match: `Success → Complete`, `Error → Error`, `Cancelled → Cancelled`, `InProgress | Blocked → None`. The `None` arm matters: a status update that is not a transition to a terminal status must not drain the queue.
- Call `self.handle_finished_conversation(finish_reason, ctx)`, which already routes through `drain_queued_prompts` and any registered `queued_prompt_callback`s.
The local AI controller path continues to feed `handle_finished_conversation` for local Agent Mode; the new history-model path feeds it for cloud-mode panes. Both converge on the same drain logic.
#### 7b. Route the popped prompt through the cloud submission path
`TerminalView::drain_queued_prompts` currently submits popped rows via `Input::submit_queued_prompt`, which goes through the local `BlocklistAIController`. That path does nothing useful for a cloud pane.
Add `Input::submit_queued_prompt_for_active_pane(prompt, ctx)` next to `submit_queued_prompt`. It selects the submission path based on pane kind, in order:
- **Cloud follow-up first.** If `ambient_agent_view_model.as_ref(ctx).is_ready_for_cloud_followup_prompt()`, emit `InputEvent::SubmitCloudFollowup { prompt }`. That event is handled by `TerminalView` and routes through `AmbientAgentViewModel::submit_cloud_followup`, the same path used for user-initiated cloud follow-ups. This branch wins over the viewer path because the old shared session is no longer live to receive a `SendAgentPrompt`.
- **Shared-session viewer next.** If `self.model.lock().shared_session_status().is_viewer()`, send the prompt straight to the sharer via `Event::SendAgentPrompt` — no buffer replace, no pending-attachment piggyback, and no use of `submit_viewer_ai_query`. When the user's editor is empty we also surface the standard `"<prompt> ◌"` loading affordance so the queued submission has visible feedback while the sharer ack flight is in flight; the `NetworkEvent::AgentPromptRequestInFlight → unfreeze_and_clear_agent_input` hop will clear it once the sharer acknowledges receipt. If the user has typed something locally, leave the buffer alone so their in-progress prompt is not clobbered.
- **Local Agent Mode fallback.** Otherwise call `submit_queued_prompt` so non-cloud queues are unaffected.
Change `drain_queued_prompts`'s `AutofireAction::Submit { text }` branch to call `submit_queued_prompt_for_active_pane(text, ctx)` instead of `submit_queued_prompt(text, ctx)`. The `PopFromEditMode` branch is unchanged.
## End-to-end flow (V2 on)
```mermaid
flowchart LR
A["User submits initial prompt"] --> B["spawn_agent emits DispatchedAgent"]
B --> C["TerminalView enqueues InitialCloudMode row"]
C --> D["Queue panel renders locked first row"]
E["User submits during setup"] --> F["Input::maybe_queue_input_during_cloud_setup"]
F --> G["AutoQueueToggle row appended after locked row"]
H["HarnessCommandStarted / AppendedExchange / failure / cancel"] --> I["TerminalView removes InitialCloudMode row"]
J["Cloud exchange completes (UpdatedConversationStatus)"] --> K["TerminalView::handle_finished_conversation"]
K --> L["drain_queued_prompts pops next row"]
L --> M["Input::submit_queued_prompt_for_active_pane → SubmitCloudFollowup"]
```
## Testing and validation
Map tests directly to the product invariants in `specs/APP-4562/PRODUCT.md`:
- **§1, §2 (feature gating)**: compile both with and without the cargo feature (`cargo check -p warp` and `cargo check -p warp --features queued_prompts_v2`); unit-test that all new helpers no-op when V2 is off.
- **§3, §4 (initial cloud-mode prompt as locked row)**: `app/src/terminal/view/queued_prompts_test.rs` covers (a) `DispatchedAgent` appends an `InitialCloudMode` row when V2 is on (`dispatched_cloud_prompt_uses_locked_queue_row_when_v2_is_enabled`), (b) `FollowupDispatched` does the same (`dispatched_cloud_followup_uses_locked_queue_row_when_v2_is_enabled`), and (c) the legacy block is not inserted when V2 is on.
- **§4 (lock semantics at the model level)**: `app/src/ai/blocklist/queued_query_tests.rs` includes `initial_cloud_mode_head_rejects_user_mutations_and_autofire` proving `enter_edit_mode`, `remove_by_id`, `reorder` (both `source_id` and `target_index == 0`), and `pop_for_autofire` no-op for `InitialCloudMode` rows; `pop_front_no_ops_when_head_is_locked` covers the non-clean drain path; `remove_initial_cloud_mode_row_only_removes_the_locked_head` covers the lifecycle removal path.
- **§6, §7 (removal sites)**: `cloud_setup_cleanup_events_remove_the_locked_queue_row` covers `HarnessCommandStarted`, `Cancelled`, `NeedsGithubAuth`, and `HandoffSnapshotUploadFailed`; `failed_event_keeps_locked_queue_row_under_cloud_mode_setup_v2` and `failed_event_removes_locked_queue_row_without_cloud_mode_setup_v2` cover the `Failed` event under both `CloudModeSetupV2` configurations, mirroring the legacy block's gating. `AppendedExchange` with renderable user query, and the oz local-to-cloud handoff first exchange, are exercised by existing terminal-view test coverage of the legacy removal sites — both paths now share the same `remove_cloud_mode_queue_row` helper.
- **§8 (during-setup queueing)**: `cloud_setup_enter_queues_followup_input_when_v2_is_enabled` asserts `maybe_queue_input_during_cloud_setup` appends an `AutoQueueToggle` row in the `WaitingForSession` state; `cloud_setup_enter_remains_blocked_when_v2_is_disabled` asserts no row is appended and the editor buffer is preserved when V2 is off.
- **§13–§16 (drain)**: `terminal_cloud_status_transition_drains_once_through_cloud_followup_input_event` asserts (a) `UpdatedConversationStatus` transitioning to a terminal status calls `handle_finished_conversation` once per transition, not per status event (the test feeds two `Success` events back-to-back and verifies a single drain), and (b) the resulting submission path goes through `InputEvent::SubmitCloudFollowup` for cloud panes via the `is_ready_for_cloud_followup_prompt` predicate.
- Full presubmit: `./script/presubmit`.
Do not run the app to test.
## Parallelization
Single workstream. The queue model, panel rendering, cloud-mode dispatch sites, conversation-status drain hook, and submission routing all share types (`QueuedQueryOrigin`, `FinishReason`, `ConversationStatus`, `AmbientAgentViewModelEvent`) that must stay consistent across edits. Splitting across child agents would create merge churn on the same files without reducing wall-clock time.
## Risks and mitigations
- **`InitialCloudMode` row leaking past run start**: every existing call to `remove_pending_user_query_block` for cloud kind has a sibling call to `TerminalView::remove_cloud_mode_queue_row` (via a unified `should_clean_up_pending_cloud_query` gate in §5), and the model-level `pop_for_autofire` and `pop_front` no-ops prevent accidental autofire or non-clean-drain pop of the locked row. The shared `QueuedQuery::is_locked()` helper centralises the check so every mutator agrees on the lock contract.
- **Double drain**: `FinishedReceivingOutput` (local) and `UpdatedConversationStatus` (history) could both call `handle_finished_conversation`. Mitigation: the new history-model entry is gated on `is_ambient_agent_session` and on a status *transition* tracked via `last_observed_conversation_status`, not on raw status updates. The `FinishReason` translation uses an exhaustive match that returns `None` for `InProgress | Blocked` so partial-status updates do not re-fire the queue.
- **Cross-surface block removal regression**: `remove_pending_cloud_mode_query_if_exchange_has_renderable_user_query` gates the legacy pending-user-query block removal on the `CloudMode` kind explicitly (independent of the V2 flag) so unrelated `PendingUserQueryKind::QueuedPrompt` blocks are not torn down under V2; the V2 queue-row removal is layered on top as a no-op when no `InitialCloudMode` row exists.
- **Conversation id not yet assigned at `DispatchedAgent`**: §4 falls back to the legacy pending-user-query block so the visual indicator is never lost.
- **Cargo feature dependency mistake**: if `queued_prompts_v2 = ["queue_slash_command"]` is omitted from `app/Cargo.toml`, V2 appears to work but the regular queue surfaces are silently dark. Mitigation: presubmit + the explicit compile-check on `--features queued_prompts_v2` in isolation.
- **Panel placement inside the V2 centered input**: the panel must match `CLOUD_MODE_V2_MAX_WIDTH` so it stays visually attached to the input card. Mitigation: render it inside the same `ConstrainedBox`/`Align` wrapper that the V2 input uses (§3).
+68
View File
@@ -0,0 +1,68 @@
# APP-4579 — Client tech spec
Implements the client-side surface of `specs/APP-4579/PRODUCT.md`. Server persistence and hidden-prompt injection are specified in `../warp-server/specs/APP-4579/TECH.md`.
## Problem
Local-to-cloud handoff must be available for conversations involved in orchestration, and the handoff spawn request must identify those conversations without exporting their topology. The server needs one fact only: whether it should inject the universal hidden orchestration-handoff message on the first cloud turn.
## Current handoff path
- `app/src/settings/ai.rs` supplies global and conversation-level cloud-handoff eligibility used by `&`, `/handoff`, footer-chip, and auto-handoff surfaces.
- `app/src/workspace/view.rs (13950-14112)` selects the environment, creates the forked server conversation, uploads snapshot state, computes the marker, and constructs the pending handoff.
- `app/src/terminal/view/ambient_agent/model.rs (144-163, 625-656, 1118-1145)` keeps `PendingHandoff` while environment setup completes, constructs the eventual handoff `SpawnAgentRequest`, and omits the marker for fresh cloud launches.
- `app/src/server/server_api/ai.rs (200-254)` defines the public spawn request payload sent to the server.
- `app/src/ai/conversation.rs` exposes whether the source conversation has a parent agent; `app/src/ai/agent_history/model.rs` exposes locally-known children.
## Client changes
### 1. Permit handoff for orchestrated local conversations
Remove only the orchestration-specific gating from the existing handoff eligibility and workspace initiation paths. The global handoff setting, cloud conversation storage requirement, feature flags, sync-token requirement, and long-running-command protection remain unchanged.
All existing surfaces become available under the same global rules for a local conversation with a parent agent, locally-known children, or both:
- `&` input prefix.
- `/handoff`.
- The footer handoff chip.
- Workspace action and auto-handoff initiation.
### 2. Compute one universal marker at handoff construction time
`complete_local_to_cloud_handoff_open` (`app/src/workspace/view.rs:14043`) already holds the source conversation and can query locally-known children. It computes the marker from either kind of orchestration relationship:
```rust
let orchestration_handoff = (source_conversation.has_parent_agent()
|| !history_model
.as_ref(ctx)
.child_conversation_ids_of(&source_conversation.id())
.is_empty())
.then_some(true);
```
The marker deliberately does not distinguish parent, child, or mixed roles. Any orchestrated source receives the same server-injected universal prompt.
### 3. Carry the optional marker through the pending handoff request
In `app/src/terminal/view/ambient_agent/model.rs (144-163, 625-656)`, retain the computed marker until the spawn request is built:
```rust
struct PendingHandoff {
// existing fields...
orchestration_handoff: Option<bool>,
}
```
`build_handoff_spawn_request` forwards this value directly. Fresh cloud launches set it to `None`, because no local handoff occurred.
### 4. Send the minimal public request shape
In `app/src/server/server_api/ai.rs (200-254)`, extend `SpawnAgentRequest` with:
```rust
pub struct SpawnAgentRequest {
// existing fields...
/// True only when a local-to-cloud handoff source participated in orchestration.
#[serde(skip_serializing_if = "Option::is_none")]
pub orchestration_handoff: Option<bool>,
}
```
Wire contract:
- Orchestrated local-to-cloud handoff: `"orchestration_handoff": true`.
- Non-orchestrated local-to-cloud handoff: field absent.
- Fresh cloud launch: field absent.
The client never sends local run identifiers, parent/child identifiers, relationship direction, or local execution state through this field.
## End-to-end client flow
1. A local conversation starts handoff through an existing surface after global eligibility checks pass.
2. The client captures and uploads its task-context snapshot as in the existing handoff flow.
3. The client forks the synced source conversation with the existing fork RPC.
4. The client evaluates whether the source has a parent agent or locally-known children.
5. If it does, the pending handoff and `SpawnAgentRequest` contain `orchestration_handoff: Some(true)`; otherwise they contain `None`.
6. The client spawns the cloud run using the forked conversation id as `conversation_id`.
7. On cloud-start failure, existing snapshot cleanup and local recovery behavior remain unchanged.
## Tests
- Eligibility tests verify orchestrated conversations can use handoff after the orchestration-specific gate is removed, while existing global and operational blockers continue to apply.
- `app/src/terminal/view/ambient_agent/model_tests.rs` verifies a pending marker propagates to `SpawnAgentRequest` and serializes as `orchestration_handoff: true`.
- The same tests verify `None` omits the field for non-orchestrated handoff and fresh cloud launch paths.
## Validation
- Run `cargo fmt` for Rust formatting.
- Run focused compile/test coverage for modified APP-4579 request and handoff code without launching the application.
+49
View File
@@ -0,0 +1,49 @@
# APP-4579 — Local-to-cloud handoff for orchestrated agents
Linear: [APP-4579](https://linear.app/warpdotdev/issue/APP-4579/support-handoff-for-orchestrators-and-orchestrated-agents-or-make-it)
## Summary
Today, local-to-cloud handoff is unconditionally disabled for any conversation that is part of an orchestration tree — either a parent that has spawned children, or a child spawned by a parent. Typing `&` does nothing, the `/handoff` slash command is hidden, the footer chip is hidden, and the workspace action shows a toast that reads "Cloud handoff isn't available for orchestrated agent conversations." This is confusing because there is no technical blocker: the existing handoff plumbing already forks the conversation, uploads a workspace snapshot, and spawns a fresh cloud agent that rehydrates from that snapshot. The only thing missing is making the cloud agent aware that its prior orchestration relationships do not survive the handoff.
This spec enables local-to-cloud handoff for orchestrated agents. When the handoff happens, the cloud agent is given a single, hidden first-turn system message that tells it that other locally-running agents in its prior orchestration are no longer reachable, so it can stop expecting messages from them and stop trying to message them.
## Behavior
### Surfaces become available for orchestrated conversations
1. In a local conversation that has a parent agent, has at least one child agent, or both, the user can initiate local-to-cloud handoff via every existing entry point:
- typing `&` as the first character of agent input
- the `/handoff` slash command in the slash command menu
- the "Handoff to cloud" footer chip in agent view
- the workspace action (`WorkspaceAction::OpenLocalToCloudHandoffPane`) dispatched by URI handlers and auto-handoff (macOS sleep, etc.)
2. The "Cloud handoff isn't available for orchestrated agent conversations" toast in `app/src/workspace/view.rs:13830` is removed. Any path that previously short-circuited on orchestration now proceeds through the standard handoff flow.
3. The non-orchestration gating (cloud handoff disabled by user/org setting, AI disabled, cloud conversation storage off, feature flag off, etc.) is unchanged.
### Cloud agent is told it lost its orchestration context, on the first turn only
4. When a handoff happens from a conversation that was part of an orchestration tree, the cloud agent's first LLM turn includes one universal hidden system message. The message is purely generic — it names neither specific run ids, specific agent names, nor the source conversation's orchestration role — and is exactly: "You have been handed off from a local environment to this cloud environment. Any orchestration relationships you had at the time of handoff — including a parent agent that started you, sibling agents under that parent, and any child agents you previously started — remain in the local environment and cannot be reached from here. Do not attempt to send them messages, wait for their messages or events, or otherwise coordinate with them. Operate independently from this point forward. Any new agents you start in this environment are reachable normally; this notice refers only to the orchestration relationships that existed at the time of handoff."
5. This hidden message is delivered as part of the cloud agent's first turn after handoff. It is rendered as a `<system-message>`-wrapped system query, the same mechanism the snapshot-rehydration preamble uses, and is invisible in the user-facing transcript UI.
6. The message is included **only** on the cloud agent's first turn. On subsequent user follow-ups within the same cloud run, the message is not re-injected. The agent retains the message in its conversation context for those follow-ups (it is part of the transcript) but is not nagged with it again. This matches the existing snapshot-rehydration preamble behavior.
7. If the same cloud run later spawns new child agents from inside the cloud environment, the hidden first-turn message must not be re-applied to those new orchestration relationships. The universal message describes the existing local agents at handoff time, not cloud agents spawned afterward.
### Invariant: orchestration and snapshot prompts compose cleanly
8. When the cloud agent's first turn would normally include the snapshot-rehydration preamble (from the existing local-to-cloud snapshot pipeline) **and** the orchestration handoff applies, both messages are delivered. They are independent injections that do not suppress each other.
9. When only one applies, only that one is delivered. Concretely:
- orchestration handoff applies, no snapshot files were uploaded → only the orchestration message
- snapshot files were uploaded, source was not orchestrated → only the snapshot-rehydration preamble (existing behavior)
- both apply → both messages, snapshot rehydration first (so it is positioned next to the patches it talks about), orchestration message immediately after
10. When neither applies (e.g. a non-orchestration handoff with no touched workspace) → no hidden first-turn messages, same as today.
### Cloud-to-cloud handoff is unaffected
11. The cloud-to-cloud handoff path (e.g. the "Continue" tombstone flow gated by `HandoffCloudCloud`, and any cloud-to-cloud retry) does not inject the orchestration handoff message. A cloud agent that hands off to a fresh cloud sandbox keeps its server-side orchestration relationships intact, so the relationships are not "severed."
### Edge cases
12. A local conversation that is part of orchestration but has no `server_conversation_token` yet (conversation has not synced to the cloud) is still blocked from handoff — same as it is today for non-orchestration conversations — because the server-side fork requires a synced source. The existing inline error toast ("Your conversation hasn't synced to the cloud yet…") covers this case unchanged.
13. A local conversation that has an active long-running command is still blocked from handoff — same as today — and the existing "Can't hand off while a command is running" toast is shown unchanged. Orchestration status does not change this.
14. The local conversation that is handed off is cancelled at the end of the existing handoff flow, regardless of orchestration. Local sibling agents and local parents continue running until they finish or are cancelled by the user. We do not cancel them as part of the handoff — the user remains in control of their local runs.
15. Auto-handoff (macOS sleep, URI-triggered handoff) is now eligible for orchestration conversations too. Today it skips orchestration conversations via the same gate; once the gate is removed it will route orchestration conversations through the same flow as user-initiated handoff. The hidden first-turn orchestration message is delivered identically.
16. The setting "Cloud handoff" (REMOTE-1573) still gates this entire flow. If the user has cloud handoff disabled or it is force-disabled by cloud-conversations-off, orchestrated handoff is not available — same as non-orchestrated handoff.
## Success criteria
- A user in a local orchestrated conversation (parent or child) can hand off to cloud through every existing entry point with no warning toast or silent failure attributable to orchestration.
- The handed-off cloud agent's first LLM turn includes the universal hidden orchestration message, and that message is not re-injected on subsequent turns of the same cloud run.
- The cloud agent does not attempt to send messages to, or wait for events from, the parent/children it had at handoff time — verified by manual dogfood with a small orchestration tree and by spot-checking the cloud agent's tool calls.
- A handoff that triggers both snapshot rehydration and orchestration handoff delivers both hidden first-turn messages, in that order.
- Non-orchestration handoff behavior is unchanged.
+69
View File
@@ -0,0 +1,69 @@
# Queued Prompts V2 for `/compact-and` and `/fork-and-compact` — Tech Spec
Builds on the regular Agent Mode queued-prompts panel from `specs/REMOTE-1543/` and the Cloud Mode extension from `specs/APP-4562/`. This spec is the third (and final) step in moving every user-facing queued prompt surface onto the new panel UI behind `QueuedPromptsV2`.
## Context
Today `/compact-and <prompt>` and `/fork-and-compact <prompt>` still file their follow-up prompts through the legacy `PendingUserQueryBlock` path while a summarize (or fork-then-summarize) runs. The follow-up appears as a rich-content placeholder in the blocklist instead of as a row in the new queued-prompts panel.
Both flows funnel into one helper:
- `TerminalView::send_user_query_after_next_conversation_finished` (`app/src/terminal/view/pending_user_query.rs:185`) inserts a `PendingUserQueryKind::QueuedPrompt` block via `insert_pending_user_query_block` and stashes a `queued_prompt_callback` on `TerminalView` (`app/src/terminal/view.rs:4218`).
- `TerminalView::handle_finished_conversation` (`app/src/terminal/view.rs:4788`) drains both the new queue (`drain_queued_prompts`) and the legacy callback. The callback submits via `Input::submit_queued_prompt` on `FinishReason::Complete` and restores the text into the input on the error/cancel reasons.
The two callsites that reach this helper are:
- `/compact-and`: dispatched as `WorkspaceAction::SummarizeAIConversation { initial_prompt, .. }`, which calls `Workspace::summarize_active_ai_conversation` (`app/src/workspace/view.rs:12173-12200`). The active terminal view sends `SlashCommandRequest::Summarize` and, if an `initial_prompt` is present, calls `send_user_query_after_next_conversation_finished(prompt, /*show_close*/ true, /*show_send_now*/ false, ctx)`.
- `/fork-and-compact`: dispatched as `WorkspaceAction::ForkAIConversation { summarize_after_fork: true, initial_prompt, .. }`. After the fork is created and restored into the new pane, `Workspace::handle_forked_conversation_prompts` (`app/src/workspace/view.rs:12071-12117`) sends `SlashCommandRequest::Summarize` on the forked terminal view, then calls the same `send_user_query_after_next_conversation_finished` helper on that new terminal view. The forked conversation has already been restored via `restore_conversation_after_view_creation`, so `selected_conversation_id` on the new terminal view resolves to the forked id by the time the helper runs.
Other `on_next_conversation_finished` callers (`app/src/terminal/view.rs:6744, 13597, 13656`) are internal init/project sequencing — they do not render user-visible queued prompts and stay on the existing path. A grep for `send_user_query_after_next_conversation_finished` confirms `/compact-and` and `/fork-and-compact` are the only remaining queued-prompt surfaces still on the legacy block.
The new queued-prompts panel (`app/src/ai/blocklist/queued_prompts_panel.rs`) and its model `QueuedQueryModel` (`app/src/ai/blocklist/queued_query.rs`) already support exactly the surface this spec needs: append a row per origin, drain on `FinishReason::Complete` via `TerminalView::drain_queued_prompts` (`app/src/terminal/view.rs:5106`), and restore text to the input on error/cancel. For local Agent Mode, `drain_queued_prompts` submits via `Input::submit_queued_prompt_for_active_pane` (`app/src/terminal/input.rs:13145`), which falls back to `Input::submit_queued_prompt` (`app/src/terminal/input.rs:13062`) — the exact path the legacy callback already uses. No new submission plumbing is required.
## Proposed changes
### 1. Two new `QueuedQueryOrigin` variants for telemetry
Extend `QueuedQueryOrigin` (`app/src/ai/blocklist/queued_query.rs:22`) with two variants matching the existing per-origin pattern:
- `CompactAndSlashCommand`
- `ForkAndCompactSlashCommand`
Neither variant is locked (`QueuedQuery::is_locked` keeps `InitialCloudMode` as the only locked origin at `app/src/ai/blocklist/queued_query.rs:63`). These rows are user-managed: interactive drag, edit, and delete, exactly like `QueueSlashCommand` and `AutoQueueToggle` rows. The follow-up has not been sent anywhere yet at queue time; only the summarize is running.
Mirror the new variants into `TelemetryQueuedQueryOrigin` (`app/src/server/telemetry/events.rs:1187`) and its `From<QueuedQueryOrigin>` impl. No new telemetry events are needed — the existing `QueuedPrompt.Edited` / `QueuedPrompt.Deleted` / `QueuedPrompt.Reordered` / `QueuedPrompt.PanelCollapseToggled` events already carry `origin` and gate on `FeatureFlag::QueueSlashCommand`, which is transitively enabled by `QueuedPromptsV2` per `app/Cargo.toml:942`.
### 2. Single `TerminalView` helper hides the V2 gate from callers
Add `TerminalView::enqueue_followup_prompt` (next to `enqueue_prompt` at `app/src/terminal/view.rs:5051`):
```rust path=null start=null
pub fn enqueue_followup_prompt(
&mut self,
prompt: String,
origin: QueuedQueryOrigin,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::QueuedPromptsV2.is_enabled() {
self.queued_query_model.update(ctx, |model, ctx| {
model.append(conversation_id, QueuedQuery::new(prompt, origin), ctx);
});
} else {
self.send_user_query_after_next_conversation_finished(
prompt,
/* show_close_button */ true,
/* show_send_now_button */ false,
ctx,
);
}
}
```
The helper takes an explicit `conversation_id` so the `/fork-and-compact` caller can pass `forked_conversation_id` directly without depending on `selected_conversation_id` post-restoration ordering. The legacy branch ignores it, matching the existing helper which uses the input's selected conversation lazily inside the callback.
The helper is the only place that names `FeatureFlag::QueuedPromptsV2` for this rollout; subsequent cleanup deletes the legacy branch and the helper collapses to a one-liner.
### 3. Route both slash-command paths through the helper
Replace the two `send_user_query_after_next_conversation_finished` callsites in `Workspace` so they delegate to the helper instead:
- `Workspace::summarize_active_ai_conversation` (`app/src/workspace/view.rs:12194`): after sending `SlashCommandRequest::Summarize`, resolve the active conversation id via `terminal.ai_context_model().as_ref(ctx).selected_conversation_id(ctx)` and call `terminal.enqueue_followup_prompt(prompt, QueuedQueryOrigin::CompactAndSlashCommand, conversation_id, ctx)`. If `selected_conversation_id` is `None`, fall through with no follow-up (matches the legacy semantics — the slash-command handler at `app/src/terminal/input/slash_commands/mod.rs:1030-1048` already requires an active conversation to dispatch `/compact-and`).
- `Workspace::handle_forked_conversation_prompts` (`app/src/workspace/view.rs:12097`): pass the already-known `forked_conversation_id` and use `QueuedQueryOrigin::ForkAndCompactSlashCommand`.
Both callsites continue to send `SlashCommandRequest::Summarize` via `ai_controller` before enqueueing the follow-up, so the conversation transitions to `InProgress` before any drain hook can observe it.
### 4. Legacy path is preserved when the flag is off
When `QueuedPromptsV2` is off, the helper's `else` branch is the exact code that runs today: `send_user_query_after_next_conversation_finished` inserts a `PendingUserQueryKind::QueuedPrompt` block, sets `queued_prompt_callback`, and `handle_finished_conversation` drains it on completion. No other code paths change.
`PendingUserQueryIndicator` continues to gate the block's visibility inside `send_user_query_after_next_conversation_finished` (`app/src/terminal/view/pending_user_query.rs:192`); this spec does not modify that gate.
## Testing and validation
Behavior maps to the existing regular-queue panel invariants in `specs/REMOTE-1543/PRODUCT.md` (12-30, 31-37). The new code is the helper plus two callsites; testing focuses on the helper's branching and on the routing changes:
- **Helper branching**: Add unit tests next to the existing terminal view tests in `app/src/terminal/view/queued_prompts_test.rs` proving (a) with V2 on, `enqueue_followup_prompt` appends a row with the supplied origin and conversation id to `QueuedQueryModel`, and (b) with V2 off, it calls `send_user_query_after_next_conversation_finished`, which sets `pending_user_query_view_id` and `queued_prompt_callback`. Use the same `App::test`-style harness already used by sibling tests in that file.
- **`/compact-and` integration**: Dispatch `WorkspaceAction::SummarizeAIConversation { initial_prompt: Some(...) }` on a terminal view with an active conversation. With V2 on, assert the queued-prompts panel contains exactly one row with origin `CompactAndSlashCommand`. With V2 off, assert a `PendingUserQueryKind::QueuedPrompt` rich content was inserted and the legacy callback is set.
- **`/fork-and-compact` integration**: Drive `Workspace::handle_forked_conversation_prompts` with `summarize_after_fork: true` and an `initial_prompt`, then assert the **forked** terminal view's `QueuedQueryModel` contains exactly one row with origin `ForkAndCompactSlashCommand` keyed by the forked conversation id. Verify the source terminal view's queue is untouched.
- **Drain semantics**: With V2 on, simulate `FinishReason::Complete` on the conversation and assert the row drains through `submit_queued_prompt_for_active_pane` → local fallback `submit_queued_prompt`. Simulate `FinishReason::Error` with an empty input and assert the row's text lands in the input editor (matches §35 of `specs/REMOTE-1543/PRODUCT.md`).
- **Telemetry**: Add a serialization assertion for both new `TelemetryQueuedQueryOrigin` variants in the existing telemetry test pattern at `app/src/server/telemetry/events.rs`.
- **Compile gating**: `cargo check -p warp` and `cargo check -p warp --features queued_prompts_v2` both pass; `cargo fmt` and `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` per the WARP.md PR workflow.
Do not run the app to test.
## Parallelization
Single workstream. The helper, two callsite edits, origin enum changes, and telemetry mirror all sit in a tight ownership boundary (`TerminalView`, `Workspace`, `QueuedQueryModel`, telemetry events). Sub-agents would create merge churn on the same files without reducing wall-clock time.
## Risks and mitigations
- **`selected_conversation_id` not yet resolving to the forked id on `CurrentPane` forks**: the helper takes an explicit `conversation_id` from the caller, and the `/fork-and-compact` path already has `forked_conversation_id` in scope at `handle_forked_conversation_prompts`. The `/compact-and` path uses the selected conversation id resolved at dispatch time on the active terminal view, which is guaranteed to be set because the slash-command handler short-circuits when none exists.
- **Double drain on the legacy path**: `handle_finished_conversation` calls both `drain_queued_prompts` and the legacy `queued_prompt_callback`. With V2 off, only the legacy callback fires (no row was appended). With V2 on, only the V2 row is present (no callback was set). The helper's branching guarantees the two paths are mutually exclusive.
- **Telemetry origin drift between core and telemetry enums**: extending `QueuedQueryOrigin` without mirroring `TelemetryQueuedQueryOrigin` would compile but ship payloads without telemetry for the new origins. The exhaustive `match` in the `From` impl catches that at compile time, per the WARP.md "Exhaustive Matching" guideline.
- **Removing the legacy block UI prematurely**: this spec deliberately keeps `send_user_query_after_next_conversation_finished` and `PendingUserQueryBlock` intact for the V2-off case. Cleanup happens when `QueuedPromptsV2` is removed in a later pass.
+26
View File
@@ -0,0 +1,26 @@
# Product Spec: Attachments on Queued Prompts
Linear: [APP-4617](https://linear.app/warpdotdev/issue/APP-4617)
Figma: none provided
## Summary
Queued agent prompts retain the image and file attachments staged in the input at the time they were queued. The attachments move off the input onto the queued row, are sent with that prompt when it fires, and are restored to the input if the row is brought back for editing.
## Problem
With queued prompts, staged attachments lived only in the live input. Queuing a prompt either left them in the input (where they would be re-sent with the user's next prompt) or cleared them (losing them entirely). A queued prompt could never carry its own attachments, and a fired queued prompt could steal attachments meant for the next message.
## Behavior
1. When a prompt is queued while one or more attachments (images, files) are staged in the input, those attachments are captured onto the queued row and removed from the live input. The input returns to empty — no text and no attachment chips — ready for the next prompt.
2. Each queued row owns its own attachment set. Queuing prompt A with attachment X and then prompt B with attachment Y produces two rows that each carry only their own attachments; firing or removing one row never alters the other's attachments.
3. When a queued row fires (the conversation it was queued on finishes), the prompt is sent with that row's stored attachments: images are sent inline as image context, and files are sent as file references. Files that share a basename are disambiguated with `(1)`, `(2)`, … suffixes.
4. A fired queued row is always submitted into the conversation it was queued on, even if the user has since navigated to a different conversation. Its attachments resolve from the row, never from whatever is currently staged in the input.
5. Attachments staged for the user's next prompt are never consumed by a firing queued row. If the user stages new attachments after queuing, those stay in the input and are sent with the user's next prompt, independent of any row that fires in the meantime.
6. Restoring a queued row to the input re-stages its attachments (the chips reappear):
- If the head row was in edit mode when auto-fire reached it and the input is empty, the row's last-committed text and its attachments are placed back into the input. Uncommitted live-editor text is not used.
- When the user manually restores a row to the input for editing, its attachments are re-staged so a manual re-submit keeps them.
7. Removing a queued row — whether deleted by the user or removed after it fires — drops its attachments. They are not left behind in the input or any other row.
8. Queued slash commands and queued skill invocations carry attachments the same way: a queued skill invocation with staged images/files sends them with the skill when it fires. A direct (non-queued) skill invocation continues to consume the live input staging as before.
9. The `/queue` command, when the agent is not currently in progress, submits immediately as a regular prompt — consuming and clearing the live staging — rather than being treated as a queued-row fire.
10. **Known limitation — cloud follow-up:** when a queued row fires into a cloud follow-up prompt, which does not support attachments, the prompt text is still sent but the row's attachments are dropped (a warning is logged). No attachment chips or files reach the cloud run.
11. **Shared-session viewer:** when a queued row fires in a shared-session viewer, its stored images/files are uploaded and sent with the prompt (when the cloud pane supports image context), via the same upload-then-send path as an immediate viewer submission.
12. Empty-queue and locked-row behavior is unchanged: draining an empty queue does nothing, and the locked initial Cloud Mode row never auto-fires. Each conversation keeps an independent queue, so attachments on one conversation's rows never appear on another's.
+81
View File
@@ -0,0 +1,81 @@
# Tech Spec: Attachments on Queued Prompts
See `specs/APP-4617/PRODUCT.md` for user-visible behavior.
## Context
Queued prompts (V2) store per-conversation rows in `QueuedQueryModel` (`app/src/ai/blocklist/queued_query.rs`). Before this change a `QueuedQuery` held only `text` + `origin`; staged attachments lived solely in the live input staging on `BlocklistAIContextModel.pending_attachments`. Two things coupled attachments to the live input rather than the queued row:
- At enqueue time the input attachments were left in place or cleared, so a row never carried them.
- The send path always sourced pending attachments from the context model: `input_for_query` built image context from `vec![]` and `input_context_for_request` (`app/src/ai/blocklist/controller/input_context.rs`) appended `context_model.pending_files()` as `FilePathReference`s. A fired queued row therefore picked up whatever was currently staged, and a direct send after queuing re-sent the previous attachments.
Relevant code (current branch):
- `app/src/ai/blocklist/queued_query.rs``QueuedQuery` (text/origin), `AutofireAction`, `pop_for_autofire` (removed the head row and returned its action)
- `app/src/ai/blocklist/context_model.rs``pending_attachments` staging, `clear_pending_attachments`
- `app/src/ai/blocklist/controller.rs:3132``input_for_query`; send path in `send_query` (~758)
- `app/src/ai/blocklist/controller/input_context.rs (230-)` — pending-file → `FilePathReference` conversion
- `app/src/ai/blocklist/controller/slash_command.rs:68``SlashCommandRequest::send_request`
- `app/src/terminal/input.rs:13179` `submit_queued_prompt`, `:13277` `submit_queued_prompt_for_active_pane`, `:5242` `execute_skill_command`, viewer send (~13767)
- `app/src/terminal/input/slash_commands/mod.rs (1069-)``/queue` handling
- `app/src/terminal/view.rs:5199` enqueue, `:5227` `drain_queued_prompts`
- `app/src/terminal/view/pending_user_query.rs` — legacy pending-query submission
## Proposed changes
### 1. Rows own their attachments (`queued_query.rs`)
`QueuedQuery` gains `attachments: Vec<PendingAttachment>`, a `new_with_attachments(text, origin, attachments)` constructor (`:59`; `new` delegates to it), and an `attachments()` accessor (`:100`). `attachments_for(conversation_id, query_id)` (`:374`) returns a row's attachments by id without removing it; it returns `&[]` when the row is absent.
### 2. Capture-and-clear at every enqueue site
Each enqueue site drains the live input via a new `BlocklistAIContextModel::take_pending_attachments(ctx)` (`context_model.rs:987`) and stores the drained set on the row with `new_with_attachments`. `take_pending_attachments` emits the same `UpdatedPendingContext` event as `clear_pending_attachments` so the input's attachment chips disappear. Sites: `terminal/view.rs:5199`, the two auto-queue-toggle paths in `terminal/input.rs` (~13433, ~13490), and the `/queue` in-progress branch in `slash_commands/mod.rs`.
### 3. Auto-fire becomes peek + remove (`queued_query.rs`, `view.rs`)
`pop_for_autofire` (which mutated the queue) is replaced by:
- `peek_autofire(conversation_id) -> Option<AutofireAction>` (`:326`) — read-only; returns the head row's action while leaving the row in the queue so the send path can resolve its attachments by id.
- `remove_fired_row(conversation_id, query_id, ctx)` (`:349`) — removes the row after dispatch/restore and clears edit state if it pointed at that row.
Both `AutofireAction` variants now carry `query_id`; `PopFromEditMode` additionally carries `attachments`. `drain_queued_prompts` (`view.rs:5227`) peeks, dispatches or restores, then calls `remove_fired_row`. This peek-then-remove ordering is required: the row must stay addressable during the synchronous send so attachments can be read by id.
### 4. Send path resolves attachments by source (`controller.rs`, `input_context.rs`)
`InputQuery` gains `queued_query_id: Option<QueuedQueryId>`. In `send_query`, the attachment set for a `UserSubmittedQueryFromInput` is resolved once:
- `Some(id)``QueuedQueryModel::attachments_for(conversation_id, id)` (the fired row)
- `None``context_model.pending_attachments()` (live staging)
`input_for_query` (`:3132`) now takes `prompt_attachments: Vec<PendingAttachment>`, splits them into image context (sent inline) and file references, and no longer relies on `input_context_for_request` for pending files. The pending-file → `FilePathReference` conversion (with duplicate-basename suffixing) moves out of `input_context.rs` into a shared `add_pending_file_attachments` (`controller.rs:3190`); `input_context.rs` no longer sources pending files.
### 5. Conversation routing for fired rows (`controller.rs`, `slash_command.rs`, `input.rs`)
A fired row routes into the conversation it was queued on rather than re-deriving from the current UI selection. `send_queued_slash_command_request` and `send_queued_user_query_in_conversation` thread `queued_query_id` plus a `conversation_id` override; `SlashCommandRequest::send_request` (`slash_command.rs:68`) replaces its `is_queued_prompt: bool` with `queued_query_id: Option<QueuedQueryId>` + `conversation_id_override: Option<AIConversationId>` and derives `is_queued_prompt` from the id. Queued skill invocations resolve `prompt_attachments` from the row (or `vec![]` if the conversation is unknown) and feed them through `add_pending_file_attachments` into `InvokeSkillUserQuery`.
### 6. Preserve next-prompt staging (`controller.rs`, `slash_command.rs`)
The context reset after a send is skipped when `is_queued_prompt` is true (the fired row's attachments came from the row, so the live `pending_attachments` belong to the user's next prompt). The same guard applies to queued skill invocations so they don't clear a new draft's staged attachments; direct skills still reset.
### 7. Split immediate vs. queued submission (`input.rs`, `pending_user_query.rs`, `slash_commands/mod.rs`)
- `submit_queued_prompt` (`input.rs:13179`) now takes `conversation_id` + `query_id` and submits the fired row into that conversation.
- New `submit_user_query_now` (`input.rs:13248`) is the immediate (non-queued) path that resets live staging; used by the `/queue` not-in-progress fallback and the legacy pending-user-query paths in `pending_user_query.rs`.
- `submit_queued_prompt_for_active_pane` (`input.rs:13277`) takes `conversation_id` + `query_id` and branches: cloud follow-up (drop attachments, log a warning), shared-session viewer (upload via the shared path below), local agent (`submit_queued_prompt`).
- `execute_skill_command` (`input.rs:5242`) replaces `is_queued_prompt: bool` with `queued_query_id` + `conversation_id_override`.
### 8. Shared viewer upload path (`input.rs`)
A new `upload_and_send_viewer_prompt` is extracted from the immediate viewer-submit path and shared with the queued viewer drain, so both go through the identical upload-then-send (`Event::SendAgentPrompt`) flow. The queued viewer drain reads the firing row's images/files from `attachments_for` and passes them in.
### 9. Re-stage on restore (`view.rs`)
`drain_queued_prompts`' `PopFromEditMode` branch and the manual edit/restore path call `context_model.append_pending_attachments(row attachments)` after restoring the row's text, so the chips reappear and a manual re-submit keeps them.
## Testing and validation
Unit tests added alongside the changed modules; each maps to PRODUCT.md invariants:
- `context_model_tests.rs``take_pending_attachments` drains and returns all staged attachments and clears the input (inv. 1); enqueue moves staged attachments onto the row and leaves the input empty (inv. 1, 7).
- `queued_query_tests.rs``peek_autofire` leaves the row until `remove_fired_row` drops it (inv. 3); `PopFromEditMode` carries committed text + attachments and peek is non-mutating (inv. 6).
- `controller_tests.rs``input_for_query` builds image/file context purely from the provided attachment set, ignoring live staging, including duplicate-basename suffixing (inv. 3, 5).
- `queued_prompts_tests.rs` — multi-cycle queue keeps each row's attachments independent and draining one leaves the other intact (inv. 2); shared `drain_one` helper mirrors peek + `remove_fired_row`.
Manual verification:
- Stage an image + file, queue while the agent is busy → chips clear from input; on fire the prompt arrives with the image inline and the file referenced (inv. 1, 3).
- Queue two prompts with different attachments → each fires with only its own (inv. 2).
- Stage attachments after queuing → they ride the next manual prompt, not the fired row (inv. 5).
- Edit-mode auto-fire pop and manual restore → attachments re-appear in the input (inv. 6).
- Cloud follow-up fire → text sent, attachments dropped, warning logged (inv. 10).
- Shared-session viewer fire → attachments uploaded and sent (inv. 11).
## Risks and mitigations
- **Double-fire / leaked rows:** peek no longer removes the row, so `remove_fired_row` must run after every dispatch and every restore. `drain_queued_prompts` removes in both `Submit` and `PopFromEditMode` arms immediately after the synchronous dispatch.
- **Attachment lifetime:** attachments are cloned when resolved by id during send and dropped when the row is removed; there is no shared ownership between the row and the live input, which is what keeps inv. 2 and inv. 5 independent.
## Parallelization
Not beneficial. The change is a single tightly-coupled thread through the queued-prompt enqueue, drain, and send paths (`queued_query.rs``view.rs`/`input.rs``controller.rs`/`slash_command.rs`); the signature changes ripple across these files and must land together. Best done sequentially in one PR.
+33
View File
@@ -0,0 +1,33 @@
# Enter on empty input sends the top queued prompt
Linear: [APP-4717](https://linear.app/warpdotdev/issue/APP-4717/change-it-so-hitting-enter-w-an-empty-buffer-and-queued-prompts-auto)
## Summary
When the queued prompts panel is showing and the terminal input is empty, pressing Enter sends the top queued row immediately — exactly as if the user clicked that row's Send-now button. The panel header advertises this with a "⏎ to send" hint that appears only when Enter would actually send.
Figma: none provided. Reference is Cursor's queue UI, which shows an Enter-to-send hint next to its "N Queued" header label; we copy that placement in our panel header.
## Behavior
1. With the queued prompts panel visible, the input buffer completely empty, and the top queued row sendable (see 5), pressing Enter in the terminal input sends the top row immediately. This is identical to clicking that row's Send-now button:
- An agent prompt row is submitted to the same target Send-now would use (running conversation follow-up, cloud follow-up, shared-session viewer send, or full-terminal-use agent when one is in control), carrying the row's own queued attachments.
- A command row (`!` prefix) executes as a shell command.
- The row is removed from the queue after dispatch; remaining rows shift up.
2. This applies regardless of the input's mode. In shell mode, an empty-buffer Enter that previously produced a fresh prompt line instead sends the top queued row while the panel is showing a sendable top row.
3. Enter sends exactly one row per press. Pressing Enter again re-evaluates: if the new top row is sendable and the buffer is still empty, it sends that row next.
4. The behavior works the same whether the panel body is expanded or collapsed (the header is visible either way).
5. Enter mirrors Send-now availability for the top row. While the top row's Send-now is disabled — the locked initial cloud-mode prompt while cloud environment setup is in progress — Enter does nothing and the hint is hidden.
- The same applies whenever prompt sending is unavailable for the pane as a whole, e.g. the user is a read-only (non-executor) viewer in a shared session. In that state the rows' Send-now buttons are also disabled (with a tooltip explaining why), Enter does nothing, and the hint is hidden — button and Enter availability are bundled and may not disagree.
- Pane-level unavailability only affects sending: it does not disable a row's edit/delete buttons, and it does not stop new prompts from being queued.
- Enter-only conditions (non-empty buffer, CLI-agent rich input open) hide the hint and suppress Enter but do not disable the Send-now buttons.
6. If the buffer contains any content (including whitespace-only content), Enter behaves exactly as it does today and the hint is hidden.
7. Header hint:
- The panel header shows an ⏎ keycap followed by "to send" next to the "N queued" label, matching the look and spacing of Warp's existing keystroke hints (e.g. "? for help"). The "to send" text uses the same color as the "N queued" label; the keycap glyph is dimmer (disabled-text styling) so it reads as a secondary affordance.
- The hint is visible exactly when an empty-buffer Enter would send the top row, and hidden otherwise (non-empty buffer, no sendable top row, sending unavailable per 5, panel hidden, or any case in 810). The hint and the Enter behavior must never disagree.
8. Whenever the panel is not rendered (no queue, inline menu like slash commands or the model selector is open, feature flag off), Enter keeps its existing behavior and no hint is shown.
9. While a queued row is in inline edit mode, Enter commits that edit as today (focus is in the row's editor, not the input). The header hint is hidden during an inline edit.
10. When the CLI-agent rich input is open, Enter keeps its existing submit-to-CLI-agent behavior and the hint is hidden. (The `submit_on_ctrl_enter` setting only affects the CLI-agent rich input, so it never changes which key sends a queued row.)
11. Sending via Enter does not touch the input buffer, its pending attachments, or focus: the buffer stays empty, attachments staged in the input are not consumed (the queued row carries its own), and focus remains in the input afterward. One pre-existing exception, shared with the Send-now button: on the shared-session viewer path an empty input temporarily shows the standard "<prompt> ◌" loading affordance until the sharer acknowledges the send.
12. When the last queued row is sent, the panel disappears (existing behavior); a subsequent Enter behaves as it did before this feature.
13. Sending a queued row records telemetry distinguishing the trigger: Send-now button click vs. Enter on empty input.
+41
View File
@@ -0,0 +1,41 @@
# APP-4717 — Enter on empty input sends the top queued prompt
See `specs/APP-4717/PRODUCT.md` for behavior. Researched at commit `e367c9de8b9629600885e40b029c10c8915f9ec8`.
## Context
- [`app/src/terminal/input.rs:12808 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/terminal/input.rs#L12808) — `Input::input_enter`. CLI-agent rich input returns early at the top (L12809-12867), so the queue-send path never applies there (PRODUCT §10). The else-if chain at L12984-12988 (`maybe_launch_cloud_handoff_request` / `maybe_queue_input_for_in_progress_conversation` / …) is where the new empty-buffer check slots in; all existing branches in that chain require a non-empty buffer, so ordering is conflict-free.
- [`app/src/terminal/input.rs:3755-3793 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/terminal/input.rs#L3755-L3793) — `handle_queued_prompts_panel_event`: the existing Send-now dispatch (command vs prompt, `remove_fired_row`, refocus). This is the logic Enter must reuse.
- [`app/src/terminal/view/queued_prompts_panel.rs:580-620 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/terminal/view/queued_prompts_panel.rs#L580-L620) — `SendNow` action handler; skips rows where `row.is_locked()` (the initial cloud-mode prompt), which is exactly the head-row sendability condition (`update_send_now_availability`, L285-324, disables the head only when it is the locked initial cloud-mode row).
- [`app/src/terminal/view/queued_prompts_panel.rs:853-903 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/terminal/view/queued_prompts_panel.rs#L853-L903) — `render_header` ("N queued" label) where the "⏎ to send" hint goes. `should_render` (L548-563) already gates on flag, inline menus, and queue presence.
- [`app/src/terminal/input.rs:9756-9763 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/terminal/input.rs#L9756-L9763) — `Input` already detects empty↔non-empty buffer transitions on every `Edited` event (`is_editor_empty_on_last_edit`); the panel can be driven from here.
- [`app/src/server/telemetry/events.rs:2945-2963 @ e367c9de`](https://github.com/warpdotdev/warp/blob/e367c9de8b9629600885e40b029c10c8915f9ec8/app/src/server/telemetry/events.rs#L2945-L2963) — existing `QueuedPrompt*` telemetry events to extend.
## Proposed changes
1. Shared dispatch helper on `Input` (`app/src/terminal/input.rs`): extract the body of the `QueuedPromptsPanelEvent::SendNow` arm into `fn send_queued_row_immediately(&mut self, conversation_id, query_id, text, is_command, trigger, ctx)`. Both the panel-event arm and the Enter path call it. It emits the new telemetry event (below) on dispatch.
2. Panel send state (`app/src/terminal/view/queued_prompts_panel.rs`):
- `can_send_prompt: bool` (host-pushed via the change-detecting `set_can_send_prompt` setter) — whether the terminal can send prompts at all (false for read-only shared-session viewers, via the existing `SharedSessionStatus::is_reader()` helper). Gates the Send-now buttons (re-runs `update_send_now_availability`, with a "Read-only viewers cannot send prompts." tooltip), empty-Enter sends, and the hint. Edit/delete buttons are unaffected.
- Host-input emptiness is *not* pushed: the panel holds the host editor's `ViewHandle` (passed at construction) and reads `is_empty` live at decision time, so the Enter decision cannot trail same-update buffer changes. A subscription to the host editor's `Edited`/`BufferReplaced` events re-renders the panel on empty↔non-empty transitions (a cached `host_editor_was_empty` flag only damps these notifications), mirroring the `CLIAgentSessionsModel` pattern below.
The panel observes the CLI-agent rich input itself (a `CLIAgentSessionsModel` subscription plus a live `is_input_open` read — Enter submits to the CLI agent while it is open) and exposes `enter_sends_queued_prompt(ctx)` = `should_render` + `can_send_prompt` + live editor emptiness + rich input closed. The hint shows when that holds, no row is in inline edit mode, and the head row is sendable (`!is_locked()`), so the hint can never advertise an Enter that wouldn't fire.
3. Enter path: inlined in `input_enter`'s dispatch chain (before `maybe_launch_cloud_handoff_request`): when `panel.enter_sends_queued_prompt(ctx)` holds, look up the head row of the active conversation's queue (`BlocklistAIHistoryModel::active_conversation_id` + `QueuedQueryModel::queue(...).first()`, skipping a locked head) and dispatch it via `send_queued_row_immediately`; a locked head means Enter does nothing.
4. Push sites: `Input` seeds `can_send_prompt` at panel construction (`is_reader`) and passes the editor handle in; `TerminalView::on_self_role_updated` pushes `set_can_send_prompt(role.can_execute())` when the shared-session role changes (panel reached via the `Input::queued_prompts_panel()` accessor).
5. Header hint rendering: `render_header` appends an enter keycap chip (`render_keystroke_with_color_overrides`, the same component the "? for help" message-bar hints use) followed by "to send" text. The text uses the header's `sub_text_color`; the keycap glyph uses `internal_colors::text_disabled` so it is dimmer. Spacing follows the message-bar hint rules (`render_message_bar_items`): 8px label→keycap, 4px keycap→text.
6. Telemetry (`app/src/server/telemetry/events.rs`): new event `QueuedPromptSentNow { origin: TelemetryQueuedQueryOrigin, trigger: QueuedPromptSendNowTrigger }` with `QueuedPromptSendNowTrigger { SendNowButton, EnterOnEmptyInput }`, payload + descriptions following the adjacent `QueuedPrompt*` events. Emitted from the shared helper in (1). (Send-now currently has no telemetry; this adds it for both triggers.)
No new feature flag: the behavior ships under the existing `QueueSlashCommand` gate the panel already requires.
## Testing and validation
- Unit tests in `app/src/terminal/input_tests.rs` next to the existing queued-panel host tests (L1277+), driving `input_enter`:
- empty buffer + queued prompt row → head row dispatched, removed from queue, buffer untouched (PRODUCT §1, §11); a second Enter sends the next row (§3).
- empty buffer + queued command row, default shell mode → command executed instead of an empty shell submission (§1, §2).
- non-empty buffer → no queue send (§6).
- locked initial cloud-mode head row → no send (§5) — the `!is_locked` filter in the Enter path is the only guard on this path.
Send-permission gating (read-only viewer) and the flag-off case are intentionally not host-tested: the former is a pushed flag whose effects are covered by the panel tests below, and with the flag off the panel (and hook target) doesn't exist.
- Panel tests in `app/src/terminal/view/queued_prompts_tests.rs`: hint hidden during inline edit and for a locked head (§7, §9); Send-now buttons disabled when `can_send_prompt` is false (via `send_now_button_disabled_for_test`) but not merely because the input is non-empty (§5). Panel tests reuse the host input's own panel when the flag is on — a second panel on the same terminal view would fight over edit-editor focus and commit edits on blur.
- `cargo check` + `./script/format`; manual smoke: queue two prompts during a running conversation, hit Enter twice with an empty input.
## Parallelization
Not beneficial: the change is small and tightly coupled (one host file + one panel file share the dispatch helper and the empty-state plumbing). A single agent implements it on this branch (`harry/app-4717-change-it-so-hitting-enter-w-an-empty-buffer-and-queued`).
+19
View File
@@ -0,0 +1,19 @@
# Windows Quake Mode: Focus and Sizing Fix — Product Spec
Linear: CODE-1787
## Summary
When the quake mode hotkey is pressed while a non-Warp application has focus on Windows, the quake window should appear with correct size and receive keyboard focus. Currently, the window appears but does not receive focus, and its size falls back to a hardcoded default instead of matching the configured screen percentage.
## Behavior
1. Pressing the quake mode hotkey while a non-Warp application has foreground focus must show the quake window and transfer keyboard focus to it. The previously focused application must lose foreground focus.
2. Pressing the quake mode hotkey while a Warp window (non-quake) has focus must continue to show the quake window with correct focus, as it does today.
3. When the quake window is shown from a hidden state, its size must match the configured width and height percentages of the display, regardless of whether Warp or another application had focus before the hotkey was pressed.
4. The quake window must appear on the monitor that contains the application which had keyboard focus when the hotkey was pressed — not necessarily the monitor the cursor is on, and not a hardcoded fallback.
5. On single-monitor setups, invariants 3 and 4 reduce to: the quake window always appears at the correct configured size on the only display.
6. These invariants apply on Windows only. macOS quake mode behavior is unchanged.
+55
View File
@@ -0,0 +1,55 @@
# Windows Quake Mode: Focus and Sizing Fix — Tech Spec
Product spec: `specs/CODE-1787/PRODUCT.md`
## Context
Two independent bugs prevent quake mode from working correctly on Windows when triggered while a non-Warp application has foreground focus.
### Bug 1: focus not transferred
`WinitWindow::focus()` in `crates/warpui/src/windowing/winit/window.rs:1080` had two branches: if the window was already visible it called `focus_window()`, otherwise it called `set_visible(true)` and relied on visibility implying focus. On Windows, `set_visible(true)` does not steal foreground focus from another application — an explicit `SetForegroundWindow` (via winit's `focus_window()`) is required. The quake window was hidden via `set_visible(false)`, so re-showing it always took the `set_visible(true)` branch and never called `focus_window()`.
### Bug 2: incorrect window size
All Windows monitor queries in `crates/warpui/src/windowing/winit/window/windows_wm.rs` routed through `get_active_window_handle()`, which requires a focused + visible Warp window. When no Warp window has focus, this fails and `active_display_bounds()` falls back to a hardcoded `DEFAULT_WINDOW_SIZE` (1280×800). The quake window is then sized as a percentage of that default instead of the actual display dimensions.
### Relevant code
- `crates/warpui/src/windowing/winit/window.rs:1080-1092``WinitWindow::focus()`
- `crates/warpui/src/windowing/winit/window/windows_wm.rs` — all Windows monitor query methods
- `crates/warpui/src/windowing/winit/window.rs:222-227``WindowManager::show_window_and_focus_app` (calls `focus()`)
- `app/src/root_view.rs:1481-1507` — quake mode toggle, hidden→visible branch
## Proposed changes
### 1. Always call `focus_window()` in `WinitWindow::focus()`
Restructure `focus()` so `focus_window()` is called unconditionally after the window is made visible or un-minimized. The previous code only called it in the already-visible branch.
Before:
```
if visible → set_minimized(false); focus_window()
else → set_visible(true) // hoped this would also focus
```
After:
```
if visible → set_minimized(false)
else → set_visible(true)
focus_window() // always, regardless of prior visibility
```
This fixes Behavior 1 and 2.
### 2. Decouple monitor queries from active-window requirement
Split the Windows monitor methods into two categories:
**Global queries** (don't care which monitor): `get_primary_monitor_handle`, `get_available_monitors`, `get_available_monitor_count`. These only need *any* winit window handle to access platform APIs. Add `get_any_window_handle()` which returns the first available window regardless of focus, and call it directly from these methods.
**Active-monitor queries** (need to know which monitor the user is on): `get_active_monitor`, `get_current_monitor_id`, `get_active_monitor_logical_bounds`. When a Warp window has focus, use its `current_monitor()`. When no Warp window has focus, fall back to `get_foreground_monitor()`, which uses Win32 `GetForegroundWindow` + `MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)` to find the monitor of the app that has keyboard focus. This is the window that triggered the global hotkey.
This fixes Behavior 3 and 4. The foreground-window approach is preferred over cursor position because the cursor may be on a different monitor than the window handling keypress events.
### 3. Add Win32 feature dependencies
Enable `Win32_Graphics_Gdi` (for `MonitorFromWindow`, `MONITOR_DEFAULTTONEAREST`) and `Win32_UI_WindowsAndMessaging` (for `GetForegroundWindow`) in `crates/warpui/Cargo.toml`.
## Testing and validation
- Manual: configure quake mode with 100% width, focus a non-Warp app, press the hotkey. Verify the quake window receives focus and spans the full display width. (Behavior 1, 3)
- Manual: with a Warp window focused, press the hotkey. Verify existing behavior is preserved. (Behavior 2)
- Manual (multi-monitor): focus an app on monitor B, press the hotkey. Verify the quake window appears on monitor B at the correct size. (Behavior 4)
- Manual: verify macOS quake mode is unaffected — changes are behind `#[cfg(windows)]` and winit-only code paths. (Behavior 6)
+62
View File
@@ -0,0 +1,62 @@
# Watch remote refs for updates — Product Spec
GitHub issue: https://github.com/warpdotdev/warp/issues/10090
Figma: none provided
## Summary
Warp should notice when Git remote-tracking refs stored on disk change for a repository that Warp is already watching. When the changed remote ref is the upstream ref tracked by a watched repository's current branch, Warp refreshes that repository's Git metadata so code review and Git operations UI reflects the new push/fetch state without requiring a manual reload or another unrelated filesystem change.
The first user-visible outcome is that the unpushed commit computation updates after a user pushes a branch. If a successful push updates `.git/refs/remotes/<remote>/<branch>`, Warp should recompute the current branch metadata and stop showing those commits as unpushed.
## Problem
Warp's repository watcher already reacts to local working-tree changes and selected Git metadata changes such as `HEAD`, local branch refs, and `index.lock`. Remote-tracking refs under `.git/refs/remotes/*` are ignored today. As a result, a push or fetch that updates a loose remote ref can leave Warp's cached code review metadata stale. The user may still see a Push action, an outdated unpushed commit list, or stale branch comparison metadata even though Git has already updated the remote-tracking ref on disk.
Users expect Warp's Git UI to track Git state changes that happen through Warp or through external commands in the same repository. Remote refs are part of that local Git state when they are stored as loose refs.
## Goals
- Detect loose remote-tracking ref changes under `.git/refs/remotes/<remote>/<branch>` for repositories already watched by Warp's repo watcher.
- Refresh Git metadata only for watched repositories whose current branch tracks the changed remote ref.
- Update code review and Git operations UI after a push so the unpushed commit list, primary action, and related metadata reflect the new upstream state.
- Preserve the existing handling for local commit-related refs (`HEAD`, `refs/heads/*`) and `index.lock`.
- Support normal repositories and linked worktrees whose remote refs live in the shared common `.git` directory.
## Non-goals
- Watching or parsing packed refs. Remote refs that only change in `.git/packed-refs` are intentionally out of scope for this implementation.
- Fetching from the network, comparing remote state directly, or polling Git. This feature reacts only to local filesystem watcher events.
- Refreshing repositories that do not track the changed remote ref.
- Refreshing metadata for non-current branches that happen to track the changed remote ref. Warp's current Git metadata is scoped to the repository's active branch.
- Changing push, publish, fetch, or PR creation behavior beyond making existing metadata refresh automatically when loose remote refs change.
- Adding new user-facing controls, preferences, telemetry, or feature flags.
## User experience
1. When a watched repository is on a branch with upstream `origin/feature` and Git updates `.git/refs/remotes/origin/feature`, Warp refreshes that repository's Git metadata after the existing watcher debounce and metadata throttling intervals.
2. After the refresh, the code review Git operations UI no longer shows commits as unpushed if `origin/feature..HEAD` is now empty.
3. If the branch still has commits ahead of its upstream after the remote ref update, Warp continues to show those commits as unpushed.
4. If the remote-tracking ref update creates the upstream ref for a newly published branch, Warp refreshes metadata for the repository that now tracks that ref and the primary action can advance from Publish/Push to the next appropriate state.
5. If a different remote ref changes, for example `.git/refs/remotes/origin/main` while the current branch tracks `origin/feature`, Warp does not refresh the feature branch's repository metadata solely because of that event.
6. If two watched worktrees share the same common `.git` directory and only one worktree's current branch tracks the changed remote ref, only that worktree's repository metadata refreshes. Other worktrees sharing the common Git directory are not invalidated unless their current branch tracks the same remote ref.
7. If multiple watched repositories or worktrees currently track the same changed remote ref, each of those repositories refreshes metadata.
8. If the repository has no upstream for its current branch, detached `HEAD`, an unreadable Git config, or a malformed upstream configuration, remote-tracking ref updates do not trigger a metadata refresh for that repository.
9. Existing local ref behavior is unchanged. Changes to `.git/HEAD`, `.git/refs/heads/*`, worktree-specific `HEAD`, and `index.lock` continue to produce the same metadata invalidations and locked-index behavior as before.
10. Remote refs stored only in packed refs are ignored. If a push or fetch updates only `.git/packed-refs`, Warp may remain stale until another existing refresh trigger occurs.
11. The behavior is transparent. Users should not see a new toast, spinner, setting, or prompt specifically for remote-ref watcher events.
12. Filesystem errors, missing refs, and racing Git updates are handled silently in the same style as existing watcher events. A failed attempt to classify a remote ref should not crash Warp or invalidate unrelated repositories.
## Success criteria
1. In a watched repository on a branch tracking `origin/<branch>`, pushing commits so that `.git/refs/remotes/origin/<branch>` updates causes Warp to refresh metadata and remove those commits from the unpushed commit list.
2. The primary Git action no longer remains stuck on Push after the current branch has no unpushed commits.
3. A remote-tracking ref update for an unrelated branch does not refresh metadata for the active repository.
4. A linked worktree whose current branch tracks the changed remote ref refreshes even though the remote ref is stored in the shared common `.git` directory outside the worktree root.
5. A linked worktree sharing the same common `.git` directory but tracking a different remote ref does not refresh.
6. Existing local branch, `HEAD`, and `index.lock` watcher behavior remains covered by regression tests and does not change.
7. Loose-ref behavior is documented and tested; packed-ref updates remain explicitly outside the scope of this feature.
## Validation
- Add unit tests for remote-ref path classification, including `.git/refs/remotes/origin/main`, remote branch names with slashes, worktree paths that must not be treated as shared remote refs, tags, local heads, and `packed-refs`.
- Add watcher routing tests that simulate remote ref changes and assert updates are delivered only to repositories whose current branch tracks the changed ref.
- Add linked-worktree routing tests for a common `.git/refs/remotes/*` update shared across multiple worktrees.
- Add code review metadata tests, or extend existing watcher subscriber tests, to assert that a remote-ref update sets the same metadata-refresh path used for recomputing unpushed commits.
- Manually validate with a real repository: open code review on a branch with unpushed commits, push the branch, and confirm the unpushed commits and primary action update without reopening the pane.
- Manually validate from an external terminal command that updates the same repository while Warp is running.
## Open questions
- None for product behavior. The scope is intentionally limited to loose remote-tracking refs and current-branch upstream metadata.
+254
View File
@@ -0,0 +1,254 @@
# Watch remote refs for updates — Tech Spec
Product spec: `specs/GH10090/product.md`
GitHub issue: https://github.com/warpdotdev/warp/issues/10090
## Problem
`repo_metadata` already watches repository roots and selected Git internals, but remote-tracking refs under `.git/refs/remotes/*` are filtered out. Code review metadata computes unpushed commits from the current branch's upstream ref, so a push or fetch that updates a loose remote-tracking ref can leave `DiffStateModel` and the Git operations UI stale until another invalidation happens.
The implementation needs to allow loose remote-ref watcher events through, keep each watched `Repository` aware of the loose remote ref tracked by its active branch, refresh that cached tracking state when `HEAD` or Git config changes can alter it, and expose remote-ref invalidations explicitly on `RepositoryUpdate`.
## Relevant code
- `crates/repo_metadata/src/entry.rs (361-484)` — Git internal path helpers: `git_suffix_components`, `extract_worktree_git_dir`, `is_shared_git_ref`, `is_commit_related_git_file`, `is_index_lock_file`, and `should_ignore_git_path`.
- `crates/repo_metadata/src/entry_test.rs (222-395)` — current allowlist and shared-ref tests; remote refs and `.git/config` are currently asserted as ignored.
- `crates/repo_metadata/src/watcher.rs (120-194)``DirectoryWatcher::find_repos_for_git_event`, which routes worktree-specific, shared local branch ref, and repo-specific Git events.
- `crates/repo_metadata/src/watcher.rs (293-335)``start_watching_directory`, which registers watched paths with the `should_ignore_git_path` filter.
- `crates/repo_metadata/src/watcher.rs (391-529)` — filesystem event handling that converts Git internal events into `RepositoryUpdate`.
- `crates/repo_metadata/src/watcher.rs (588-626)``RepositoryUpdate`, especially `commit_updated` and `index_lock_detected`.
- `crates/repo_metadata/src/repository.rs (54-151)``Repository` stores `root_dir`, optional per-worktree `external_git_directory`, and optional shared `common_git_directory`.
- `crates/repo_metadata/src/repository.rs (162-229)``Repository::start_watching`, which registers the worktree root, per-worktree gitdir, and shared `refs/heads` for linked worktrees.
- `app/src/code_review/diff_state.rs (1116-1247)` — code review repository subscriber maps repository updates to metadata invalidation and throttled metadata refresh.
- `app/src/code_review/diff_state.rs (1347-1393)``load_metadata_for_repo` reads `@{u}` and recomputes `unpushed_commits`.
- `app/src/code_review/git_status_update.rs (168-283)` — Git status metadata watcher refreshes when repository metadata flags indicate Git state changed.
- `app/src/util/git.rs (391-468)``get_unpushed_commits` computes `<upstream>..HEAD`.
## Current state
`should_ignore_git_path` uses an allowlist for `.git` internals. Only commit-related files (`HEAD` and `refs/heads/*`) and `index.lock` are allowed through; `.git/refs/remotes/origin/main` and `.git/config` are explicitly ignored in `entry_test.rs`.
For normal repositories, the repository root watcher is recursive, so `.git/refs/remotes/*`, `.git/config`, and `.git/HEAD` would be observable if the filter allowed them. For linked worktrees, remote refs and shared config are stored in the shared common `.git` directory, outside the worktree checkout and outside the per-worktree gitdir. `Repository::start_watching` currently adds `common_git_dir/refs/heads` for shared local branch refs, but it does not add `common_git_dir/refs/remotes` or common Git config.
Once a Git internal path reaches `DirectoryWatcher::handle_watcher_event`, it is classified with `is_commit_related_git_file` or `is_index_lock_file`. A `commit_updated` update is enough to trigger the existing code review and git-status metadata refresh paths, but it does not distinguish a local commit/branch update from an upstream remote-ref update. The missing pieces are path classification, cached tracked-upstream state on `Repository`, watcher registration for linked worktrees, scope-aware routing to repositories tracking the changed remote ref, and an explicit `RepositoryUpdate` field for remote-ref changes.
## Proposed changes
### 1. Add remote-tracking ref and tracking-config path helpers
Extend `crates/repo_metadata/src/entry.rs` with helpers for loose remote-tracking refs and Git files that can change a repository's tracked remote ref:
- `is_remote_tracking_ref(path: &Path) -> bool`
- true for paths under `.git/refs/remotes/<remote>/<branch...>`.
- false for paths under `.git/worktrees/<name>/...`, `.git/refs/heads/*`, `.git/refs/tags/*`, `.git/packed-refs`, and non-Git paths.
- requires at least a remote component and one branch component after `refs/remotes`.
- `remote_tracking_ref_path_under_common_git_dir(path: &Path) -> Option<PathBuf>`
- canonicalization-friendly helper for routing. It returns the full loose ref path only for shared remote refs, not worktree-local files.
- `is_tracking_state_git_file(path: &Path) -> bool`
- true for files that can change the active branch's tracked remote ref: repository-specific `HEAD`, common `.git/config`, and per-worktree `config.worktree` when present.
- false for local branch ref files, tags, `packed-refs`, objects, logs, and hooks.
Update `should_ignore_git_path` so loose remote-tracking refs and tracking-state files are allowlisted. Keep `packed-refs` ignored. Do not add broad `refs/*` matching; tags and other Git internals remain filtered out.
Keep `is_commit_related_git_file` focused on `.git/HEAD` and `.git/refs/heads/*`. Remote refs should not be folded into that helper because `RepositoryUpdate` will expose them separately.
### 2. Store the tracked remote ref on `Repository`
Add a cached tracked-upstream field to `crates/repo_metadata/src/repository.rs`:
- `tracked_remote_ref: Option<TrackedRemoteRef>`
Add a small internal type:
- `TrackedRemoteRef`
- `full_ref_name: String`
`full_ref_name` should be the symbolic full upstream ref returned by Git, for example `refs/remotes/origin/feature`. Store the ref name rather than remote/branch components so Git owns branch config parsing, quoted branch names, worktree config, includes, and other config edge cases.
`Repository` should initialize this field in `Repository::new` or during the first watcher registration, and expose narrow helpers:
- `pub(crate) fn tracked_remote_ref(&self) -> Option<&TrackedRemoteRef>`
- `pub(crate) fn tracks_remote_ref_path(&self, remote_ref_path: &Path) -> bool`
- `pub(crate) fn refresh_tracked_remote_ref(&mut self) -> bool`
- `pub(crate) fn tracked_remote_ref_path(&self) -> Option<PathBuf>`
`refresh_tracked_remote_ref` should run Git in the repository worktree context and update the cached value:
1. Run `git -C <repo_root> rev-parse --symbolic-full-name @{u}`.
2. If Git exits non-zero, cache `None`. This covers detached `HEAD`, no upstream, malformed config, unreadable config, and racing branch changes.
3. Trim stdout to one line.
4. If the ref name does not start with `refs/remotes/`, cache `None`. This excludes local upstreams such as `remote = .` that resolve to `refs/heads/<branch>`.
5. Validate the ref name is relative and does not contain path traversal components.
6. Cache `TrackedRemoteRef { full_ref_name }`.
Implementation details:
- Prefer using an existing Git command helper in the app/repo metadata layer if one is available; otherwise add a narrow helper dedicated to resolving the current upstream ref.
- Do not run Git for every remote-ref event. Run it only on repository construction/startup and allowlisted tracking-state events (`HEAD`, common `.git/config`, and optional `config.worktree`). Remote-ref routing should use the cached value.
- Use the existing repository task queue for Git-backed upstream refreshes so filesystem watcher routing does not block on process execution. Add a task such as `Task::RefreshTrackedRemoteRef { repository: WeakModelHandle<Repository> }`. The task should run the Git command off the watcher event path, then update the repository cache on completion. If the cached value changed, enqueue `RepositoryUpdate { remote_ref_updated: true, ..Default::default() }` for that repository's subscribers. Do not broaden remote-ref routing to every repository as a shortcut.
- `tracked_remote_ref_path` should compute `self.common_git_dir().join(full_ref_name)` from the cached `TrackedRemoteRef`; do not store the path separately as duplicate state.
- `tracks_remote_ref_path` should compare the changed path to the computed tracked remote ref path, normalizing/canonicalizing existing parent paths where possible.
This cached field is the scope boundary that satisfies the product requirement that only repositories tracking the changed remote ref refresh metadata.
### 3. Refresh cached tracking state when it can change
The tracked remote ref for a watched repository can change when any of these local files change:
- `self.git_dir()/HEAD`: the active branch changes, `HEAD` becomes detached, or `HEAD` is reattached to a branch with a different upstream.
- `self.common_git_dir()/config`: branch upstream config changes, including `git branch --set-upstream-to`, `git branch --unset-upstream`, `git push -u`, `git remote rename`, and manual edits to `branch.<name>.remote` or `branch.<name>.merge`.
- `self.git_dir()/config.worktree`: worktree-specific upstream config changes, if worktree-specific config is enabled and this repo supports reading it.
Update watcher handling so events for `is_tracking_state_git_file(path)` enqueue a tracked-remote-ref refresh task for each affected repository instead of running Git inline. When the queued task completes, it should compare the resolved upstream ref to the cached value. If `refresh_tracked_remote_ref` changed the cache, enqueue `RepositoryUpdate { remote_ref_updated: true, ..Default::default() }` for that repository.
Routing for tracking-state files should be scoped by where the file lives:
1. Worktree-specific `HEAD` and `config.worktree` under `.git/worktrees/<name>/...` route only to that linked worktree.
2. Normal repo `.git/HEAD` routes only to the repository whose working tree owns that `.git` directory.
3. Common `.git/config` routes to all watched repositories whose `common_git_dir()` matches that `.git` directory, then each repository decides whether its cached `tracked_remote_ref` changed.
A common config edit may affect multiple watched worktrees if Git resolves a different upstream for their active branches. It may also affect none of them. The config event may enqueue refresh tasks for every watched repository sharing that common Git directory, but only repositories whose resolved upstream changed should receive `remote_ref_updated`. Do not broaden remote-ref refreshes to every watched worktree as a substitute for the per-repository cache check.
### 4. Register shared Git paths for linked worktrees
Update `Repository::start_watching` in `crates/repo_metadata/src/repository.rs` so linked worktrees watch shared Git paths from the common Git directory in a way that includes remote-tracking refs and shared config.
Preferred registration for linked worktrees:
- the worktree root, as today.
- the per-worktree gitdir, as today, for `HEAD`, `index.lock`, and optional `config.worktree`.
- `common_git_dir/refs`, when it exists, so shared local branch refs and remote-tracking refs are visible.
- `common_git_dir/config`, if the watcher can register a file path; otherwise `common_git_dir` with the existing allowlist filter.
This lets a linked worktree observe both existing remote refs and first-time creation under `refs/remotes` without having to create Git directories from Warp. It also lets upstream tracking additions/removals in common config update the cached `tracked_remote_ref`.
Update `Repository::stop_watching` to unregister the same shared paths when the last subscriber is removed. Keep start/stop symmetric, preferably by sharing a helper that computes the optional watch paths.
For normal repositories, no extra watch path is required because the root watcher already recursively covers `.git/refs/remotes`, `.git/config`, and `.git/HEAD` once the filter allows those paths.
### 5. Add remote-ref routing in `DirectoryWatcher`
Update `DirectoryWatcher::find_repos_for_git_event` in `crates/repo_metadata/src/watcher.rs` with a routing tier for shared remote-tracking refs:
1. Worktree-specific paths under `.git/worktrees/<name>/...` keep the existing highest-priority route.
2. Remote-tracking refs under `.git/refs/remotes/*` route to watched repositories whose `common_git_dir()` contains the event path and whose `Repository::tracks_remote_ref_path(event_path)` returns true based on the cached full upstream ref name.
3. Shared local branch refs under `.git/refs/heads/*` keep the existing broadcast-to-shared-common-git-dir route.
4. Common `.git/config` routes to all watched repositories sharing that common Git directory so each can refresh its cached tracked remote ref.
5. Repo-specific paths keep the existing fallback route.
The remote-ref tier should deduplicate repository handles just like the existing tiers. It should log the routing tier and affected repo count with the existing `[GIT_EVENT_ROUTING]` pattern.
Remote-ref filesystem events should produce `RepositoryUpdate { remote_ref_updated: true, ... }` synchronously after cached-path routing. Tracking-state events should not produce an immediate subscriber update; they enqueue `Task::RefreshTrackedRemoteRef`, and task completion produces `remote_ref_updated = true` only for repositories whose cached `tracked_remote_ref` changed. No Git internal path should be added to `added`, `modified`, `deleted`, or `moved`, matching the current treatment for local Git metadata files.
### 6. Add a queued tracked-ref refresh task
Extend `TaskQueue` in `crates/repo_metadata/src/watcher.rs` with a task dedicated to refreshing the cached upstream ref:
- `Task::RefreshTrackedRemoteRef { repository: WeakModelHandle<Repository> }`
The task should:
1. Upgrade the repository handle.
2. Run `refresh_tracked_remote_ref` for that repository, using Git to resolve `@{u}` outside the watcher event path.
3. If the cached value changed, collect the repository's current subscriber IDs and enqueue `Task::Update` with `RepositoryUpdate { remote_ref_updated: true, ..Default::default() }` for each subscriber.
4. If the repository was dropped, Git fails, or the resolved upstream is unchanged, complete without delivering a subscriber update unless the cache changed to or from `None`.
This preserves ordering enough for correctness: if `git push -u` updates both `.git/config` and a remote ref and the remote-ref event arrives before the cache refresh completes, that remote-ref event may not match the old cache. The queued config refresh still emits `remote_ref_updated` when the tracked upstream changes, so code review metadata refreshes without requiring broad remote-ref routing.
### 7. Add `RepositoryUpdate.remote_ref_updated`
Extend `crates/repo_metadata/src/watcher.rs`:
- `pub remote_ref_updated: bool`
- true when the repository's tracked upstream state changed, or when the loose remote-tracking ref currently tracked by the repository changed.
Update all `RepositoryUpdate` plumbing:
- `RepositoryUpdate::is_empty` should include `!self.remote_ref_updated`.
- `merge_repository_updates` should OR `remote_ref_updated`, like `commit_updated` and `index_lock_detected`.
- Destructuring call sites, tests, and default builders should include the new field.
- Logs should distinguish `commit_updated`, `remote_ref_updated`, and `index_lock_detected`.
Keep `commit_updated` for local commit/branch state (`HEAD` and `refs/heads/*`). Use `remote_ref_updated` for upstream state so consumers can reason about refresh causes without conflating local and remote ref changes.
### 8. Preserve code review and git status behavior
Consumers should refresh metadata when either `commit_updated` or `remote_ref_updated` is true.
Existing flow after the watcher update:
1. `Repository` caches that the active branch tracks `.git/refs/remotes/origin/feature`.
2. Watcher sees `.git/refs/remotes/origin/feature` change.
3. `find_repos_for_git_event` routes only to repositories whose cached full upstream ref name computes to that tracked remote ref path.
4. `handle_watcher_event` enqueues `RepositoryUpdate { remote_ref_updated: true, ... }`.
5. `DiffStateModel::handle_file_update` treats `remote_ref_updated` like `commit_updated` for metadata invalidation and schedules the throttled metadata refresh.
6. `load_metadata_for_repo` re-reads `@{u}` and recomputes `get_unpushed_commits`.
7. `CodeReviewView` observes the updated metadata and recalculates the primary Git action.
Tracking-add/remove flow:
1. Git updates `.git/config` after `git push -u`, `git branch --set-upstream-to`, or `git branch --unset-upstream`.
2. The watcher routes the config event to repositories sharing that common Git directory.
3. Each repository calls `refresh_tracked_remote_ref`.
4. Repositories whose cached tracked remote ref changed receive `RepositoryUpdate { remote_ref_updated: true, ... }`.
5. Code review and Git status metadata refresh from the same path used for remote-ref content updates.
`GitRepoStatusModel::should_refresh_metadata` should also return true for `remote_ref_updated`, so branch/status metadata remains consistent.
### 9. Tests
Add and update unit tests in `crates/repo_metadata`:
- `entry_test.rs`
- `should_ignore_git_path` does not ignore `.git/refs/remotes/origin/main`.
- `should_ignore_git_path` does not ignore `.git/config` or `.git/worktrees/<name>/config.worktree`.
- remote branch names with slashes are recognized.
- `.git/refs/remotes/origin` without a branch is not recognized.
- `.git/packed-refs`, `.git/refs/tags/*`, `.git/refs/heads/*`, and worktree-local paths are not remote-tracking refs.
- existing local branch and index-lock assertions remain unchanged.
- `repository.rs` tests or a new `repository_tests.rs`
- `tracked_remote_ref` initializes from a mocked or fixture-backed `git rev-parse --symbolic-full-name @{u}` result such as `refs/remotes/origin/feature`.
- `tracked_remote_ref_path` computes `common_git_dir/refs/remotes/origin/feature` from cached full ref name state.
- `tracks_remote_ref_path` returns true for the computed loose remote ref path.
- slash-containing branch names are preserved in the full ref name returned by Git.
- `refresh_tracked_remote_ref` returns true when Git reports tracking was added, removed, or changed to a different upstream ref, and false when the resolved upstream ref is unchanged.
- returns/caches `None` for detached `HEAD`, no upstream, Git command failure, local upstreams such as `refs/heads/main`, malformed output, absolute paths, and path traversal components.
- runs Git in the worktree root so linked worktrees resolve their own active branch and worktree-specific config correctly.
- `watcher_tests.rs`
- a matching remote-ref event is delivered to a subscriber as `remote_ref_updated = true`, `commit_updated = false`, and contains no file-list changes.
- an unrelated remote-ref event is not delivered to a repository that tracks a different upstream.
- two repositories/worktrees tracking the same remote ref both receive the update.
- a `.git/config` change that adds, removes, or changes the active branch upstream enqueues `Task::RefreshTrackedRemoteRef`, and task completion emits `remote_ref_updated = true`.
- a `.git/config` change unrelated to the active branch may enqueue `Task::RefreshTrackedRemoteRef`, but task completion does not emit `remote_ref_updated` when the resolved upstream is unchanged.
- linked worktrees register/unregister shared refs and shared config in a way that covers `refs/remotes`, including first-time creation under `refs/remotes` when the common `refs` directory exists.
- local `refs/heads/*`, worktree-specific `HEAD`, and `index.lock` routing continue to pass existing regression tests.
Update app-level tests only if repo-metadata tests cannot prove the end-to-end invalidation contract. The key app-level assertion is that `DiffStateModel::handle_file_update` treats `remote_ref_updated` as full metadata invalidation, matching `commit_updated`.
### 10. Manual validation
1. Open Warp code review on a branch tracking `origin/<branch>` with one or more unpushed commits.
2. Push the branch from Warp or an external terminal.
3. Confirm the loose ref `.git/refs/remotes/origin/<branch>` updates.
4. Confirm the unpushed commit list clears and the primary Git action updates without reopening code review.
5. Run `git branch --unset-upstream`, then `git branch --set-upstream-to=origin/<branch>`, and confirm metadata refreshes when tracked remote ref state is removed and restored.
6. Repeat with another branch's remote ref update and confirm the active branch does not refresh.
7. Repeat in a linked worktree whose common `.git` directory is outside the worktree checkout.
## Risks and mitigations
### Risk: over-invalidating every worktree sharing a common `.git`
Shared refs and shared config are visible to every linked worktree. Broadcasting remote-ref changes to all watched worktrees would satisfy freshness but violate the product requirement and create unnecessary metadata work.
Mitigation: cache only Git's resolved full upstream ref name on each `Repository` and compute the loose remote ref path from `common_git_dir()` when routing. For common config changes, deliver updates only when `refresh_tracked_remote_ref` changes the cached value. Tests must include two worktrees sharing the same common `.git` directory but tracking different upstream refs.
### Risk: cached tracked remote ref becomes stale
If the watcher misses a `HEAD` or config event, remote-ref routing could use an outdated cached upstream ref name.
Mitigation: initialize the cache in `Repository::new`, refresh it on all allowlisted tracking-state events, and refresh it during initial scan/start-watching if needed. Keep existing manual and metadata refresh paths as backstops. Prefer conservative false negatives over routing unrelated remote refs to every repository.
### Risk: running Git from watcher-triggered state refreshes
Resolving the upstream with Git is more correct than parsing config, but it introduces process execution when `HEAD` or config changes.
Mitigation: run Git only on repository construction/startup and tracking-state events, not on every remote-ref event. Use `Task::RefreshTrackedRemoteRef` in the existing repository task queue so watcher routing never blocks on Git. Cache `None` on Git failures and let existing manual/other refresh paths handle the repository.
### Risk: over-broad shared Git watching
Watching `common_git_dir/refs` or `common_git_dir` for linked worktrees is broader than the current `refs/heads` registration.
Mitigation: keep `should_ignore_git_path` as the allowlist boundary. Only local branch refs, remote-tracking refs, tracking-state files, and index/HEAD files should pass through; tags and other refs remain ignored. Add tests proving tag changes and unrelated Git internals under the broader watched directory do not produce repository updates.
### Risk: stale reads while Git is updating refs or config
A filesystem event can fire while Git is still writing a ref or replacing config.
Mitigation: route based on path and cached tracking state, refresh cached tracking state after config/HEAD events by asking Git for the current upstream, then reuse the existing debounced watcher and throttled metadata refresh. If metadata load races and observes stale state, later Git file events or manual refresh paths should correct it. Avoid adding bespoke retry loops unless tests prove they are necessary.
## Follow-ups
- Support `.git/packed-refs` if product later needs packed remote refs to trigger metadata refresh.
- Consider a shared utility for resolving the current branch's upstream ref if other Git UI features need the same information.
- Consider adding telemetry around metadata refresh causes if remote-ref invalidations become performance-sensitive.
+77
View File
@@ -0,0 +1,77 @@
# GH11107: Reduce first-time agent onboarding callouts
## Summary
Reduce the first-time Agent Modality onboarding tutorial from four callouts to two callouts, and remove the FTUE tab/session configuration modal from the handoff into the tutorial. The shorter flow should teach the same essential concepts with less interruption: terminal input can route commands or natural language, and agent conversations now live in their own scoped agent experience.
## Problem
The current Agent Modality first-time tutorial shows four sequential callouts before the user can finish onboarding. That amount of instructional UI is too heavy for a first-run experience, especially because some concepts can be combined without losing clarity.
## Goals
- Show at most two callouts for the Agent Driven Development onboarding tutorial.
- Preserve the natural language detection opt-in/override explanation.
- Preserve the transition from terminal input into the scoped agent experience.
- Preserve project initialization behavior for users who selected a project.
- Preserve the ability to finish without submitting anything for users who did not select a project.
- Start the post-slide tutorial directly without requiring first-time users to configure a tab, session type, worktree, or startup config.
- Reuse the existing callout visual style, button conventions, keyboard shortcuts, progress dots, and placement patterns.
## Non-goals
- Redesigning the callout component.
- Changing the broader onboarding slides before the callout tutorial starts.
- Changing the natural language detection setting outside the first-run tutorial.
- Changing how the agent experience itself works after the tutorial completes.
- Removing or changing the manual session configuration modal outside FTUE.
- Changing reusable tab config, worktree, or new-session menu behavior outside the FTUE handoff.
- Introducing a new visual treatment, animation, or Figma-driven layout for the callouts.
## Figma
Figma: none provided. This change uses the existing onboarding callout component and consolidates existing callout content.
## Behavior
1. When a first-time user enters the Agent Driven Development tutorial with Agent Modality enabled, Warp shows exactly two sequential callouts.
1. First-time Agent Driven Development users do not see the tab config/session config modal between completing onboarding slides and starting the callout tutorial.
1. First-time Agent Driven Development users are not required to choose a session type, project setup, worktree setup, or startup tab config before the callout tutorial starts.
1. If the user selected a project in the onboarding slides, Warp should continue using that selection for the tutorial/project initialization path without re-asking through the tab config modal.
1. Manual session configuration remains available from its existing non-FTUE entrypoints.
2. The two-callout sequence is:
- Callout 1: terminal input with natural language support.
- Callout 2: Warp's agent experience.
3. Callout 1 teaches that the terminal input can be used for terminal commands and can also support natural language requests for the agent.
4. Callout 1 includes the natural language detection explanation:
- Natural language detection is off by default when the user's setting is initially off.
- If enabled, Warp can autodetect plain-English agent requests typed into terminal input.
- The user can override auto-detection with the configured input-mode toggle keybinding.
5. If natural language detection was initially off, Callout 1 includes a checkbox labeled `Enable Natural Language Detection`.
6. The natural language detection checkbox reflects the current setting value while the callout is visible.
7. Toggling the natural language detection checkbox updates the setting immediately.
8. If natural language detection was already enabled before the tutorial started, Callout 1 does not need to show the enable checkbox. It should instead use shorter copy focused on the override keybinding.
9. Callout 1 has a primary `Next` action for Agent Driven Development users.
10. Callout 1 shows the first active progress dot in a two-dot sequence for Agent Driven Development users.
11. While Callout 1 is visible, the tutorial remains anchored to the terminal input / terminal context. The user should not be moved into the scoped agent experience before advancing past Callout 1.
12. Advancing from Callout 1 enters the scoped agent experience and shows Callout 2.
13. Callout 2 teaches that agent conversations are their own scoped view outside the terminal, and that the user can press `ESC` to return to the terminal.
14. Callout 2 shows the second active progress dot in a two-dot sequence.
15. For users who selected a project before the tutorial:
- Callout 2 offers an initialization action.
- The primary action is `Initialize`.
- A secondary action lets the user skip initialization.
- Choosing `Initialize` submits the initialization flow just as the current final onboarding callout does.
- Choosing skip finishes the tutorial without submitting initialization.
- Pressing `ESC` while this callout is focused still exits the scoped agent experience and returns to terminal context.
16. For users who did not select a project before the tutorial:
- Callout 2 offers a primary `Finish` action.
- Callout 2 offers a secondary `Back to terminal` action with the `ESC` keybinding.
- Choosing `Finish` ends the tutorial without submitting a prompt.
- Choosing `Back to terminal` exits the scoped agent experience, clears the tutorial prompt, and returns to terminal context.
17. The existing placeholder prompt behavior should remain coherent:
- Callout 1 should populate terminal-context sample input.
- Callout 2 should populate agent-context sample input, or `/init` for the project initialization path.
- Finishing, skipping, or returning to terminal clears tutorial-provided input.
18. Terminal-intention onboarding remains terminal-focused. It should not enter the scoped agent experience or show the agent-experience callout unless the user chose the Agent Driven Development path.
19. Keyboard shortcuts continue to work while each callout is focused:
- `Enter` advances or activates the primary action.
- `Backspace`/delete activates the skip action only when a skip action is visible.
- `Escape` returns to terminal while the final agent-experience callout is visible.
20. Existing telemetry concepts remain meaningful:
- A display event is recorded for each visible callout.
- A next event is recorded when the user advances.
- A completion event is recorded with the user's completion type.
- Removed callouts should not be reported as displayed.
21. The shortened flow should not regress non-Agent-Modality onboarding. Universal Input onboarding should continue to use its existing flow.
22. If onboarding is triggered while Warp is running in a mode that cannot show onboarding, no callouts are shown, matching existing behavior.
23. If a tutorial is already active, starting the tutorial again should continue to be ignored rather than creating duplicate callouts.
24. The callout UI uses the existing component styling, theme colors, progress dots, button styling, and layout behavior. This change is a content and flow reduction, not a visual redesign.
+188
View File
@@ -0,0 +1,188 @@
# GH11107: Tech Spec — Reduce first-time agent onboarding callouts
## Context
`specs/GH11107/PRODUCT.md` defines the target behavior: Agent Driven Development onboarding with Agent Modality should show two visible callouts instead of four.
The current flow is implemented as a small model/view state machine in the onboarding crate and consumed by `TerminalView`.
- `crates/onboarding/src/callout/model.rs:64` defines `AgentModalityCalloutState` with four visible states: `MeetTerminalInput`, `NaturalLanguageSupport`, `IntroducingAgentExperience`, and `UpdatedAgentInput`.
- `crates/onboarding/src/callout/model.rs:185` implements `next_agent_modality`, which advances through all four states for `OnboardingIntention::AgentDrivenDevelopment`.
- `crates/onboarding/src/callout/model.rs:302` maps callout states to tutorial prompts via `prompt_for_agent_modality`.
- `crates/onboarding/src/callout/model.rs:345` emits callout display telemetry names for each visible state.
- `crates/onboarding/src/callout/model.rs:476` starts Agent Modality onboarding at `MeetTerminalInput`.
- `crates/onboarding/src/callout/view.rs:99` renders `get_agent_modality_callout_options`, including the current `total_steps = 4` for Agent Driven Development.
- `crates/onboarding/src/callout/view.rs:118` renders `MeetTerminalInput`.
- `crates/onboarding/src/callout/view.rs:136` renders `NaturalLanguageSupport`.
- `crates/onboarding/src/callout/view.rs:178` renders `IntroducingAgentExperience`.
- `crates/onboarding/src/callout/view.rs:190` renders `UpdatedAgentInput`.
- `crates/onboarding/src/callout/view.rs:362` positions `UpdatedAgentInput` differently from earlier callouts by returning `false` from `should_position_above_zero_state`.
- `app/src/terminal/view.rs (13942-14117)` owns callout lifecycle side effects: submitting prompts, entering agent view on `EnterAgentModality`, applying natural language detection changes, clearing input, and exiting agent view.
- `app/src/workspace/view/onboarding.rs (178-195)` chooses `AgentOnboardingVersion::AgentModality` when `FeatureFlag::AgentView` is enabled.
- `app/src/terminal/view/init.rs (876-948)` registers debug keybindings that launch Agent Modality onboarding with project, without project, and with terminal intention.
- `app/src/root_view.rs:3258` starts a pending tutorial after onboarding/auth. In the `OpenWarpNewSettingsModes && TabConfigs` path, Agent Driven Development previously set a pending onboarding intention and opened the session config modal before the tutorial.
- `app/src/workspace/view.rs (2022-2106)` handles the session config modal completion/dismissal and queued onboarding tutorial when that modal was used as the FTUE handoff.
The existing design already has the right separation of responsibilities:
- The onboarding model decides which state comes next and emits semantic events.
- The onboarding view maps state to callout text/buttons.
- `TerminalView` applies terminal/agent side effects in response to view events.
The implementation should keep that boundary and only shorten the Agent Driven Development state sequence.
## Proposed changes
### 1. Collapse the four visible Agent Driven Development states into two rendered steps
Keep two product concepts:
- Terminal input with natural language support.
- Warp's agent experience.
There are two reasonable implementation approaches:
1. Remove the unused enum variants entirely.
2. Keep the enum variants but skip the obsolete states.
Prefer removing the obsolete variants if the resulting diff stays small. The skipped states are no longer product-visible, and exhaustive matches are easier to reason about when the enum only represents states that can occur.
The resulting Agent Modality visible states should be:
- `NaturalLanguageSupport`
- `IntroducingAgentExperience`
`MeetTerminalInput` and `UpdatedAgentInput` should be removed or made unreachable.
### 2. Start Agent Modality onboarding at the combined terminal/NLD callout
Update `OnboardingCalloutModel::start_onboarding` so Agent Modality starts at `NaturalLanguageSupport` instead of `MeetTerminalInput`.
This preserves the current terminal-first flow while eliminating the separate "meet terminal input" callout. The `NaturalLanguageSupport` view copy will absorb the terminal-input concept.
### 3. Update `next_agent_modality`
For `OnboardingIntention::AgentDrivenDevelopment`:
- `Off` should advance to `NaturalLanguageSupport`.
- `NaturalLanguageSupport` should advance to `IntroducingAgentExperience`.
- Advancing from `NaturalLanguageSupport` should still emit `EnterAgentModality`, because this is the moment the tutorial moves from terminal context into scoped agent context.
- `IntroducingAgentExperience` should complete the flow:
- `FinalState::Initialize` when `has_project` is true.
- `FinalState::Finish` when `has_project` is false.
For `OnboardingIntention::Terminal`:
- The flow should remain terminal-only.
- `NaturalLanguageSupport` should complete with `FinalState::Finish`.
- It should not emit `EnterAgentModality`.
### 4. Move final actions from `UpdatedAgentInput` to `IntroducingAgentExperience`
Update `get_agent_modality_callout_options`:
- Agent Driven Development `total_steps` becomes `2`.
- `NaturalLanguageSupport` uses `StepStatus::new(0, 2)` for Agent Driven Development.
- `IntroducingAgentExperience` uses `StepStatus::new(1, 2)` for Agent Driven Development.
`NaturalLanguageSupport` should combine terminal-input and NLD content. It should continue to branch on `initial_natural_language_detection_enabled`:
- If NLD was initially enabled, use shorter override-focused copy and no checkbox.
- If NLD was initially disabled, show the full NLD explanation and checkbox.
`IntroducingAgentExperience` should become the final action surface:
- With project:
- title should remain agent-experience oriented or otherwise clearly communicate the scoped agent view.
- primary button: `Initialize`.
- secondary button: `Skip initialization`.
- Without project:
- primary button: `Finish`.
- secondary button: `Back to terminal` with `escape`.
Terminal intention can continue to use the natural language support callout as its final step, with a one- or two-step display depending on the final product copy. The important invariant is that Terminal intention does not show the agent-experience callout.
### 5. Update skip, finish, and back-to-terminal handling
The current model handles these actions on `UpdatedAgentInput`.
Move that behavior to `IntroducingAgentExperience`:
- `skip()` should complete with `FinalState::Skip` when the state is `IntroducingAgentExperience` and `has_project` is true.
- `finish()` should complete with `FinalState::Finish` when the state is `IntroducingAgentExperience` and `has_project` is false.
- `back_to_terminal()` should complete with `FinalState::BackToTerminal` when the state is `IntroducingAgentExperience`, regardless of whether the user selected a project. This keeps the `ESC` behavior aligned with the final callout copy.
Keep logging for invalid actions, but update messages and match arms so valid new-state actions do not log errors.
### 6. Update prompt mapping
Update `prompt_for_agent_modality`:
- `NaturalLanguageSupport` should return a terminal-context sample appropriate for the combined first callout, likely the current `MeetTerminalInput` placeholder (`Run a command...`) or a refined terminal/NLD example.
- `IntroducingAgentExperience` should return:
- `/init` when `has_project` is true.
- the current agent-context placeholder when `has_project` is false.
- Completion states should continue to return `OnboardingQuery::None`.
The downstream input application in `TerminalView::apply_onboarding_callout_query_to_input` can remain unchanged because it already locks agent mode for `AgentPrompt` and leaves terminal commands in terminal context.
### 7. Update callout positioning
Today `should_position_above_zero_state` returns `false` only for `UpdatedAgentInput`.
After the final callout moves to `IntroducingAgentExperience`, update this method so the final agent-experience callout uses the intended agent-input positioning.
The expected behavior is:
- first callout: terminal/zero-state positioning.
- second callout: agent-input positioning after `EnterAgentModality`.
### 8. Update telemetry names
Update `send_callout_displayed_telemetry` so it only emits displayed events for callouts that can actually be shown.
Recommended names:
- Keep `natural_language_support` for the combined first callout to preserve continuity with existing telemetry.
- Keep `introducing_agent_experience` for the second callout.
Remove or stop emitting:
- `meet_terminal_input`
- `updated_agent_input`
Completion telemetry in `set_state` can remain unchanged because the existing `FinalState` values still describe user outcomes.
### 9. Keep `TerminalView` side effects mostly unchanged
`app/src/terminal/view.rs` should not need major changes.
The important existing behaviors should continue to be driven by model events:
- `EnterAgentModality` enters agent view without submitting a prompt.
- `NaturalLanguageDetectionToggled` persists the setting immediately.
- `FinalState::Initialize` submits `/init`.
- `FinalState::Skip | FinalState::Finish` clears input and completes onboarding.
- `FinalState::BackToTerminal` exits agent view, clears input, and completes onboarding.
If moving final actions causes `FinalState::Initialize` prompt lookup to differ, prefer keeping the existing hard-coded `/init` submission in the `Initialize` handler rather than relying on prompt state.
### 10. Update debug/demo surfaces and comments
Update comments in:
- `crates/onboarding/src/callout/model.rs`
- `crates/onboarding/src/callout/view.rs`
so they no longer describe a four-step Agent Modality flow.
The debug keybindings in `app/src/terminal/view/init.rs` can stay, but their launched flows should now show only two callouts for Agent Driven Development.
Update `crates/onboarding/examples/callout_flow.rs` only if its demo text or assumptions mention the old four-step sequence.
### 11. Bypass the FTUE session config modal before the callout tutorial
Update `RootView::start_pending_tutorial` so the `OpenWarpNewSettingsModes && TabConfigs` Agent Driven Development branch no longer calls `set_pending_onboarding_intention` or `show_session_config_modal`.
Instead:
- Keep `open_vertical_tabs_panel_if_enabled` so the user's onboarding UI customization is still reflected.
- Call `Workspace::start_agent_onboarding_tutorial(tutorial, ctx)` directly for Agent Driven Development.
- Keep the Terminal-intention branch unchanged: it may open vertical tabs when enabled, but it should not open the agent tutorial or the session config modal.
Do not remove `Workspace::show_session_config_modal` or the pending session-config tutorial/chip code. Those paths are still used by manual session configuration and should remain intact outside the FTUE handoff.
## State transition diagram
```mermaid
flowchart TD
Off --> NaturalLanguageSupport
NaturalLanguageSupport -- Terminal intention / Next --> CompleteFinish[Complete: Finish]
NaturalLanguageSupport -- Agent Driven Development / Next --> EnterAgent[Emit EnterAgentModality]
EnterAgent --> IntroducingAgentExperience
IntroducingAgentExperience -- Has project / Initialize --> CompleteInitialize[Complete: Initialize]
IntroducingAgentExperience -- Has project / Skip initialization --> CompleteSkip[Complete: Skip]
IntroducingAgentExperience -- No project / Finish --> CompleteFinish2[Complete: Finish]
IntroducingAgentExperience -- ESC / Back to terminal --> CompleteBack[Complete: BackToTerminal]
```
## Testing and validation
### Unit tests
There do not appear to be existing unit tests for `crates/onboarding/src/callout/model.rs`. If adding tests stays lightweight, add model tests covering:
- Agent Driven Development starts at `NaturalLanguageSupport`.
- Agent Driven Development advances from `NaturalLanguageSupport` to `IntroducingAgentExperience` and emits `EnterAgentModality`.
- Agent Driven Development with project completes with `Initialize`.
- Agent Driven Development without project completes with `Finish`.
- Skip and back-to-terminal complete with the correct final states from `IntroducingAgentExperience`.
- Terminal intention completes from `NaturalLanguageSupport` without emitting `EnterAgentModality`.
If adding tests requires too much test harness setup, rely on debug flow manual validation for this small state-machine change.
### Manual validation
Use the existing debug actions registered in `app/src/terminal/view/init.rs`:
- `[Debug] Onboarding Callout: Modality - Project`
- `[Debug] Onboarding Callout: Modality - No Project`
- `[Debug] Onboarding Callout: Modality - Terminal`
Validate:
- Project Agent Driven Development flow shows exactly two dots and two callouts.
- No-project Agent Driven Development flow shows exactly two dots and two callouts.
- First callout appears in terminal context and does not enter agent view.
- Clicking `Next` on the first callout enters agent view and shows the second callout.
- Project flow primary action initializes with `/init`.
- Project flow skip action completes without initialization.
- Project flow `ESC` exits agent view and returns to terminal.
- No-project flow `Finish` completes without submitting.
- No-project flow `Back to terminal` exits agent view and clears input.
- Natural language detection checkbox appears only when initially disabled and updates the setting immediately.
- Terminal-intention debug flow does not enter agent view.
- Universal Input onboarding still follows its existing flow.
- Completing FTUE with Agent Driven Development selected does not open the session config modal.
- The tutorial starts directly after the workspace/auth handoff, preserving the selected project/no-project tutorial variant.
- Manual `WorkspaceAction::ShowSessionConfigModal` entrypoints still open the session config modal.
### Commands
Run:
- `cargo fmt`
- A targeted check for onboarding/app compilation, such as `cargo check -p onboarding` if supported by workspace dependencies.
- Because `app/src/root_view.rs` is touched, run the smallest relevant app check available locally in addition to `cargo check -p onboarding`.
Before opening or updating a PR, follow repo policy and run the required `cargo fmt` and `cargo clippy` checks from presubmit guidance.
## Risks and mitigations
### Entering agent view at the wrong time
Risk: If `EnterAgentModality` is emitted too early, the first callout will appear in agent context instead of terminal context.
Mitigation: Keep `EnterAgentModality` on the transition from `NaturalLanguageSupport` to `IntroducingAgentExperience`, not on start.
### Invalid action handling after moving final buttons
Risk: Buttons moved from `UpdatedAgentInput` to `IntroducingAgentExperience` could dispatch actions that the model still considers invalid.
Mitigation: Update `skip`, `finish`, and `back_to_terminal` match arms in the same diff as the view button move.
### Prompt mode mismatch
Risk: The first callout could force agent input mode if it uses `OnboardingQuery::AgentPrompt`.
Mitigation: Return `OnboardingQuery::TerminalCommand` for the first callout if the intended context is terminal input, and keep agent prompts for the second callout.
### Telemetry discontinuity
Risk: Removing two callouts changes telemetry volume and may surprise dashboards that expect the old names.
Mitigation: Preserve the two retained callout names and intentionally stop emitting display events for removed callouts. Call out the expected telemetry change in the PR description.
## Parallelization
Parallel sub-agents are not recommended for this implementation. The change is small and tightly coupled across one state machine, one view mapping, and one parent event consumer. Splitting it would create more coordination overhead than wall-clock savings.
If this expands into visual redesign or new tests, a second local agent could independently add validation coverage in a separate worktree such as `../warp-gh11107-tests` on branch `agent/gh11107-tests`, while the main implementation remains on the feature branch. For the scoped two-callout change, a single branch and single PR is the simplest strategy.
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+36
View File
@@ -0,0 +1,36 @@
# Product Spec: Support 'agy' (Antigravity) CLI Agent in Warp
**Issue:** [warpdotdev/warp#11368](https://github.com/warpdotdev/warp/issues/11368)
**Figma:** none provided
## Summary
Add native support for the `agy` (Antigravity) CLI agent in Warp. This enables the terminal to automatically identify `agy` command executions and transition the active pane into "Agent Mode" (with dedicated layouts, toolbars, and branding).
## Problem
The Antigravity CLI agent (`agy`) is an autonomous developer tool. While users can run it in Warp today, Warp currently lacks native support for it as a recognized agent:
1. Running `agy` does not trigger the terminal's Agent Mode layout or toolbar.
2. The UI does not brand the session with the Antigravity logo or colors.
Note: Richer notifications (OSC 777) and plugin installation are deferred until an official Antigravity plugin for Warp exists.
## Goals
- Native command detection: typing or running `agy` triggers Agent Mode immediately.
- Custom branding: render the dedicated Antigravity toolbar with the same monochrome treatment as Pi: a white brand tile and a black custom logo.
## Non-Goals
- Providing inline terminal chips for plugin installation or updates (deferred).
- Processing structured OSC 777 notifications (deferred).
- Reading local `.antigravitycli/skills` directories (deferred).
## Success Criteria
1. Running `agy` in Warp launches Agent Mode and styles the pane with Antigravity branding.
2. The command is properly classified as a one-off shell command.
## Validation
- **Manual verification**: Verify the end-to-end command execution inside a live terminal window.
+79
View File
@@ -0,0 +1,79 @@
# Technical Spec: Support 'agy' (Antigravity) CLI Agent in Warp
See `specs/GH11368/product.md` for the product spec.
**Issue:** [warpdotdev/warp#11368](https://github.com/warpdotdev/warp/issues/11368)
## 1. Problem
Warp lacks native command detection and branding support for the `agy` (Antigravity) CLI agent. Running the agent does not trigger Agent Mode or the associated toolbar.
To resolve this, we must wire `CLIAgent::Antigravity` into the terminal's agent detection and branding config. Support for plugins and OSC 777 notifications is intentionally excluded in this milestone.
## 2. Relevant Code
- `app/src/terminal/cli_agent.rs``CLIAgent` enum with all identity methods: `command_prefix()`, `to_serialized_name()`, `from_serialized_name()`, `from_harness()`, `display_name()`, `icon()`, `supported_skill_providers()`, `skill_command_prefix()`, `supports_bash_mode()`, `brand_color()`, `brand_icon_color()`, `detect()`, and the `From<CLIAgent> for CLIAgentType` telemetry conversion.
- `crates/input_classifier/src/util.rs``ONE_OFF_SHELL_COMMAND_KEYWORDS` that determine shell-vs-natural-language classification.
- `crates/warp_core/src/ui/icons.rs``Icon` enum register and SVG asset mappings.
- `app/src/server/telemetry/events.rs``CLIAgentType` telemetry enum.
## 3. Proposed Changes
### 3a. Add Identity and Branding (`app/src/terminal/cli_agent.rs`)
1. Add `Antigravity` to the `CLIAgent` enum (before `Unknown`):
```rust
pub enum CLIAgent {
...
Goose,
Hermes,
Vibe,
Antigravity,
Unknown,
}
```
2. The new `Antigravity` variant must appear in every match arm across the `CLIAgent` impl.
- `command_prefix()`: returns `"agy"`.
- `to_serialized_name()`: Uses derive macro `"Antigravity"`.
- `from_serialized_name()`: Uses derive macro `"Antigravity"`.
- `from_harness()`: No change needed.
- `display_name()`: returns `"Antigravity"`.
- `icon()`: returns `Some(Icon::AntigravityLogo)`.
- `supported_skill_providers()`: returns `&[]` (no skills integration yet).
- `skill_command_prefix()`: Falls through to wildcard `_ => "/"`.
- `supports_bash_mode()`: Falls through to `false`.
- `brand_color()`: returns `Some(ANTIGRAVITY_COLOR)`, matching Pi's white monochrome brand tile color. Add an `ANTIGRAVITY_COLOR` constant at module level.
- `brand_icon_color()`: returns `ColorU::new(0, 0, 0, 255)`, matching Pi's black logo color on light brand tiles.
- `detect()`: Works automatically via `enum_iterator::Sequence` and prefix `"agy"`.
3. Add `CLIAgent::Antigravity => CLIAgentType::Antigravity` to the telemetry conversion. Add an `Antigravity` variant to `CLIAgentType` in `app/src/server/telemetry/events.rs`.
### 3b. Register Command Classifier (`crates/input_classifier/src/util.rs`)
Add `"agy"` to the static `ONE_OFF_SHELL_COMMAND_KEYWORDS` hashset:
```rust
static ref ONE_OFF_SHELL_COMMAND_KEYWORDS: HashSet<&'static str> = HashSet::from([
"#", "echo", "man", "sudo", "claude", "codex", "gemini", "agy"
]);
```
### 3c. Add SVG Asset and Icon Registry (`crates/warp_core/src/ui/icons.rs`)
1. Add `AntigravityLogo` to the `Icon` enum.
2. Map it to the SVG path in the match expression inside `Icon::svg_path()`:
```rust
Icon::AntigravityLogo => "bundled/svg/antigravity_cli.svg",
```
3. Add the SVG asset file `app/assets/bundled/svg/antigravity_cli.svg`.
## 4. End-to-End Flow
1. User types `agy` in their terminal and hits Enter.
2. The classifier marks it as a command execution.
3. The shell starts the `agy` process.
4. Warp identifies the `agy` command via `CLIAgent::detect()` and initiates the Agent Mode layouts and toolbar, with custom branding and icons.
## 5. Testing and Validation
- **Unit tests**: Verify CLI agent detection works for prefix `agy` in `cli_agent_tests.rs`.
- **Unit tests**: Verify `"agy"` short-circuits as a one-off shell keyword in `input_classifier` tests, ensuring it bypasses natural language classification.
- **Manual Verification**: Run Warp locally, trigger `agy`, and verify Agent Mode transition and UI styling.
+71
View File
@@ -0,0 +1,71 @@
# Hide Warp Dock Icon — Product Spec
## Summary
Add a macOS-only setting that lets users hide Warp from the Dock and Cmd-Tab app switcher while Warp continues running. This is intended for users who primarily launch or focus Warp via a global hotkey and do not want Warp to occupy Dock or app-switcher space.
## Motivation
Users who primarily use Warp through the dedicated hotkey window or another global hotkey do not need a persistent Dock icon. The icon occupies Dock and Cmd-Tab space, and clicking it can open a normal Warp window separate from the user's hotkey workflow. Users currently resort to unsupported bundle edits that are reverted by updates and can leave broken Dock state.
## Goals
- Provide a macOS setting to show or hide Warp's Dock icon.
- Hide Warp from both the Dock and Cmd-Tab switcher when the setting is off.
- Keep the setting independent of the global hotkey mode; users can hide the Dock icon whether global hotkey is disabled, dedicated hotkey window is enabled, or show/hide-all-windows hotkey is enabled.
- Preserve existing app icon customization when the Dock icon is visible.
## Non-goals
- Changing Warp's default behavior. Existing users should continue to see Warp in the Dock unless they opt out.
- Adding a menu bar/status bar icon as part of this PR.
- Changing the icon art options added for the Dock icon; hiding the Dock icon is a separate presentation setting, not another icon style.
- Implementing equivalent Dock/taskbar hiding behavior on Windows, Linux, or web.
## User experience
### Settings
1. On macOS, settings include a user-facing control for Dock visibility near the existing app icon customization controls.
2. The default is to show Warp in the Dock.
3. Turning the setting off immediately removes Warp from the Dock and Cmd-Tab switcher.
4. Turning the setting back on immediately restores Warp to the Dock and Cmd-Tab switcher.
5. The setting is hidden or unsupported on non-macOS platforms.
### Hidden Dock icon state
1. When the Dock icon is hidden, Warp remains running and existing terminal sessions continue unaffected.
2. Warp does not appear in the Dock.
3. Warp does not appear in Cmd-Tab.
4. Users can still access Warp through configured global hotkeys, existing visible windows, Mission Control, or other macOS window-management surfaces.
### Global hotkey interaction
1. The setting is independent of dedicated hotkey window mode.
2. If a user has dedicated hotkey window mode enabled, hiding the Dock icon does not change hotkey behavior.
3. If a user uses show/hide-all-windows global hotkey mode, hiding the Dock icon does not change that behavior.
4. Hiding the Dock icon does not enable a global hotkey, change an existing global hotkey, or require one.
### Persistence and launch
1. The hidden Dock icon preference persists across restart.
2. On launch, Warp should apply the saved Dock visibility preference as early as practical so the Dock icon does not visibly linger longer than necessary.
3. If applying the hidden Dock state fails, Warp should leave the app in the safe visible-Dock state.
## Acceptance criteria
1. A macOS user can disable the Dock icon from settings and immediately no longer sees Warp in the Dock.
2. With the Dock icon disabled, Warp is absent from Cmd-Tab.
3. Re-enabling the Dock icon restores Dock and Cmd-Tab presence.
4. The setting persists across restart.
5. Existing app icon customization continues to affect the Dock icon when the Dock icon is visible.
6. Non-macOS users do not see an enabled no-op Dock visibility setting.
## Manual test plan
- On macOS, manually toggle the setting off and verify Warp disappears from the Dock and Cmd-Tab while remaining running.
- Verify a configured global hotkey can still show/focus Warp while the Dock icon is hidden.
- Toggle the setting back on and verify the Dock icon and Cmd-Tab entry return.
- Restart Warp with the setting off and verify the hidden Dock state is restored.
- Verify the setting is not shown as enabled on non-macOS platforms.
- Verify existing app icon customization still works when Dock visibility is on.
+126
View File
@@ -0,0 +1,126 @@
# Hide Warp Dock Icon — Tech Spec
## Summary
Add a macOS-only `show_dock_icon` appearance setting that switches Warp between regular and accessory AppKit activation policies. When disabled, Warp is hidden from the Dock and Cmd-Tab while continuing to run.
## Relevant existing code
- `app/src/settings/app_icon.rs` — app icon settings namespace and generated settings state.
- `app/src/settings_view/appearance_page.rs` — Appearance settings UI.
- `app/src/appearance.rs` — runtime appearance/app icon setting handling.
- `app/src/lib.rs` — macOS `AppBuilder` setup before `app_builder.run`.
- `crates/warpui_core/src/platform/mod.rs` and `crates/warpui_core/src/core/app.rs` — platform delegate API.
- `crates/warpui/src/platform/mac/app.rs` — macOS app builder/run wiring.
- `crates/warpui/src/platform/mac/delegate.rs` — macOS platform delegate implementation.
- `crates/warpui/src/platform/mac/objc/app.{h,m}` — AppKit delegate and activation-policy calls.
## Design
### 1. Add a macOS Dock visibility setting
Add a generated setting under `AppIconSettings`:
- Name: `show_dock_icon`
- Type: `bool`
- Default: `true`
- Platform support: `SupportedPlatforms::MAC`
- Sync: disabled, matching app icon settings behavior
- Storage key: `ShowDockIcon`
- TOML path: `appearance.icon.show_dock_icon`
- Description: whether Warp is shown in the macOS Dock and Cmd-Tab switcher.
Keep this as a separate field from `app_icon`. Do not add a hidden variant to `AppIcon`, because `AppIcon` still describes artwork when the Dock icon is visible.
### 2. Apply the saved preference during launch
In `app/src/lib.rs`, after public preferences are available and before `app_builder.run`, read the saved `ShowDockIcon` value from `prefs_for_public_settings` using the generated setting helper, following the same pre-app-read pattern used by `ForceX11`.
Extend `warpui::platform::mac::AppExt` with `set_show_dock_icon_on_launch`, store the value in the macOS backend, and apply it in `warp_app_will_finish_launching`.
Initializing it before launch lets the AppKit layer apply accessory mode as early as practical, reducing visible Dock flicker for users who have already hidden the Dock icon.
### 3. Apply runtime setting changes
Handle the generated changed event alongside `AppIconState` in `AppearanceManager`. On `ShowDockIcon` changes, call the platform delegate to update Dock visibility immediately.
Add a platform delegate method such as `set_dock_icon_visible(visible: bool)`. Non-macOS implementations should be no-ops. The macOS implementation should dispatch to the main queue and call the Objective-C AppKit bridge.
### 4. macOS AppKit bridge
Add `-[WarpDelegate setDockIconVisible:]` in Objective-C. It should call:
- `NSApplicationActivationPolicyRegular` when `visible == YES`
- `NSApplicationActivationPolicyAccessory` when `visible == NO`
Return whether AppKit accepted the activation-policy change. If hiding fails, leave or restore the regular policy so Warp remains visible in the Dock.
### 5. Settings UI
Add a switch labelled "Show Warp in Dock" near the existing app icon controls in Appearance settings.
- Default checked state reflects `AppIconSettings::show_dock_icon`.
- Toggle dispatch updates `AppIconSettings.show_dock_icon`.
- Gate display/support via `is_supported_on_current_platform` from the setting metadata rather than compile-time `cfg` checks.
- Include search terms such as "dock", "cmd tab", and "app switcher".
## Behavior flows
### User hides the Dock icon
1. User opens Appearance settings and turns off Show Warp in Dock.
2. `AppIconSettings.show_dock_icon` is saved.
3. `AppearanceManager` receives the changed event.
4. The platform delegate applies accessory activation policy.
5. Warp disappears from the Dock and Cmd-Tab.
### User restores the Dock icon
1. User turns on Show Warp in Dock.
2. `AppIconSettings.show_dock_icon` is saved.
3. `AppearanceManager` receives the changed event.
4. The platform delegate applies regular activation policy.
5. Warp returns to the Dock and Cmd-Tab.
### Launch with hidden Dock icon
1. `app/src/lib.rs` reads `ShowDockIcon` before `app_builder.run`.
2. The macOS app builder stores `show_dock_icon_on_launch`.
3. `warp_app_will_finish_launching` applies the initial activation policy.
## Risks and mitigations
### Risk: users hide the Dock icon without a hotkey
Users can still reach existing visible windows, Mission Control, and other macOS window-management surfaces, but the main intended workflow is hotkey-driven.
Mitigation: keep the setting opt-in, default it to visible, and make the label explicit.
### Risk: AppKit rejects activation-policy changes
Mitigation: check the Objective-C return value. If hiding fails, leave or restore regular activation policy so Warp remains visible in the Dock.
### Risk: non-macOS no-op setting
Mitigation: mark the setting as macOS-only and gate the Appearance row with `is_supported_on_current_platform`.
## Test plan
Automated:
- Add settings/schema coverage ensuring `appearance.icon.show_dock_icon` exists, defaults to `true`, is macOS-only, and does not sync to cloud where practical.
- Add compile coverage for `warpui` macOS code paths in the existing macOS CI job.
Manual macOS:
- Toggle Show Warp in Dock off and verify Warp disappears from the Dock and Cmd-Tab.
- Verify configured global hotkeys still focus/show Warp while Dock visibility is disabled.
- Restart Warp with Show Warp in Dock disabled and verify the app starts in hidden-Dock mode.
- Toggle Show Warp in Dock back on and verify the Dock icon and Cmd-Tab entry return.
- Verify changing the selected app icon still updates Dock art when Show Warp in Dock is enabled.
- Verify non-macOS builds do not show an enabled no-op setting.
## Future considerations
- Consider a separate menu bar/status item recovery surface if user feedback indicates it is needed.
- Consider launch-at-login/background-start behavior as a separate feature for users who want Warp available only through hotkey after boot.
+28
View File
@@ -0,0 +1,28 @@
# GH12243: Prevent RowIterator crash after clear resize truncates wide characters
Issue: https://github.com/warpdotdev/warp/issues/12243
## Summary
Warp Preview must not crash when terminal output containing wide characters is resized during a full-grid clear flow. Terminal rows produced by a clear-driven no-reflow resize must remain valid when later rendered, scrolled, or restored from scrollback-like storage.
## Problem
The reported crash happens after a command-enter / clear-hook / resize sequence. The terminal grid can be shrunk at a column boundary that splits a wide character from its spacer, leaving a retained row that later cannot be safely restored for rendering or scrolling.
This is a crash fix, not a user-facing feature. The user-visible requirement is that Warp continues to render, resize, and scroll terminal output without aborting, even when CJK or other double-width characters sit exactly at a resize boundary.
## Goals / Non-goals
Goals:
- Preserve terminal stability during clear-driven resize flows that truncate wide-character pairs.
- Preserve the invariant that rows produced by the clear-driven no-reflow resize path do not contain an orphaned trailing wide-character marker.
- Keep existing reflow behavior for ordinary terminal resize, line wrapping, scrollback, and wide-character continuation rows.
Non-goals:
- Reworking all terminal wide-character handling.
- Claiming to fix every public `RowIterator::next` crash report unless the same producer path is proven.
- Changing the visual semantics of valid wrapped wide characters.
## Behavior
1. When Warp receives a full-grid clear sequence and the terminal is resized before the command is finished, the active grid resizes without reflowing old terminal output into scrollback.
2. If that no-reflow resize shrinks the terminal width and the retained final cell is a wide character, the resulting row remains valid for later rendering, scrolling, and scrollback-like storage. Warp must not leave a final-column wide character that requires a spacer outside the row bounds.
3. For that invalid trailing wide character, Warp resets the retained final cell's foreground/content state instead of preserving the leading glyph as a single-cell character. The reset preserves the cell background, matching existing clear/overwrite behavior for wide-character boundary repairs.
4. Valid wide-character pairs wholly inside the new width remain unchanged. A wide character whose spacer is still retained continues to occupy two cells and must still materialize with its matching spacer.
5. Wide-character spacers wholly outside the new width are discarded with the rest of the overflow content. If discarding overflow leaves the retained row ending in `WIDE_CHAR`, that retained leading cell is reset; otherwise discarding overflow content must not mutate unrelated retained cells.
6. Rows produced by this clear-driven no-reflow resize path must not contain:
- a `WIDE_CHAR` marker without a following `WIDE_CHAR_SPACER` in the same row, or
- a `WIDE_CHAR_SPACER` marker without a preceding `WIDE_CHAR` in the same row.
7. Ordinary reflowing resize behavior is unchanged. When resize is allowed to reflow content, valid wide characters that cross a wrap boundary continue to use the existing leading-spacer semantics for wrapped rows rather than being cleared, flattened, or silently narrowed.
8. Screen-mode routing is unchanged. Unfinished primary grids with full-grid clear behavior continue to use the no-reflow clear path; finished primary grids continue to use normal scrollback behavior; alt-screen grids continue to resize without flat-storage scrollback.
9. The existing clear-resize scrollback-width regression remains fixed. Resizing under full-grid clear behavior continues to keep active grid width and flat-storage width in sync.
+136
View File
@@ -0,0 +1,136 @@
# GH12243: Tech Spec — Prevent RowIterator crash after clear resize truncates wide characters
Product spec: `specs/GH12243/product.md`
Issue: https://github.com/warpdotdev/warp/issues/12243
Code references inspected at commit: `55b411ec694a5c16a01929bcaef1d8f971677ca2`
## Context
The issue is a producer-side row-invariant break that later appears as a consumer-side panic. The observed crash is in flat-storage row materialization, but the malformed row is produced earlier when the active terminal grid is resized without reflow under full-grid clear behavior.
Relevant current code:
- [`CONTRIBUTING.md:90-107 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/CONTRIBUTING.md#L90-L107) — spec PR requirements for `specs/GH<issue-number>/product.md` and `tech.md`.
- [`app/src/terminal/view.rs:12935-12955 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/view.rs#L12935-L12955) — terminal view handles CLI-agent OSC notifications that start the clear-style redraw behavior.
- [`app/src/terminal/model/block.rs:1100-1102 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/block.rs#L1100-L1102) — block-level entry point enables full-grid clear behavior on the output grid.
- [`app/src/terminal/model/grid/grid_handler.rs:330-340 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler.rs#L330-L340) — `FullGridClearBehavior` distinguishes in-place redraw behavior from normal scrollback preservation.
- [`app/src/terminal/model/grid/grid_handler.rs:490-492 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler.rs#L490-L492) — `enable_full_grid_clear_behavior` switches a grid handler to the clear path.
- [`app/src/terminal/model/grid/resize.rs:57-82 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/resize.rs#L57-L82) — `GridHandler::resize_storage` takes the alt-screen / `FullGridClearBehavior::Clear` early path and delegates to `self.grid.resize(false, ...)`, then syncs flat-storage columns.
- [`app/src/terminal/model/grid/resize.rs:98-158 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/resize.rs#L98-L158) — the normal resize path pushes rows into flat storage, changes flat-storage width, then materializes rows back with `pop_rows`.
- [`app/src/terminal/model/grid/ansi_handler.rs:1500-1534 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/ansi_handler.rs#L1500-L1534) — existing wide-character boundary helpers reset both halves when an overwrite or clear boundary splits a wide-character pair.
- [`app/src/terminal/model/grid/ansi_handler.rs:1536-1582 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/ansi_handler.rs#L1536-L1582) — `write_at_cursor` resets the paired cell before writing when the cursor lands on either half of a wide-character pair.
- [`app/src/terminal/model/grid/grid_storage/resize.rs:308-363 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_storage/resize.rs#L308-L363) — `GridStorage::shrink_cols` calls `row.shrink(columns)` and, when `reflow` is false, pushes the shortened row without processing wrapped cells.
- [`app/src/terminal/model/grid/grid_storage/resize.rs:365-390 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_storage/resize.rs#L365-L390) — the `reflow=true` branch already has special wide-character handling: a trailing `WIDE_CHAR` is replaced with `LEADING_WIDE_CHAR_SPACER` and moved into wrapped content.
- [`crates/warp_terminal/src/model/grid/row.rs:66-92 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/crates/warp_terminal/src/model/grid/row.rs#L66-L92) — `Row::shrink` splits off cells beyond the new column count and returns non-empty discarded cells. It is a low-level primitive and does not know whether the caller will discard overflow or reflow it.
- [`crates/warp_terminal/src/model/grid/flat_storage/mod.rs:185-217 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/crates/warp_terminal/src/model/grid/flat_storage/mod.rs#L185-L217) — flat storage skips wide-character spacer cells when serializing rows and records leading-spacer metadata separately.
- [`crates/warp_terminal/src/model/grid/flat_storage/mod.rs:124-140 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/crates/warp_terminal/src/model/grid/flat_storage/mod.rs#L124-L140) — `FlatStorage::pop_rows` materializes stored rows through `rows_from`.
- [`crates/warp_terminal/src/model/grid/flat_storage/row_iterator.rs:86-133 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/crates/warp_terminal/src/model/grid/flat_storage/row_iterator.rs#L86-L133) — `RowIterator::next` fills row cells from grapheme runs and marks `row[idx + 1]` as `WIDE_CHAR_SPACER` for width-2 graphemes. If `idx` is the final row cell, this panics.
- [`app/src/terminal/model/grid/grid_handler_tests.rs:1511-1530 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler_tests.rs#L1511-L1530) — existing helper asserts there are no orphaned `WIDE_CHAR` or `WIDE_CHAR_SPACER` flags in a visible row.
- [`app/src/terminal/model/grid/grid_handler_tests.rs:1533-1563 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler_tests.rs#L1533-L1563) — existing tests assert that overwriting either half of a wide-character pair clears the paired cell.
- [`app/src/terminal/model/grid/grid_handler_tests.rs:1769-1774 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler_tests.rs#L1769-L1774) — existing test covers finished primary-grid behavior with full-grid clear enabled.
- [`app/src/terminal/model/grid/grid_handler_tests.rs:2001-2022 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler_tests.rs#L2001-L2022) — existing wide-character wrap test protects `LEADING_WIDE_CHAR_SPACER` semantics.
- [`app/src/terminal/model/grid/grid_handler_tests.rs:2208-2247 @ 55b411ec`](https://github.com/warpdotdev/warp/blob/55b411ec694a5c16a01929bcaef1d8f971677ca2/app/src/terminal/model/grid/grid_handler_tests.rs#L2208-L2247) — existing full-grid clear resize tests guard the earlier flat-storage column-sync regression.
The important ownership boundary is that `RowIterator::next` is the panic site, not the best primary fix site. The producer creates a row that violates the wide-character invariant described in `product.md`; flat storage then faithfully serializes and rematerializes that malformed row until the missing spacer becomes an out-of-bounds write.
## Proposed changes
### 1. Repair the confirmed producer path in `GridStorage::shrink_cols`
In `app/src/terminal/model/grid/grid_storage/resize.rs`, keep the fix localized to the branch where `GridStorage::shrink_cols` has just called `row.shrink(columns)`.
After `row.shrink(columns)` returns, handle the no-reflow split-wide-character case before the non-reflow branch pushes the shortened row into `new_raw`:
- If `!reflow`, `columns > 0`, and the retained final cell has `WIDE_CHAR`, reset that cell to an empty cell with the same background.
- Continue to pass the discarded cells returned by `row.shrink(columns)` into the existing `reflow=true` branch unchanged.
This satisfies `product.md` behavior 2-6 by repairing rows that would otherwise end with an orphaned `WIDE_CHAR`. Resetting the retained leading cell matches the existing overwrite/clear behavior for wide-character boundary operations and avoids pretending that a width-2 glyph has a valid one-cell representation.
This intentionally discards the boundary glyph. That is consistent with this no-reflow path: right-side overflow cells are already discarded, and once the spacer half is not present in the retained row the retained leading half is no longer independently renderable. The reset preserves the cell background so resize does not create a visual hole in applications that paint non-default backgrounds.
### 2. Do not move this fix into `Row::shrink`
`Row::shrink` only knows that cells were split off. It does not know whether the caller will discard those cells, reflow them into wrapped rows, or transform them with leading-spacer semantics. Its return value should preserve the original discarded cell flags so callers can decide how to handle a split wide-character pair. A generic `Row::shrink` cleanup that removes `WIDE_CHAR` whenever the first discarded cell is a spacer would also affect callers that still intend to preserve or reflow the wide character.
Keeping the fix in `GridStorage::shrink_cols` preserves the caller-specific distinction:
- `reflow=false`: overflow is discarded, so a retained final `WIDE_CHAR` must be reset rather than materialized as a one-cell glyph.
- `reflow=true`: overflow is wrapped, and the existing code moves a trailing `WIDE_CHAR` into wrapped content while placing a `LEADING_WIDE_CHAR_SPACER` in the retained row.
This directly protects `product.md` behavior 7.
### 3. Do not use `RowIterator::next` as the primary repair
`RowIterator::next` should not silently paper over this producer bug by dropping or narrowing width-2 graphemes whenever `idx + 1 == row.len()`. That would prevent this specific panic but would make corrupted flat-storage rows harder to diagnose and could hide unrelated producers.
If implementation review identifies a producer path that cannot be repaired before flat-storage materialization and requires extra consumer hardening, keep it secondary and explicit:
- It must log enough context to identify the producer path.
- It must have a dedicated test that proves the fallback does not corrupt valid rows.
- It must not replace the producer-side regression test.
Do not add consumer fallback for this issue by default.
### 4. Regression test in `grid_handler_tests.rs`
Add a focused test next to the existing full-grid clear resize tests:
`test_full_grid_clear_shrink_cols_does_not_orphan_wide_char_at_boundary`
The test should:
1. Create a `GridHandler` with a wider initial column count than the final resized width.
2. Enable `FullGridClearBehavior::Clear`.
3. Build a valid wide-character pair exactly at the shrink boundary, preferably through the normal grid input path so `WIDE_CHAR` and `WIDE_CHAR_SPACER` are produced by terminal writing rather than hand-set flags.
4. Resize through the real `grid.resize(SizeInfo::new_without_font_metrics(...))` API so the test exercises `GridHandler::resize_storage` and `GridStorage::shrink_cols`.
5. Use `assert_no_orphaned_wide_chars` to assert the row invariant.
6. Assert the exact boundary postcondition: the final retained cell is reset to an empty cell preserving the original background, and no retained cell contains an orphaned `WIDE_CHAR` or `WIDE_CHAR_SPACER`.
7. Push the post-resize retained row through a `FlatStorage` whose column count matches the resized grid width, then call `flat_storage.pop_rows(1)` and assert one row materializes without panic and keeps the boundary cell reset.
This is slightly stronger than only asserting "does not panic" because it proves the producer invariant before flat storage gets involved.
### 5. Add a resize-specific `reflow=true` guard
Add a focused `GridStorage` / `GridHandler` resize regression for the ordinary reflow path:
`test_shrink_cols_reflow_preserves_split_wide_char_as_wrapped_content`
The test should:
1. Build a valid wide-character pair at the shrink boundary in a normal reflowing resize path, without enabling `FullGridClearBehavior::Clear`.
2. Resize narrower through the real resize API so `GridStorage::shrink_cols(reflow=true, ...)` handles the split pair.
3. Assert the retained row uses the existing `LEADING_WIDE_CHAR_SPACER` representation rather than narrowing the retained cell.
4. Assert the wrapped content still contains the original `WIDE_CHAR` cell followed by its `WIDE_CHAR_SPACER`.
5. Assert the no-reflow boundary reset rule from change 1 does not run when `reflow=true`.
This protects `product.md` behavior 7 and proves the producer-side fix is scoped to discarded overflow, not ordinary wrapped wide-character content.
### 6. Preserve existing regression coverage
Do not remove or weaken the existing clear-resize tests. In particular:
- `test_full_grid_clear_resize_then_scroll_does_not_panic_on_row_iteration`
- `test_full_grid_clear_resize_narrower_then_scroll_does_not_panic`
- `test_full_grid_clear_resize_then_bounds_to_string_does_not_panic`
- `test_resize_finished_primary_with_full_grid_clear_behavior_uses_scrollback`
- `test_wide_char_wrap_preserves_own_leading_spacer`
Those tests protect the earlier flat-storage column-sync behavior, finished primary-grid routing, and normal wrapped wide-character semantics. They also prevent this issue from being conflated with #10305-style width mismatches.
## Testing and validation
Map tests to `product.md` behavior:
- Behavior 1, 8, 9: run the existing full-grid clear resize and routing tests:
```bash
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_full_grid_clear_resize_then_scroll_does_not_panic_on_row_iteration
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_full_grid_clear_resize_narrower_then_scroll_does_not_panic
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_full_grid_clear_resize_then_bounds_to_string_does_not_panic
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_resize_finished_primary_with_full_grid_clear_behavior_uses_scrollback
```
- Behavior 2-6: add and run:
```bash
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_full_grid_clear_shrink_cols_does_not_orphan_wide_char_at_boundary
```
This test should fail before the producer fix by detecting an orphaned `WIDE_CHAR` at the final retained column or by panicking during flat-storage materialization. It is the accepted deterministic proof for the original crash, which is difficult to reproduce manually because it depends on command timing, clear-hook handling, resize timing, and a wide-character boundary.
- Behavior 4, 7: add and run the resize-specific `reflow=true` regression, and keep running the existing wrap guard:
```bash
cargo nextest run --package warp terminal::model::grid::tests::test_shrink_cols_reflow_preserves_split_wide_char_as_wrapped_content
cargo nextest run --package warp terminal::model::grid::grid_handler::tests::test_wide_char_wrap_preserves_own_leading_spacer
```
- Behavior 4, 6, 7: run the existing wide-character editing and wrapping tests around `assert_no_orphaned_wide_chars`. At minimum, run the full grid-handler test module if time permits:
```bash
cargo nextest run --package warp terminal::model::grid::grid_handler::tests
```
If a narrower subset is needed, include the tests whose names mention wide char, spacer, wrap, erase, delete, insert, and clear.
- General formatting and linting:
```bash
./script/format --check
cargo clippy --workspace --all-targets --all-features --tests -- -D warnings
```
- PR-level validation before final review:
```bash
./script/presubmit
```
If unrelated local failures appear, record the failing test names and explain why they are unrelated to grid storage / row materialization.
Manual validation is secondary to the deterministic regression tests, but the implementation PR should still exercise the known clear-resize repro locally: emit an OSC 777 CLI-agent session start, print multiple rows whose full-width glyph targets adjacent shrink widths, open find on a repeated character, slowly shrink the pane or window, then finish the command if needed. The fixed build should not log a `row_iterator.rs:132` panic during resize, find rerun, or command-finished block serialization.
## Parallelization
Parallel sub-agents are not proposed for the implementation itself. The code change is narrow and the implementation/test files are tightly coupled:
- `app/src/terminal/model/grid/grid_storage/resize.rs`
- `app/src/terminal/model/grid/grid_handler_tests.rs`
- `app/src/terminal/model/grid/tests.rs`
Splitting these across agents would likely create more coordination overhead than saved time. A useful parallel review pattern is possible after the first implementation draft: one reviewer can inspect the producer/consumer tradeoff while another runs the focused regression tests, but the patch should be authored as one coherent change.
## Risks and mitigations
### Risk: accidentally changing reflow semantics
A too-low-level fix in `Row::shrink` could remove `WIDE_CHAR` before the `reflow=true` branch has a chance to preserve wrapped wide-character semantics.
Mitigation: keep the mutation in `GridStorage::shrink_cols` and gate it on `!reflow`.
### Risk: hiding malformed rows in `RowIterator`
A broad consumer guard in `RowIterator::next` could stop panics while making future producer bugs invisible.
Mitigation: repair the confirmed producer and rely on deterministic invariant tests. Add consumer hardening only if it is explicitly justified and separately tested.
### Risk: overclaiming coverage for related RowIterator issues
Public issues such as #11471 and #12459 share a `RowIterator::next` crash shape, but their public reports do not prove the same no-reflow clear-resize producer.
Mitigation: describe this spec as fixing the deterministic producer in #12243. Do not claim it fixes every RowIterator bounds-check crash unless further evidence ties those reports to the same producer.
### Risk: resetting the boundary glyph is user-visible
When no-reflow resize discards the spacer cell, the retained leading cell can no longer be represented as a valid two-cell wide character in that row. Resetting that leading cell means the boundary glyph is not displayed.
Mitigation: this only happens at the discard boundary in a path that already discards right-side overflow rather than restoring it after resize. Valid pairs inside the retained width are unchanged, and ordinary reflowing resize still preserves split wide characters through `LEADING_WIDE_CHAR_SPACER`.
## Follow-ups
- If more RowIterator crashes appear with different producer paths, consider a broader audit of all row producers that can create or mutate wide-character pairs.
- Consider adding debug-only invariant checks around row transitions into flat storage if future failures show more malformed-row producers.
+366
View File
@@ -0,0 +1,366 @@
# PRODUCT.md — Per-tab theme overrides driven by directory and launch configurations
Issue: https://github.com/warpdotdev/warp/issues/478
Related: https://github.com/warpdotdev/warp/issues/2618 (set warp theme in launch configuration)
## Summary
The Warp theme is a single global value today (`appearance.themes.theme` in
`settings.toml`); switching it affects every open tab at once. Users have asked
for years (`#478`, 55+ upvotes; `#2618`) for tabs to render with different
themes when they represent different contexts — different projects, local vs.
remote machines, production vs. development.
This spec covers a focused first cut of per-tab theme overrides driven by
**three** sources, in priority order:
1. A user-visible **manual** override on a tab (set via the launch
configuration YAML or via a right-click menu).
2. A **directory-pattern** auto-match: the user maps directory paths to
themes in `settings.toml`, and tabs whose active pane's cwd matches a
pattern render with the mapped theme. This is the path most users in the
issue thread describe (`pyronaur`, `janderegg`, `milopersic`): "I `cd`
into project A, my theme should change."
3. A **launch-configuration window-level default** that themes every tab a
given launch configuration opens unless the tab itself has another
override.
The global theme remains the fallback when none of the three sources apply.
Window chrome (title bar, sidebar, settings views, the tab strip) continues
to follow the global theme so windows holding mixed-theme tabs remain
visually coherent at the window level.
This spec deliberately scopes out automatic theming triggered by SSH host,
hostname, runtime escape codes, or shell hooks. Those appear in `#478`
discussion and are listed as follow-ups that consume the override field this
spec introduces.
Figma: none provided.
## Goals / Non-goals
In-scope surfaces:
- A new settings map `appearance.themes.directory_overrides` whose keys are
directory paths (tilde-expanded) and whose values are theme identifiers.
Matching uses longest-prefix-wins.
- The launch-configuration YAML schema gains an optional `theme:` field at
the tab level and at the window level.
- A persisted per-tab override that survives session restore.
- A right-click tab menu entry for **Pin theme** (manually override the
active tab's theme to a chosen theme) and **Reset theme** (clear a
manual override; cwd-pattern matching may then reapply).
Out of scope:
- SSH-host-driven, hostname-driven, or `whoami`-driven theming
(`stevenchanin`, `pyronaur`, `zethon`, `janderegg`).
- Escape-code or shell-hook protocols for runtime theme switching
(`yatharth`, for Claude-Code session signaling).
- Per-pane theming. Panes inside a tab continue to share one theme.
- Per-tab wallpaper or graphics (`scottaw66`, `SheepDomination`).
- Changes to the global theme storage path
(`appearance.themes.theme`), the theme picker UI, or custom-theme loading.
Overrides reuse the existing theme identifier type.
## Resolution order
A tab's effective theme is determined by walking these layers and returning
the first hit:
1. **Menu pin**, if any. Set by the right-click "Pin theme" menu and
cleared by the right-click "Reset theme" menu. Persists across
sessions.
2. **Launch-configuration manual pin**, if any. Set by a tab-level
`theme:` in the launch configuration that opened (or restored) the
tab. Cleared only by an explicit "Forget launch config theme" menu
entry — `Reset theme` does **not** clear this layer (per Zach's v4
review). Persists across sessions.
3. **Directory match**, if any. The active pane's current working
directory is matched against `appearance.themes.directory_overrides`;
if a key is a prefix of the cwd, the longest such key wins.
4. **Launch-configuration window-level default**, if the tab was opened
from a launch configuration with a window-level `theme:` and no
closer override applies. Persists across sessions.
5. **Global theme** as derived from `ThemeSettings` and the system theme,
exactly as today.
If none of the override sources resolve to a known theme, behavior is
bit-for-bit identical to today.
## Behavior
### Directory-pattern overrides
1. `settings.toml` accepts a new section
`[appearance.themes.directory_overrides]` whose entries map a directory
path to a theme identifier. Example:
```toml
[appearance.themes.directory_overrides]
"~/Work/medone" = "Dark City"
"~/Work/bondwise" = "Solarized Dark"
"~/Work/checkpt" = "Dracula"
```
Theme values are the same string form accepted by the global
`appearance.themes.theme` (the parser tolerates both display names like
`"Dark City"` and snake-case like `"dark_city"`; see #14 below).
2. Keys are tilde-expanded to absolute paths at match time. Trailing
slashes are normalized away. Symlinks are not resolved — the cwd as
the shell reports it is what's matched. Path normalization rules,
per platform:
- **Linux:** matching is case-sensitive (matches the filesystem
semantics on standard ext4/xfs). Path separators are `/`.
- **macOS:** matching is case-insensitive (matches the default
HFS+/APFS case-insensitive setting). Path separators are `/`.
- **Windows:** matching is case-insensitive. Both `/` and `\` are
accepted as separators in `directory_overrides` keys and are
normalized internally to a single canonical form before
comparison. Drive letters are normalized to uppercase (`c:\Work`
and `C:\Work` are equivalent keys; the second-written one wins
per TOML duplicate-key semantics). Tilde expands to
`%USERPROFILE%`.
Component-boundary matching (the rule in the previous paragraph
that prevents `~/Work/medone` from matching `~/Work/medone-archive`)
uses the platform's path-component definition — on Windows that
means the boundary follows either `\` or `/` after normalization.
3. Match resolution: a key matches if it is a prefix of the active pane's
cwd at a path-component boundary. `~/Work/medone` matches both
`~/Work/medone` and `~/Work/medone/apps/admin-api`, but does **not**
match `~/Work/medone-archive` (no component boundary). When multiple
keys match, the longest one wins (most specific).
4. The cwd evaluated for a tab is the cwd of the **focused pane** in that
tab. A tab whose focused pane is in a non-shell context (notebook,
settings view, etc.) has no cwd and falls through directory matching.
5. When a tab's active pane changes cwd (because the user ran `cd`, opened
a subdirectory, or moved focus to a pane in a different cwd), directory
matching re-runs. If the new cwd matches a different key, the tab
immediately re-renders with the new theme. If it matches no key, the
tab falls through to the next layer in the resolution order.
6. Adding, editing, or removing entries in `directory_overrides` while
Warp is running re-evaluates every open tab. Tabs whose effective theme
changes redraw; tabs whose effective theme is unchanged do not.
7. A theme name in `directory_overrides` that does not resolve to a known
theme is treated the same way an unknown launch-configuration theme is
(#11): a warning is logged identifying the offending key, the entry is
skipped for matching purposes, and the rest of the map continues to
work.
7a. **`directory_overrides` is stored locally and never synced to Warp's
cloud.** Directory paths can encode employer, customer, and project
names (`~/Work/<client>/<engagement>/...`); cloud-syncing the keys
would push that organizational context off-machine. Users on
multiple machines who want shared themes today set them per-machine.
An opt-in cloud-sync mode is a candidate follow-up. The global
theme setting (`appearance.themes.theme`) and per-tab pins set via
the right-click menu remain user-controllable surfaces; only this
map is local-only.
7b. **Diagnostic output never contains raw `directory_overrides` keys.**
Local logs are routinely shared with Warp support and copied into
bug reports, so even local diagnostics can leak path keys. The
invariant is: any warning or telemetry emitted by the
`directory_overrides` machinery refers to an offending entry by a
short non-cryptographic hash of its key plus the offending value
(the theme name, which is non-sensitive). For example, a warning
that today might say `directory_overrides: "~/Work/AcmeCorp/2026":
unknown theme "Drakula"` instead reads
`directory_overrides[hash=8a3f9c]: unknown theme "Drakula"`. The
user can grep their `settings.toml` for the bad theme name to find
the offending row. Hashes are stable for the same key but contain
no path information.
### Launch-configuration overrides
8. A launch configuration YAML may include `theme:` on any tab entry.
Accepted values are theme identifiers in the form documented in #14.
Omitting the field leaves the tab to fall through the resolution order.
9. A launch configuration YAML may include `theme:` on any window entry.
Tabs in that window with no tab-level `theme:` and no directory match
inherit this window-level value (per the resolution order: #3 sits
below #2).
10. Saving a window's current state to a launch configuration preserves
every theme override that was *not* derived from directory matching,
so that reopening the saved configuration produces the same effective
themes (modulo cwd matching, which re-runs against the current
`directory_overrides`). Specifically, for each tab the *preserved
override* is the first non-empty value of, in priority order: the
tab's menu pin (layer #1), the tab's launch-configuration manual
pin (layer #2), or the tab's window-level default (layer #4). Tabs
whose effective theme came only from directory matching (layer #3)
have no preserved override. The save rule is then:
- If every tab in the window has the same preserved override
`Some(X)` and no tab has a manual pin different from the others,
the saved YAML emits a single window-level `theme: X` and no
per-tab `theme:` fields.
- Otherwise, each tab whose preserved override is `Some(Y)` emits a
per-tab `theme: Y`; tabs with no preserved override emit no
`theme:` field; window-level `theme:` is omitted.
- Directory-matched themes never appear in saved YAML — directory
matching is settings-level and re-applies on next open.
This means a launch configuration that originally opened with a
window-level `theme:` round-trips correctly: every tab inherited
the value into its `window_default` slot, the save rule sees a
shared preserved override, and emits the same window-level
`theme:` again.
### YAML / settings format
11. An unknown theme identifier — anywhere it appears — never causes a
file-level load failure. The deserializer accepts any string; the
resolver runs at apply time and falls back to the next resolution
layer for the affected entry only. Other tabs in the same launch
configuration, and other entries in the same `directory_overrides`
map, are unaffected. Each unknown name produces exactly one logged
warning per load.
12. Custom themes referenced by an override behave identically to custom
themes used as the global theme — loaded from the user's themes
directory, fail-soft to the next layer if the file is missing, same
trust/validation rules as the global theme loader.
13. Overrides persist per-tab through session restore, by source:
- **Menu pin** (layer #1) — kept on relaunch.
- **Launch-configuration manual pin** (layer #2) — kept on
relaunch. The launch configuration that set it is not necessarily
reopened on restore, so the value travels with the tab.
- **Launch-configuration window-level default** (layer #4) — kept
on relaunch, same reason as #2.
- **Directory match** (layer #3) — not stored; recomputed on
relaunch from the current `directory_overrides` and the restored
cwd. Editing `directory_overrides` between sessions therefore
takes effect on the next launch.
14. The accepted form for any theme reference (in
`directory_overrides`, in launch-config tab `theme:`, in launch-config
window `theme:`) is a single string. Both the human-readable display
form (`"Dark City"`, `"Solarized Dark"`, `"Dracula"`) and the
snake-case form (`"dark_city"`, `"solarized_dark"`, `"dracula"`) are
accepted. Matching is case-insensitive on whitespace-stripped input.
Custom themes are referenced by their custom-theme name, same as
today's global setting.
### Rendering scope
15. When a tab has an effective override (from any layer), the override
applies to: the terminal cell foreground/background, the ANSI 16-color
palette used by the terminal grid, and any in-tab UI surfaces whose
colors are derived from the active theme (block backgrounds, command
output styling, accent colors). The window chrome (title bar, sidebar,
settings views, the tab strip itself) continues to follow the global
theme.
16. Switching tabs is instant — no flash, no progressive paint. Only the
rendering of the newly-active tab reflects its (possibly different)
theme; inactive tabs do not redraw on switch.
17. Changing the global theme updates every tab whose effective theme
falls through to the global layer. Tabs with overrides at any
higher-priority layer are unaffected.
### User affordances
18. The right-click tab context menu gains three entries, alongside the
existing per-tab attributes:
- **Pin theme...** — opens a submenu listing available themes.
Choosing one sets a *menu pin* on the tab (resolution layer #1),
which wins over every other layer including a launch-config
manual pin. Visible at all times.
- **Reset theme** — clears only the menu pin (layer #1). The tab
falls through to the launch-config manual pin / directory match /
window default / global layers in that order. Visible only when
the tab has a menu pin.
- **Forget launch config theme** — clears only the launch-config
manual pin (layer #2). The tab falls through to the directory
match / window default / global layers. Visible only when the
tab has a launch-config manual pin.
Splitting "Reset theme" from "Forget launch config theme" means a
user who pinned a different theme via the menu can clear that pin
without unintentionally also discarding what their launch
configuration originally set. (Per Zach's v4 review.)
19. The existing per-tab `color:` field on a tab template (the small
colored indicator next to the tab title) is independent of the new
theme override. Both can be set; both are honored.
20. The feature applies on every supported platform (macOS, Linux,
Windows). It is **gated behind a feature flag** named
`appearance.themes.per_tab_overrides` (per Zach's v4 review):
- Initial release ships with the flag **off** in stable and **on**
in dev/preview, so the rollout can soak with internal users
before reaching the broader install base.
- When the flag is off the feature is invisible: the right-click
menu entries are hidden, `directory_overrides` matching does not
run (the settings group is still parseable so users who set up
the map under preview do not lose data), and launch-configuration
`theme:` fields are deserialized but ignored. The user-visible
behavior is bit-for-bit identical to today.
- The default flips to on in stable in a follow-up release, after
telemetry on the preview/dev cohort confirms no regressions in
render performance, settings parsing, or session restore.
- An empty `directory_overrides` map (the default) plus no
launch-config theme fields plus no pinned themes — even with the
flag on — is bit-for-bit identical to current behavior.
### Accessibility
21. The override does not change any text content, accessible labels,
or focus order. Screen readers continue to report tab titles and
contents identically. The three new right-click menu entries have
accessible labels "Pin theme" (with a submenu of theme names),
"Reset theme", and "Forget launch config theme"; each is announced
as a menu item, and each follows the menu's existing visibility
rules (Reset theme appears only when the tab has a menu pin;
Forget launch config theme appears only when the tab has a
launch-config manual pin).
## User-visible failure modes
- **Unknown theme name** — anywhere it appears, the entry is skipped at
apply time, a one-line warning is written to the Warp log identifying
the source (launch configuration filename + tab title or index for
launch-config sources; **redacted entry identifier** for
`directory_overrides` sources, never the raw path key — see #7b),
and the rest of the configuration loads normally.
- **Custom theme file missing** — same fallback the global theme uses
today: tab opens with the next-layer theme, warning logged.
- **Two `directory_overrides` keys are equivalent after tilde expansion**
— last-write-wins per TOML semantics; a warning is logged identifying
the duplicate.
- **A pane's cwd is unavailable** (non-shell pane content) — that pane
contributes no cwd to directory matching; if it is the focused pane the
tab falls through to the window/global layers.
## Open questions
(Resolved in v4 review and folded into the spec — kept here as a record
of the design choices made.)
- ~~`directory_overrides` keys: glob vs. prefix matching.~~ Resolved:
prefix matching only (Zach v4: "i think prefix-match is fine to
start"). Globs are a follow-up.
- ~~"Reset theme" scope: clear all manual layers, or only menu pins.~~
Resolved: only menu pins (Zach v4: "i don't think it should" clear
launch-config pins). A separate "Forget launch config theme" entry
exists for the launch-config layer. Reflected in behaviors #18 and
in the resolution order.
- Should saving a launch configuration emit `directory_overrides`
entries so themes travel with the launch config? Spec keeps the two
surfaces independent — launch configs carry only manual pins;
directory matching is settings-level and does not round-trip through
saved launch configs. A future "shareable theme bundle" could
compose them. Open for product-team input.
+1099
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
# Notebook editor: Raw/Rendered toggle for Mermaid code blocks
## Summary
When a notebook code block's language is set to `Mermaid`, Warp shows a Raw/Rendered icon-button toggle in the block footer. The toggle defaults to Raw only when the user explicitly creates or converts a Mermaid code block in an ordinary editable notebook, which keeps in-progress source text editable. Planning documents and rendered Markdown file views default Mermaid blocks to Rendered so diagrams are visible by default. Selecting Rendered renders the source as a full-width diagram whose height is derived from the loaded SVG's aspect ratio; if rendering fails, an error frame is shown. The language dropdown also shows branded icons for each language.
Reference: GitHub issue `warpdotdev/warp-external#549`.
## Problem
The notebook code block language dropdown exposes `Mermaid` as a selectable language. Today, as soon as the user picks `Mermaid`, Warp unconditionally switches the block into its Mermaid diagram rendering path. This makes ordinary code, plain notes, or work-in-progress diagrams appear as a broken or empty diagram frame instead of staying readable and editable as normal text.
## Goals / Non-goals
**Goals:**
- Mermaid code blocks default to Raw (source text) only when the user explicitly creates or converts a Mermaid code block in an ordinary editable notebook.
- Mermaid code blocks in planning documents and rendered Markdown file views default to Rendered diagrams.
- A Raw/Rendered segmented control lets the user explicitly opt in to diagram rendering per block.
- Rendered mode shows the rendered SVG diagram, or an error frame when the source is invalid.
- The authored Mermaid source is always preserved in the buffer, regardless of display mode.
**Non-goals:**
- Making the Raw/Rendered choice persistent across sessions or round-trips to markdown.
- Reworking the code-block language dropdown, the list of supported languages, or the styling of non-Mermaid code blocks.
- Changing the Mermaid render pipeline's theme, caching, or `mermaid_to_svg` conversion behavior.
- Changing Mermaid behavior in other non-notebook surfaces such as read-only agent output.
## Figma
Figma: https://www.figma.com/design/Lvb72IUdZsYXHj4pjMj6uu/Render-images-and-mermaid-diagrams?node-id=48-4195
## Behavior
The following invariants apply to notebook code blocks whose language is set to `Mermaid` (via the dropdown or via markdown round-trip).
**Language selection**
1. The `Mermaid` option remains available in the code block language dropdown.
2. Picking `Mermaid` sets the block's language to Mermaid in the buffer; the block serializes as a ```` ```mermaid ```` fenced code block on markdown export, regardless of display mode.
3. Picking `Mermaid` in an ordinary editable notebook does not, on its own, trigger diagram rendering — the block opens in Raw mode.
**The language dropdown**
4. The code block language dropdown button and menu are wide enough to display language names and their icons without truncation (wider than the current narrow implementation).
5. Each language option in the dropdown displays a branded icon alongside its text label (e.g., the Go gopher, Python snake, Rust gear, etc.). Languages without an available branded icon fall back to a generic code icon.
**The Raw/Rendered toggle**
6. A Mermaid-labeled block displays a Raw/Rendered toggle in the block footer using two icon buttons — a code-brackets icon (`<>`) for Raw and a dataflow/graph icon for Rendered. These form a segmented-control-style pair.
7. The active button shows a visible background highlight (surface overlay) to indicate which mode is selected; the inactive button shows no background. The Raw button is highlighted in Raw mode; the Rendered button is highlighted in Rendered mode.
8. The footer leaves a clear horizontal gap between the `Mermaid` language label and the Raw/Rendered segmented control, so the label and buttons do not visually crowd each other.
9. In ordinary editable notebooks, the toggle defaults to Raw when the user creates a new code block and sets its language to Mermaid, or changes an existing code block's language to Mermaid.
10. The toggle is visible whenever the block's language is Mermaid, regardless of which mode is active — including when the diagram is successfully rendered. The toggle must not disappear when the block switches to Rendered mode.
11. The Raw/Rendered choice is per-block and per-session only — it is not persisted to the notebook file or round-tripped through markdown export.
**Raw mode**
12. In Raw mode the block renders as an ordinary notebook code block: editable source text, the standard code-block chrome (border, copy button, language dropdown), and `Mermaid` shown as the selected language.
13. All ordinary code-block editing behaviors apply in Raw mode: click to place a cursor, select, type, paste, copy, cut, backspace/delete individual characters, and undo/redo.
14. The buffer content is the source of truth and is preserved regardless of display mode.
**Rendered mode — successful render**
15. When the user selects Rendered, Warp attempts to render the block's current source as a Mermaid diagram using the existing SVG rendering pipeline.
16. While the async render is in progress the block shows a "Rendering Mermaid diagram…" placeholder inside a full-width diagram frame.
17. Before the Mermaid SVG has loaded, the diagram frame uses the full available code-block content width and a stable placeholder height that does not depend on the raw Mermaid source text height.
18. On a successful render the block shows the rendered diagram inside the diagram frame. The rendered frame uses the full available code-block content width, and its height is derived from the loaded SVG's aspect ratio at that full width. The rendered height must not be derived from the raw source text height, and loaded diagrams must not be capped to their intrinsic SVG width when additional block width is available.
19. A block that starts in Rendered mode, including a planning-document Mermaid block or rendered Markdown file Mermaid block that renders by default, must relayout to this same full-width/aspect-ratio height as soon as the initial SVG load completes. The user must not need to toggle Raw/Rendered to get the correct natural-width height.
20. In Rendered mode, the block does not show a text insertion cursor/caret over the diagram. Clicking the rendered diagram may select the block or interact with footer controls, but it must not leave a flashing text cursor inside the diagram frame.
**Rendered mode — failed render**
21. If the Mermaid source cannot be parsed or rendered, the block shows an error frame in place of the diagram. The error frame displays a message such as "Error rendering Mermaid diagram. Please check syntax."
22. The error frame uses the same full-width frame and border style as the diagram frame — it does not fall back to code-block view or use raw source text height.
23. The Raw/Rendered toggle remains visible and functional in the error state. The user can switch back to Raw to edit and fix the source.
**Round-trip and export**
24. The block is persisted and exported as a ```` ```mermaid ```` fenced code block regardless of the current display mode.
25. Reopening an ordinary editable notebook opens Mermaid blocks in Raw mode (the toggle resets to Raw on every open). Opening the same markdown as a rendered Markdown file view opens Mermaid blocks in Rendered mode.
**Planning documents**
26. AI planning documents use the same underlying Mermaid block UI and markdown serialization, but Mermaid blocks default to Rendered rather than Raw.
27. A rendered Mermaid block in a planning document uses the same full-width/aspect-ratio sizing behavior and must not show a flashing text cursor over the diagram.
28. Users can switch a planning-document Mermaid block back to Raw for the current session; the underlying markdown remains a fenced Mermaid code block.
**Rendered Markdown file views**
29. Directly opened Markdown files default to the rendered Markdown view rather than raw Markdown source when Warp opens them in the Markdown viewer.
30. Mermaid blocks in the rendered Markdown file view default to Rendered diagrams rather than Mermaid source text.
31. Users can switch a rendered Markdown file back to Raw from the pane header Markdown toggle; Raw mode opens the file in the code editor. Returning to the rendered Markdown view defaults Mermaid blocks to Rendered again for that view.
**Feature flag**
32. This behavior is gated by the existing `FeatureFlag::MarkdownMermaid` flag. When the flag is off, Mermaid blocks render as ordinary code blocks with no toggle and no diagram rendering.
+296
View File
@@ -0,0 +1,296 @@
# Notebook editor: Raw/Rendered toggle for Mermaid code blocks — Tech Spec
## Context
See `specs/GH549/PRODUCT.md` for the full user-facing behavior.
Today the notebook editor unconditionally renders Mermaid-labeled code blocks as diagrams when `FeatureFlag::MarkdownMermaid` is enabled and the interaction state is `Selectable` or `Editable` (governed by `NotebooksEditorModel::render_mermaid_diagrams_in_state`). The new behavior requires every Mermaid block to default to Raw (code-block view) and let the user opt in to diagram rendering per block via an explicit toggle, matching the existing Raw/Rendered segmented control used in the markdown file viewer.
Relevant files:
- `crates/editor/src/render/element/mod.rs (880-895)``renderable_blocks` match arm for `BlockItem::RunnableCodeBlock` fetches the `runnable_command` via `parent.runnable_command_at(start_offset, ctx)` and passes it to `RenderableRunnableCommand::new` (which calls `render_block_footer`). The `BlockItem::MermaidDiagram` arm does **not** fetch the command, so no footer is rendered — this is the root cause of the toggle disappearing in Rendered mode.
- `crates/editor/src/render/element/runnable_command.rs``RenderableRunnableCommand::new` accepts `Option<&dyn RunnableCommandModel>`, creates the footer element, lays it out, and paints it at `content_rect.lower_right()` in a higher z-index layer. The `COMMAND_SPACING` already reserves `BLOCK_FOOTER_HEIGHT` padding at the bottom, so footer space exists.
- `crates/editor/src/content/edit.rs (689-798)``LayoutTask::from_styled_block` decides whether a `CodeBlockType::Mermaid` block becomes `LayoutTask::MermaidDiagram` or `LayoutTask::MermaidCodeFallback`, driven by `RenderLayoutOptions::render_mermaid_diagrams` and `AssetCache` state.
- `crates/editor/src/content/edit.rs (501-603)``EditDelta::layout_delta` iterates blocks with a `current_offset` counter that tracks each block's start `CharOffset`.
- `crates/editor/src/content/mermaid_diagram.rs``mermaid_asset_source` and `mermaid_diagram_layout`; constructs the async `AssetSource` that fetches and caches the SVG.
- `crates/editor/src/render/model/mod.rs (201-204, 1649-1656)``RenderLayoutOptions` (currently `Copy`) and `RenderState::set_render_mermaid_diagrams`.
- `crates/editor/src/render/element/mermaid.rs``RenderableMermaidDiagram` shows the loading placeholder, SVG, or error state and paints the footer in rendered mode.
- `app/src/notebooks/editor/model.rs (148-161, 337-343)``render_mermaid_diagrams_in_state` and the interaction-state handler that sets the global flag.
- `app/src/notebooks/editor/notebook_command.rs (582-686)``render_block_footer` renders the language dropdown, copy button, and run button; Mermaid blocks get no special UI today.
- `app/src/notebooks/editor/view.rs``EditorViewAction` enum, `RichTextEditorView::handle_action`, and `watch_layout_affecting_asset_loads`, which eagerly watches layout-affecting Mermaid assets and rebuilds layout after they finish loading.
- `app/src/view_components/markdown_toggle_view.rs``MarkdownToggleView` wraps `SegmentedControl<MarkdownDisplayMode>` and emits `MarkdownToggleEvent::ModeSelected`; already used by `FileNotebookView`.
- `app/src/notebooks/file/mod.rs (78-81)``MarkdownDisplayMode` enum (`Rendered` / `Raw`).
Current layout-time flow (Mermaid language tag, `FeatureFlag::MarkdownMermaid` enabled):
1. `text.rs` classifies the block as `CodeBlockType::Mermaid`.
2. `edit.rs` sees `render_mermaid_diagrams == true` and routes based on `AssetCache` state: `Loaded``MermaidDiagram`, `Loading``MermaidCodeFallback` with pending asset, `FailedToLoad``MermaidCodeFallback`.
3. `RenderableMermaidDiagram` shows "Rendering Mermaid diagram…" while loading, then SVG.
4. `mermaid_diagram_layout` previously used `width = min(available_width, intrinsic_svg_width)` for loaded SVGs, and fell back to the raw code-block height when the SVG was not loaded. This caused rendered blocks to sometimes use the raw source text height and sometimes use SVG aspect-ratio height.
Key properties informing the design:
- `render_mermaid_diagrams` is a global `bool` in `RenderLayoutOptions`; there is no per-block rendering control today.
- `EditDelta::layout_delta` already tracks `current_offset` per block — this can be threaded into `from_styled_block` as a `block_start: CharOffset` param to enable per-block lookup.
- `MarkdownDisplayMode` and `MarkdownToggleView` already exist and are reusable.
## Proposed changes
### 1. Stop auto-rendering Mermaid in notebooks (default Raw)
Change `NotebooksEditorModel::render_mermaid_diagrams_in_state` to always return `false`. Remove (or no-op) the call to `set_render_mermaid_diagrams` in `handle_interaction_state_model_event`. This makes all Mermaid blocks default to code-block (Raw) view (Behavior invariants 3, 5, 8).
Non-notebook surfaces (plans, agent output) use `RenderState` instances that are separate from the notebook editor model. They are unaffected by this change to `NotebooksEditorModel`.
### 2. Add `mermaid_render_offsets` to `RenderLayoutOptions`
`RenderLayoutOptions` in `crates/editor/src/render/model/mod.rs` currently derives `Copy`. Add:
```rust
pub mermaid_render_offsets: std::collections::HashSet<string_offset::CharOffset>,
```
Remove `Copy` from the derive (a `HashSet` is not `Copy`). Change `from_styled_block` to take `layout_options: &RenderLayoutOptions` (reference) instead of by value. Update all call sites accordingly.
Add to `RenderState`:
```rust
pub fn set_mermaid_render_offsets(
&mut self,
offsets: std::collections::HashSet<string_offset::CharOffset>,
) -> bool { ... }
```
Returns `true` when the set changed (caller uses this to trigger relayout, same pattern as `set_render_mermaid_diagrams`).
### 3. Thread `block_start` into `from_styled_block`
Add `block_start: CharOffset` as a parameter to `LayoutTask::from_styled_block`. In `EditDelta::layout_delta`, pass the already-tracked `current_offset` as `block_start` when calling `from_styled_block`. Update the test helper `layout_mermaid_block_for_test` to pass `CharOffset::zero()` (or a suitable test offset).
Change the Mermaid routing condition from:
```rust
if layout_options.render_mermaid_diagrams && is_mermaid(...)
```
to:
```rust
if (layout_options.render_mermaid_diagrams
|| layout_options.mermaid_render_offsets.contains(&block_start))
&& is_mermaid(...)
```
### 4. Produce `MermaidDiagram` for all states when user opted in
For blocks in `mermaid_render_offsets`, always produce `LayoutTask::MermaidDiagram` regardless of `AssetCache` state (the render element will display the appropriate UI per state):
- `Loaded` → call `mermaid_diagram_layout`, which uses the full available code-block content width and derives height from the loaded SVG aspect ratio at that width.
- `Loading` → call `mermaid_diagram_layout`, which uses the full available code-block content width and a stable placeholder height that is independent of raw source text height, then emit `MermaidDiagram` (render element shows loading placeholder).
- `FailedToLoad` → call `mermaid_diagram_layout`, which uses the same full-width stable fallback dimensions, then emit `MermaidDiagram` (render element shows error message).
Empty source always falls through to `MermaidCodeFallback` regardless of mode (Behavior invariant 8 — cannot render empty).
For blocks NOT in `mermaid_render_offsets`, keep the existing logic (`Loading``MermaidCodeFallback` with pending asset, `FailedToLoad``MermaidCodeFallback`), which is the correct Raw behavior.
### 5. Show error message in `RenderableMermaidDiagram`
In `crates/editor/src/render/element/mermaid.rs`, extend `RenderableMermaidDiagram::layout` to check `AssetCache` for the block's `asset_source`:
- `AssetState::Loading` → existing "Rendering Mermaid diagram…" `before_load` placeholder.
- `AssetState::Loaded` → existing `Image` element (SVG render).
- `AssetState::FailedToLoad` → create a text element with "Error rendering Mermaid diagram. Please check syntax." using `code_text` styles and `placeholder_color`, wrapped in `Align::finish()`, stored in `self.image_element` to reuse the same paint path.
Draw the block cursor at the right edge of the rendered diagram when the selection head is on the Mermaid block, matching the existing horizontal rule and image affordance. Do not draw a text cursor over the diagram contents; the rendered frame itself is not text-editable.
### 6. Add `mermaid_display_mode` to `NotebookCommand`
Add to `NotebookCommand`:
```rust
mermaid_display_mode: MarkdownDisplayMode, // default: Raw
mermaid_toggle: ViewHandle<MermaidDisplayModeToggle>,
```
`MermaidDisplayModeToggle` is a thin new view (in `notebook_command.rs`) that wraps `MarkdownToggleView`. In its view-level subscription to `MarkdownToggleEvent::ModeSelected(mode)`, it dispatches `EditorViewAction::MermaidDisplayModeSelected { start_anchor, mode }` (see §7).
In `render_block_footer`, when `block_style == CodeBlockType::Mermaid`, render two icon buttons directly (no separate view wrapper needed — see §10). This is always visible for Mermaid blocks regardless of editor focus (Behavior invariant 9).
### 7. Add `EditorViewAction::MermaidDisplayModeSelected` and handle it
Add to `EditorViewAction` in `app/src/notebooks/editor/view.rs`:
```rust
MermaidDisplayModeSelected {
start_anchor: Anchor,
mode: MarkdownDisplayMode,
},
```
In `RichTextEditorView::handle_action` for this variant:
1. Resolve `start_anchor` to a `CharOffset` via `self.model.as_ref(ctx).buffer_selection_model().as_ref(ctx).resolve_anchor(&start_anchor)`.
2. Call `self.model.update(ctx, |model, ctx| model.set_mermaid_render_mode(offset, mode, ctx))`.
`NotebooksEditorModel::set_mermaid_render_mode(offset, mode, ctx)`:
1. Find the `NotebookCommand` at `offset` via `self.child_models.model_at::<NotebookCommand>(offset)` and update its `mermaid_display_mode`.
2. Recompute `mermaid_render_offsets` by iterating all `NotebookCommand` model handles and collecting offsets where `mermaid_display_mode == Rendered`.
3. Let `changed = self.render_state.update(ctx, |rs, _| rs.set_mermaid_render_offsets(new_offsets))`.
4. If `changed`, call `self.rebuild_layout(ctx)`.
### 8. Keep the Raw/Rendered toggle visible in Rendered mode (fix for toggle disappearing)
When a block switches to `BlockItem::MermaidDiagram`, `renderable_blocks` in `crates/editor/src/render/element/mod.rs` currently creates `RenderableMermaidDiagram::new(item)` with no footer. Fix:
1. In the `MermaidDiagram` arm of `renderable_blocks`, fetch `runnable_command` the same way the `RunnableCodeBlock` arm does:
```rust
BlockItem::MermaidDiagram { .. } => {
let start_offset = item.block_offset;
let runnable_command = parent.runnable_command_at(start_offset, ctx);
RenderableMermaidDiagram::new(item, runnable_command, self.display_options.focused, ctx).finish()
}
```
2. In `RenderableMermaidDiagram` (`crates/editor/src/render/element/mermaid.rs`):
- Add `footer: Box<dyn Element>` field (same as `RenderableRunnableCommand`).
- Accept `model: Option<&dyn RunnableCommandModel>`, `editor_is_focused: bool`, `ctx: &AppContext` in `new()`; create the footer via `model.render_block_footer(editor_is_focused, ctx)` or `Empty::new().finish()` when `model` is `None`.
- In `layout()`, lay out the footer at `SizeConstraint::strict(vec2f(content_width, BLOCK_FOOTER_HEIGHT))`.
- In `paint()`, paint the footer at `content_rect.lower_right() - vec2f(footer_width, 0)` inside a higher z-index layer, matching `RenderableRunnableCommand`.
- In `after_layout()` and `dispatch_event()`, delegate to the footer.
`COMMAND_SPACING` (used for Mermaid block layout) already reserves `BLOCK_FOOTER_HEIGHT` as `padding.bottom`, so the footer occupies space that is already accounted for in the block's total height — no layout dimension changes are needed.
This ensures the Raw/Rendered toggle (part of `render_block_footer`) remains visible regardless of whether the block renders as `RunnableCodeBlock` (Raw mode) or `MermaidDiagram` (Rendered mode), satisfying Behavior invariant 6.
### 9. Language icons in the code block dropdown (Behavior invariants 45)
Replace `Dropdown::add_items` (plain text labels) with `Dropdown::set_rich_items` (full `MenuItem` items) in `NotebookCommand::new`. Each item is built with `MenuItemFields::new(code_block_type.to_string()).with_icon(icon_for_type(&code_block_type)).with_on_select_action(...).into_item()`.
Language → icon mapping (using existing bundled SVG assets via the `Icon` enum):
| Language | Icon |
|----------|------|
| Shell | `Icon::Terminal` (`terminal.svg`) |
| Mermaid | `Icon::Code1` (placeholder — no branded Mermaid SVG yet) |
| PowerShell | `Icon::Powershell` (`powershell.svg`) |
| Go | new `Icon::GoLang` (`go.svg`) |
| C++ | new `Icon::CppLang` (`cpp.svg`) |
| JavaScript | new `Icon::JavaScriptLang` (`javascript.svg`) |
| Python | new `Icon::PythonLang` (`python.svg`) |
| Rust | new `Icon::RustLang` (`rust.svg`) |
| SQL | new `Icon::SqlLang` (`sql.svg`) |
| JSON | new `Icon::JsonLang` (`json.svg`) |
| PHP | new `Icon::PhpLang` (`php.svg`) |
| Kotlin | new `Icon::KotlinLang` (`kotlin.svg`) |
| C#, Java, Ruby, YAML, Lua, Swift, Elixir, Scala, text | `Icon::Code1` fallback |
Add the new variants (`GoLang`, `CppLang`, etc.) to the `Icon` enum in `crates/warp_core/src/ui/icons.rs` with the corresponding SVG paths.
Also increase the dropdown button width (`set_top_bar_max_width`) and menu width (`set_menu_width`) so language names and icons render without truncation.
### 10. Replace text toggle with icon buttons (Behavior invariants 610)
Remove `MermaidDisplayModeToggle` view and the `mermaid_toggle` field from `NotebookCommand`. Instead, render two icon buttons inline inside `render_block_footer` using `block_footer_action_button`-style construction:
```rust
// In render_block_footer, when block_style == CodeBlockType::Mermaid:
let is_raw = matches!(self.mermaid_display_mode, MarkdownDisplayMode::Raw);
let start_anchor_raw = self.start.clone();
let start_anchor_rendered = self.start.clone();
let raw_button = icon_button(appearance, Icon::Code1, is_raw, self.mouse_state_handles.mermaid_raw_button_state.clone())
.with_cursor(Cursor::Arrow).on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(&EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_raw.clone(),
mode: MarkdownDisplayMode::Raw,
});
}).finish();
let rendered_button = icon_button(appearance, Icon::Grid, !is_raw, self.mouse_state_handles.mermaid_rendered_button_state.clone())
.with_cursor(Cursor::Arrow).on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(&EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_rendered.clone(),
mode: MarkdownDisplayMode::Rendered,
});
}).finish();
```
Add `mermaid_raw_button_state: MouseStateHandle` and `mermaid_rendered_button_state: MouseStateHandle` to `MouseStateHandles`.
Using element-level click handlers that call `ctx.dispatch_typed_action` replaces the previous view-subscription mechanism, simplifying the implementation.
### 11. Mermaid block sizing and relayout (Behavior invariants 1518)
In `mermaid_diagram_layout` (`crates/editor/src/content/mermaid_diagram.rs`):
- Remove the `default_height` parameter that was supplied from raw code-block layout.
- Compute `max_width = layout.max_width() - spacing.x_axis_offset()` and always use that value as the rendered frame width.
- When the SVG is loaded, compute `height = max_width * intrinsic_svg_height / intrinsic_svg_width`.
- When the SVG is loading, failed, evicted, or lacks valid intrinsic dimensions, use a stable placeholder height equal to `base_line_height * 10.0`.
- Do not clamp loaded SVGs to their intrinsic width; a loaded rendered diagram should use the full available code-block content width.
The resulting shape is:
```rust
let width = max_width;
let height = loaded_svg_height_for_full_width
.unwrap_or((layout.rich_text_styles().base_line_height().as_f32() * 10.0).into_pixels());
```
In `app/src/notebooks/editor/view.rs`, keep eagerly watching layout-affecting `BlockItem::MermaidDiagram` asset loads and rebuild layout when they complete in `Selectable`, `Editable`, or `EditableWithInvalidSelection`. Rendered Mermaid blocks can now appear while the notebook is in editing mode, so restricting this rebuild to `Selectable` leaves the frame stuck at fallback dimensions until another unrelated layout event.
### 12. Keep markdown storage and classification unchanged
- Do not change `From<&CodeBlockText> for CodeBlockType`. Block stays `CodeBlockType::Mermaid`.
- Do not change `to_markdown_representation`. Block serializes as ```` ```mermaid ````.
- Do not change `CodeBlockType::all()`. `Mermaid` stays in the dropdown.
- `mermaid_display_mode` is transient (not saved to the notebook file); reopening always restores Raw (Behavior invariant 23).
## Diagram
```mermaid
flowchart TD
A[User picks Mermaid from dropdown] --> B[Block in Raw mode by default]
B --> C[Footer shows Raw/Rendered toggle]
C --> D{User selects Rendered?}
D -- no --> E[LayoutTask::MermaidCodeFallback -> code-block view]
D -- yes --> F[offset added to mermaid_render_offsets, rebuild_layout]
F --> G[from_styled_block: offset in mermaid_render_offsets?]
G -- yes --> H[AssetCache state?]
H -- Loaded --> I[MermaidDiagram -> SVG]
H -- Loading --> J[MermaidDiagram -> loading placeholder]
H -- FailedToLoad --> K[MermaidDiagram -> error message]
G -- no --> E
```
## Risks and mitigations
**Risk: `RenderLayoutOptions` is no longer `Copy` — call sites break**
Mitigation: Change `from_styled_block` to take `&RenderLayoutOptions`. The tasks are built sequentially before the parallel layout step, so a reference lifetime is safe. Update the test helper and any other call sites.
**Risk: `mermaid_render_offsets` becomes stale when blocks are added, removed, or renumbered**
Mitigation: `NotebooksEditorModel::set_mermaid_render_mode` recomputes the full set from current child models on every toggle. Child models' `mermaid_display_mode` defaults to `Raw` when a new `NotebookCommand` is created, so new blocks are always absent from the set. When a block is deleted, its `NotebookCommand` is dropped from `child_models`, and the next call to `set_mermaid_render_mode` (or a full recompute on `rebuild_layout`) will produce a set without the stale offset.
**Risk: Error state blocks are not re-laid out when source changes**
Mitigation: The `MermaidCodeFallback` path for Raw mode already watches pending assets. For Rendered-mode blocks with a failed asset, the block is in `MermaidDiagram` state. When the user edits the source, the buffer changes, `rebuild_layout` is called, and `from_styled_block` re-checks `AssetCache` for the new source hash. No special watcher is needed.
**Risk: Loading rendered blocks keep fallback dimensions after the SVG finishes**
Mitigation: Keep `BlockItem::MermaidDiagram` in `layout_affecting_asset_load` and rebuild layout when a watched asset load resolves in all notebook states where rendered Mermaid can be visible (`Selectable`, `Editable`, and `EditableWithInvalidSelection`).
**Risk: Cost of calling `mermaid_diagram_layout` for Loading/Failed blocks in Rendered mode**
Mitigation: `mermaid_diagram_layout` calls `mermaid_diagram_size` which only checks `AssetCache` state — it does not trigger a new render. If the asset is not loaded, it falls back to the stable placeholder height. There is no extra work.
## Testing and validation
Layout unit tests in `crates/editor/src/content/edit_tests.rs`:
- Behavior invariants 5, 8: Mermaid block not in `mermaid_render_offsets` lays out as `BlockItem::RunnableCodeBlock`, not `BlockItem::MermaidDiagram`.
- Behavior invariants 1113: Mermaid block in `mermaid_render_offsets` with `AssetState::Loaded` lays out as `BlockItem::MermaidDiagram`.
- Behavior invariant 12: Mermaid block in `mermaid_render_offsets` with `AssetState::Loading` lays out as `BlockItem::MermaidDiagram` (loading placeholder path).
- Behavior invariant 14: Mermaid block in `mermaid_render_offsets` with `AssetState::FailedToLoad` lays out as `BlockItem::MermaidDiagram` (error path).
- Behavior invariants 1517: loaded Mermaid diagram layout uses full available width and derives height from the loaded SVG's aspect ratio at that width; unloaded Mermaid diagram layout uses full available width with stable placeholder height, not raw code-block height.
- Behavior invariant 22: markdown export for a Mermaid-labeled block emits ```` ```mermaid ```` regardless of `mermaid_render_offsets`.
Notebook model tests in `app/src/notebooks/editor/model_tests.rs`:
- Behavior invariants 3, 5: setting a block's language to `Mermaid` produces a `RunnableCodeBlock` render by default (not `MermaidDiagram`).
- Behavior invariants 1114: calling `set_mermaid_render_mode(offset, Rendered, ctx)` updates `mermaid_render_offsets` and causes the block to lay out as `MermaidDiagram`.
- Behavior invariants 5, 18: calling `set_mermaid_render_mode(offset, Raw, ctx)` removes the offset and restores code-block layout.
- Behavior invariant 23: `mermaid_display_mode` is `Raw` on a freshly created `NotebookCommand`.
Manual verification per `specs/GH549/PRODUCT.md`:
- Add a code block, set language to Mermaid → block shows as code block with Raw/Rendered toggle defaulting to Raw.
- In Raw mode, place the cursor inside the Mermaid source and press Backspace/Delete → only the adjacent character is removed; the Mermaid block remains in place.
- Select Rendered → full-width diagram frame appears (loading, then SVG, or error if source is invalid). **Toggle must remain visible inside the diagram frame.** The loaded SVG should use the full available block width and derive height from that full-width render.
- While in Rendered mode, click inside the diagram frame → the cursor appears at the right edge of the rendered block, not over the diagram contents.
- Click Raw from the diagram view → code block view restored with toggle visible.
- Edit in Raw mode, then select Rendered again → new source is rendered.
- Save and reopen notebook → toggle resets to Raw.
## Follow-ups
- Persist `mermaid_display_mode` per block in the notebook file format so it survives reopen.
- Consider unifying this toggle with plans/agent-output Mermaid rendering via `specs/mermaid-markdown-in-plans/`.
- Add a proper branded Mermaid SVG icon to the asset bundle and map it via `Icon::MermaidLang`.
- Add branded SVGs for languages currently falling back to `Icon::Code1` (C#, Java, Ruby, YAML, Swift, Elixir, Scala).
+127
View File
@@ -0,0 +1,127 @@
# Side-by-Side Diff Layout in the Code Review Pane - Product Spec
GitHub issue: https://github.com/warpdotdev/warp/issues/7043
Roadmap reference: https://github.com/warpdotdev/warp/issues/9233 ("Improved code review - Side by side diffs", listed under "Seeking community drivers")
Figma: none provided
## Summary
Add a side-by-side diff layout as an alternative to the existing inline diff in the Code Review pane only. The user picks the layout in Settings -> Code under a new "Diff layout" subsection, and the choice is persisted as a synced `code.editor.diff_layout` setting. Inline remains the default. Side-by-side renders the baseline on the left and the modified file on the right, with hunk-aligned padding so corresponding lines sit on the same row.
V1 applies only to Code Review. AI block-list diffs, inline banner diffs, and settings-menu surfacing for those surfaces are explicitly deferred to v2.
## Problem
Warp renders Code Review file diffs as a single-column inline view: deletions on `-` rows, additions on `+` rows, both interleaved. The Code Review pane consumes editor and diff primitives through `LocalCodeEditorView`, `CodeReviewEditorState`, and `CodeEditorView`, and currently has no layout knob.
Two real costs follow from that:
- Side-by-side is the de-facto standard for diff review across GitHub, GitLab, Phabricator, JetBrains IDEs, VS Code, and tools like Beyond Compare. Users with wide displays expect it, and the absence shows up repeatedly in user feedback, including the request that opened this issue: "since we aren't writing the code ourselves," the reviewer needs to read both versions in parallel rather than reconstructing them mentally from a single column.
- Warp's review surface is shaped by AI-generated edits more than by hand-written commits. An AI agent regularly produces diffs that touch dozens of unrelated regions in one block. In an inline layout, a 30-line modification with reordered code is hard to follow, because the same logical block appears as `-` lines in one place and `+` lines in another. Side-by-side puts those next to each other.
The two related open issues - #9017 (word wrap in diff and Markdown) and #9040 (auto line wrap in diff) - assume a single diff layout and argue about wrapping inside it. They are out of scope for this spec but become more useful once a side-by-side layout exists, since wrap behavior interacts with column width.
## Goals
- Introduce a `DiffLayout` choice with two values: `Inline` (today's behavior) and `SideBySide`.
- Default `DiffLayout` to `Inline` for every existing user; the change is opt-in.
- Surface the choice in Settings -> Code under a new "Diff layout" subsection for the Code Review pane.
- Persist the choice as a synced user setting `code.editor.diff_layout` so it carries across sessions and machines.
- Apply the chosen layout to the Code Review pane only in v1.
- In `SideBySide`, render the baseline on the left, the modified file on the right, with hunk-aligned padding so that unchanged context lines, modifications, and pure additions or deletions all sit at the same vertical position on both sides.
- Keep edits in the diff constrained to the right, modified pane. The left, baseline pane is never editable.
- Keep the text cursor in the right, modified pane only. The left pane does not expose an insertion cursor.
- Allow selection on the left pane for copy, including deleted-line ranges.
- Preserve every existing Code Review diff feature in both layouts: hunk navigation (`f`/`F`), accept and reject, save, revert to base, comment threads, hidden lines, find-in-diff, and the existing nav bar.
- Keep `Inline` byte-for-byte identical to today. No regression to the default code path.
## Non-goals
- Applying side-by-side layout to AI block-list diffs in v1. That surface is deferred to v2.
- Applying side-by-side layout to inline banner diffs in v1. That surface is deferred to v2.
- Adding settings-menu surfacing for AI block-list or inline banner diffs in v1.
- Word-level or character-level diff highlighting on changed lines. Issue #9017 and #9040 are the natural follow-up specs for that.
- A vertical "stacked" layout (baseline on top, modified below). The roadmap and the issue specifically ask for side-by-side; a stacked variant could be a separate spec.
- Cross-pane selection. Selection in `SideBySide` is per-pane, matching GitHub Desktop and GitLab MR review.
- Per-pane width control. The split is fixed at 50/50 in this change; resizable splits can follow.
- A separate layout choice per surface. V1 has only one participating surface: Code Review.
- Mobile/wasm-only behavior changes beyond what falls out naturally from layout symmetry.
- Changing the existing `DiffMode` enum (`Head` / `MainBranch` / `OtherBranch(String)`) in `app/src/code_review/diff_state.rs:266`. That enum controls *what* is being compared; the new `DiffLayout` enum controls *how* the comparison is rendered. The two are orthogonal.
## Behavior
1. When `code.editor.diff_layout` is unset or `inline`, Code Review diffs in Warp render identically to today. No regression to the default form.
2. When `code.editor.diff_layout` is `side_by_side`, Code Review renders with the baseline on the left and the modified file on the right.
- The split is a 50/50 vertical split with a single 1-pixel divider in the panel chrome.
- The two panes share vertical scroll position and inherit horizontal scrollbars independently, since wrap-vs-no-wrap behavior is per-pane.
- The baseline pane shows the file content prior to the diff, with deletions visible. The modified pane shows the post-diff file content, with additions visible. Unchanged context lines appear in both panes at the same vertical position.
- AI block-list diffs and inline banner diffs continue to render inline in v1 regardless of the stored setting.
3. Hunk-aligned padding keeps corresponding lines aligned across panes. The alignment algorithm pairs deleted lines with added lines within a hunk so that a modification renders on a single shared row across the two panes:
- Unchanged lines: rendered at the same row on both sides with the same content.
- Modifications: within each hunk, deleted lines and added lines are paired in order. For a hunk of `D` deleted lines followed by `A` added lines, the first `min(D, A)` pairs render on shared rows: the deleted line on the left at row N, the added line on the right at row N. The shared row is what makes "before/after" review readable.
- Excess deletions (when `D > A`): the trailing `D - A` deleted-only lines render on the left at consecutive rows; the right pane shows blank padding at the same rows. This is the pure-deletion case for unpaired suffixes.
- Excess additions (when `A > D`): the trailing `A - D` added-only lines render on the right at consecutive rows; the left pane shows blank padding at the same rows. This is the pure-addition case for unpaired suffixes.
- The padding rows are visually the same height as a normal line and use the same gutter as the matching pane, so vertical positions on the two panes always agree.
- Word-level or character-level highlighting on a paired modification row is out of scope (see Non-goals); a row pair shows the deleted line in full on the left and the added line in full on the right.
4. Synchronized vertical scrolling:
- A scroll wheel event in either pane drives both panes by the same delta.
- Cursor-up or cursor-down moves only the right pane cursor. The baseline pane scrolls without exposing or moving a cursor so the corresponding line stays in view.
- Hunk navigation actions (`f` / `F` / "Next change" / "Previous change") move the focused hunk on both panes simultaneously, focusing the matching row in the modified pane.
- Search and find-next in code review keeps both panes scrolled to the matched line, with the match highlighted on the pane that contains it.
5. Selection is per-pane:
- Mouse drag selection on one pane never extends into the other.
- Cmd-A in the modified pane selects only modified-pane content.
- Cmd-A in the baseline pane selects only baseline-pane content that is selectable for copy.
- Copy from a pane copies only that pane's selected text. The clipboard text is the rendered pane's content (no diff markers added).
- Deleted-line ranges in the baseline pane are selectable for copy.
- This matches GitHub Desktop and GitLab MR review behavior. Cross-pane selection is out of scope.
6. Settings -> Code is the entry point for the layout choice:
- The Code settings page gains a "Diff layout" subsection with a two-option segmented control: "Inline" and "Side by side".
- Selecting either option updates `code.editor.diff_layout` and refreshes visible Code Review diffs without re-fetching diff data or losing the current scroll position.
- The currently active layout is shown as the selected segment.
- The diff toolbar continues to expose per-view ephemeral controls such as whitespace visibility, but it does not expose `code.editor.diff_layout`; the layout is a user-level preference for Code Review v1.
7. The setting is read by the Code Review diff host at diff-construction time and on every change:
- Code Review construction reads `code.editor.diff_layout` and dispatches to inline rendering or `DiffLayout::SideBySide` rendering.
- The Code Review pane subscribes to setting updates and applies the new layout to every visible diff. Diffs that are scrolled out of view rebuild lazily on next render.
- Switching the setting while a diff is open preserves the current scroll position and cursor row in both layouts. The user does not need to scroll back to where they were.
8. Hunk navigation, accept, reject, save, and revert behave identically across layouts:
- Accept (write the modified file) writes the same content that the inline layout would have written.
- Reject discards the modification on both panes; the result is the baseline.
- Revert-to-base restores the editor to the baseline content; the side-by-side renderer then shows two identical panes (which rebuild as a no-op diff).
- Save writes the modified content via `FileModel`, the same as today.
9. Comment threads in Code Review render in `SideBySide` next to the line they target:
- Comments authored on a line in the baseline pane render under that line in the baseline pane.
- Comments authored on a line in the modified pane render under that line in the modified pane.
- The corresponding pane shows a small "comment marker" gutter glyph at the same row to indicate that the other side has a thread there.
- Multi-line comment ranges that span both deleted and added regions render on the side they were originally authored against, the same as today.
10. Hidden lines (collapsed unchanged context) work identically across layouts:
- The same "Show N more lines" affordance appears at the same vertical position on both panes.
- Expanding hidden lines on either pane expands them on both, since context lines exist in both files.
11. Find-in-diff in Code Review highlights matches in both panes when both panes contain the search term, and only the matching pane when only one does. Cycling through matches with `cmd-G` / `cmd-shift-G` advances the search position across panes; focus remains on the modified pane when the next match lives on the baseline side.
12. The Code Review header (`app/src/code_review/code_review_header/`) and the existing diff menu's `DiffMode` selector ("Head" / "Main Branch" / "Other Branch") are unchanged. They control the comparison base; layout is orthogonal.
13. Telemetry: when the user changes the layout in Settings -> Code, emit a `CodeReviewTelemetryEvent` carrying the new layout value. Layout-change rate is the metric for adoption.
14. Accessibility:
- The modified pane participates in normal edit-focused tab order. The baseline pane can receive focus for copy selection only and never exposes edit actions.
- The cursor is announced only in the modified pane.
- Screen-reader output for a side-by-side row should read as "Original: <text> ... Modified: <text>" so a non-sighted reviewer can still hear both sides.
- Color is not the only signal of change: every changed row carries a `+` or `-` gutter glyph in the corresponding pane, identical to today's inline gutter.
15. Performance budget:
- Switching layouts on a 5,000-line diff completes in under 200ms on an M1 MacBook Air, measured from settings change to first paint of the new layout.
- Memory overhead for `SideBySide` is bounded to the additional state needed to render the baseline pane alongside the modified pane.
16. Feature flag gating:
- The change ships behind a `SideBySideDiffLayout` `FeatureFlag` defined in `crates/warp_features/src/lib.rs` (the canonical flag enum, re-exported from `app/src/features.rs`) that defaults to off in shipping builds and on in dogfood/preview builds. Once stabilized, the flag is removed and the setting becomes the user-facing control.
- When the flag is off, the Settings page does not render the "Diff layout" widget, and the setting is treated as `Inline` regardless of stored value.
## Mockup placeholder
The mockup placeholder for this spec is the Settings -> Code page. It should show a "Diff layout" subsection under Code settings with an "Inline" / "Side by side" segmented control and explanatory helper text that the setting applies to Code Review.
+559
View File
@@ -0,0 +1,559 @@
# Side-by-Side Diff Layout in the Code Review Pane - Tech Spec
Product spec: `specs/GH7043/product.md`
GitHub issue: https://github.com/warpdotdev/warp/issues/7043
Roadmap reference: https://github.com/warpdotdev/warp/issues/9233
## Context
The v1 diff rendering surface is the Code Review pane only. AI block-list diffs and inline banner diffs continue to use the existing inline path until a v2 spec extends the layout setting to those surfaces.
Relevant Code Review and editor primitives:
- `app/src/code/editor/view.rs::CodeEditorView` is the rendering target. V1 side-by-side uses two editor-view instances: a select-only baseline view and the normal modified view.
- `app/src/code/editor/element.rs::EditorWrapper` owns editor rendering, layout, hit testing, gutters, and text shaping for each `CodeEditorView`. V1 keeps the editor internals mostly unchanged and puts cross-pane coordination in a parent bridge wrapper.
- `warp_editor::render::model::RenderState` holds the state used for shaped text, gutters, visible rows, and hit testing. Each editor view keeps its own render state.
- `app/src/code/editor/diff.rs` holds editor-level diff line decoration and gutter rendering. `DiffLineType` classifies lines as `Context`, `Add`, `Delete`, and `HunkHeader`.
- `app/src/code_review/diff_state.rs` holds hunk state, the `DiffMode` enum (`Head` / `MainBranch` / `OtherBranch(String)`, comparison base rather than layout), and the per-file diff state model.
- `app/src/code_review/comments/diff_hunk_parser.rs` parses hunks into ordered per-line records. The side-by-side aligner consumes those records; no new parser is introduced.
- `app/src/code_review/editor_state.rs::CodeReviewEditorState` owns Code Review editor state. The layout hook belongs here or in the equivalent shared wrapper, not in per-file `InlineDiffView` migration code.
- `app/src/code/local_code_editor.rs::LocalCodeEditorView` owns the local editor path that hosts Code Review editors. Layout flips route through this host into the side-by-side bridge wrapper when the Code Review path renders side-by-side.
- `app/src/settings/code.rs` is the settings group for `code.*`. Settings are declared via `define_settings_group!` with `toml_path`, `default`, `supported_platforms`, and `sync_to_cloud` fields.
- `app/src/settings_view/code_page.rs` is the explicit Code settings UI. Declaring a settings entry does not render it; the page needs a concrete widget registered in the Code section.
- `crates/warp_features/src/lib.rs` defines the canonical `FeatureFlag` enum, `DOGFOOD_FLAGS`, `PREVIEW_FLAGS`, and changelog descriptions. `app/src/features.rs` re-exports the feature API.
- `app/src/lib.rs` builds the set of compiled-in feature flags through cfg-gated `FeatureFlag::Variant` entries. The corresponding Cargo feature declarations live in `app/Cargo.toml`.
- `app/src/code_review/scroll_preservation.rs` holds scroll preservation helpers that the side-by-side scroll-sync model can build on.
- `app/src/code_review/comments/comment.rs` and `comment_list_view.rs` own comment rendering. Comment placement gains a per-pane gutter marker; existing anchoring on `EditorLineLocation` remains.
- `app/src/code_review/telemetry_event.rs` defines `CodeReviewTelemetryEvent`. The layout-change event registers here.
- `app/src/code_review/find_model.rs` holds the find-in-diff state model that needs to traverse both panes in side-by-side.
The implementation introduces a `DiffLayout` enum (`Inline` / `SideBySide`), stores it as `code.editor.diff_layout`, exposes it in Settings -> Code, and gates the Code Review path behind `SideBySideDiffLayout`.
Architecture choice: `DiffLayout::SideBySide` is implemented as two `CodeEditorView` instances wrapped by a Code Review bridge component. The baseline view renders base content in select-only mode. The modified view renders the global buffer entry for the working file. The bridge owns cross-pane synchronization for hidden lines, scroll position, find state, and shared diff state while keeping the two views' buffers separate.
## Proposed changes
### 1. Introduce the `DiffLayout` enum
Add `app/src/code/diff_layout.rs`:
```rust
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffLayout {
#[default]
Inline,
SideBySide,
}
impl DiffLayout {
pub fn is_side_by_side(&self) -> bool {
matches!(self, DiffLayout::SideBySide)
}
}
```
Re-export from `app/src/code/mod.rs`. The enum is `Copy` so it can travel through view contexts without lifetime concerns. The `serde` representation (`"inline"` / `"side_by_side"`) matches the setting value.
`DiffLayout` is intentionally separate from display and comparison state:
- `DisplayMode` answers "where on screen does this diff live" (own pane vs embedded vs inline banner).
- `DiffMode` answers "what is being compared" (head vs main branch vs another branch).
- `DiffLayout` answers "how do we render the diff content" (one column vs two columns).
V1 only reads `DiffLayout` in the Code Review pane. AI block-list and inline banner hosts keep their current inline behavior even when the stored value is `side_by_side`.
### 2. Add the `code.editor.diff_layout` setting
Extend `define_settings_group!` in `app/src/settings/code.rs`:
```rust
diff_layout: DiffLayoutSetting {
type: crate::code::diff_layout::DiffLayout,
default: crate::code::diff_layout::DiffLayout::Inline,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "code.editor.diff_layout",
description: "Layout for Code Review diff views: 'inline' or 'side_by_side'.",
},
```
The setting type system already supports enums via `serde`, matching how other setting groups carry strongly typed values. Default is `Inline`, so existing users are unaffected unless they opt in.
Resolution: this setting is intentionally global storage even though v1 has one participating surface. That leaves room for v2 surfaces without adding a second setting name.
### 3. Settings page integration in `settings_view/code_page.rs`
`code.editor.diff_layout` is exposed in Settings -> Code under a new "Diff layout" subsection. This is the primary user entry point for the Code Review preference.
Add a `DiffLayoutWidget` row in `app/src/settings_view/code_page.rs`:
- The widget renders a two-option segmented control: "Inline" and "Side by side".
- The selected segment reads from `CodeSettings::DiffLayout`.
- Segment changes write `code.editor.diff_layout` through the settings store and emit the layout-change telemetry event.
- Register the widget explicitly in the Code settings group near the existing Code Review settings widgets. This resolves the CodeSettings rendering concern: declaring the setting alone does not make it appear in the page.
- Gate widget registration on `FeatureFlag::SideBySideDiffLayout.is_enabled()` so the control is hidden while the runtime flag is off.
The diff toolbar continues to own only per-view ephemeral controls, such as whitespace visibility. It does not expose `code.editor.diff_layout`.
Resolution: the settings-page widget integration is part of v1 and is not left as implicit settings metadata.
### 4. Add the `SideBySideDiffLayout` feature flag
The flag is wired in the actual repo feature-flag locations:
1. **Enum variant**: add `SideBySideDiffLayout,` to `crates/warp_features/src/lib.rs::FeatureFlag`, near related Code Review flags such as `CodeReviewFind`.
2. **Cargo feature and compiled-in registration**:
- Add `side_by_side_diff_layout = []` to `[features]` in `app/Cargo.toml`, following the existing `code_review_find = []` pattern.
- Add the cfg-gated entry in `app/src/lib.rs` alongside the other compiled-in feature flags:
```rust
#[cfg(feature = "side_by_side_diff_layout")]
FeatureFlag::SideBySideDiffLayout,
```
3. **Dogfood and preview runtime defaults**:
- Add `FeatureFlag::SideBySideDiffLayout` to `DOGFOOD_FLAGS` in `crates/warp_features/src/lib.rs` for the first internal phase.
- Move it to `PREVIEW_FLAGS` when widening beyond dogfood. Preview flags are automatically included in dogfood builds.
- Do not add it to `RELEASE_FLAGS` until the staged rollout is complete.
4. **Changelog description**: add a `description_for_changelog` match arm:
```rust
SideBySideDiffLayout => Some("Enables a side-by-side diff layout in the code review pane."),
```
Rollout:
- Compile-time gate: `side_by_side_diff_layout` controls whether the app binary includes `FeatureFlag::SideBySideDiffLayout` in the compiled-in flag list.
- Runtime gate: the feature-flag service decides whether `FeatureFlag::SideBySideDiffLayout.is_enabled()` returns true for the current channel/user.
- Dispatch bridge: Code Review construction first checks the runtime flag. If disabled, it treats the effective layout as `DiffLayout::Inline` regardless of stored settings. If enabled, it reads `code.editor.diff_layout` and renders either the inline editor path or the side-by-side bridge wrapper.
- Default rollout schedule: off in shipping builds -> 5% dogfood -> 25% dogfood -> 100% dogfood -> preview -> release.
Resolution: hidden flag state suppresses both the settings widget and the runtime layout path.
### 5. Side-by-side bridge wrapper
`DiffLayout::SideBySide` is rendered by a bridge wrapper that owns two `CodeEditorView` children:
```rust
pub struct SideBySideDiffBridge {
baseline_view: CodeEditorView,
modified_view: CodeEditorView,
shared_diff_state: HunkAlignment,
hidden_lines: HiddenLineRanges,
scroll_anchor: SideBySideScrollAnchor,
find_state: CodeReviewFindState,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Side {
Baseline,
Modified,
}
```
The bridge wrapper sits inside the existing Code Review editor host. It owns the side-by-side layout, renders the two editor views with equal widths and one divider, and propagates shared state changes in both directions.
Bridge-owned synchronization:
- **Hidden line ranges**: collapsed unchanged regions are represented once and applied to both views. Expanding hidden lines from either pane updates the shared hidden-line state and reconfigures both views so heights stay aligned.
- **Scroll position**: scrolling either pane updates a shared line anchor, then drives the other pane to the corresponding aligned row. Sync is line-anchored rather than pixel-based so font, wrapping, or line-height differences cannot accumulate drift.
- **Find state**: a single Code Review find session spans both views. Matches from either buffer are highlighted, and "Next" / "Prev" can traverse across panes.
- **Shared diff state**: both views read the same diff result and `HunkAlignment` so they agree on which baseline rows are deletions, which modified rows are insertions, and where visual gaps belong. This shared diff state is not a shared buffer.
The two buffers stay separate:
- The baseline view's buffer source is the base pre-edit content.
- The modified view's buffer source is the global buffer entry for the working file.
- Edits to the modified buffer re-trigger the diff. The refreshed diff updates both views' decoration config: newly deleted baseline rows remain regular selectable rows in the baseline view, while the modified view renders the corresponding baseline-only rows as gaps. Newly inserted modified rows render as regular rows in the modified view and gaps in the baseline view.
Resolution: the editor view internals stay close to the existing model. Side-specific behavior lives in each child editor view, and cross-pane coordination lives in the bridge.
### 6. Baseline and modified editor views
In `DiffLayout::Inline`, the existing Code Review editor path is used unchanged.
In `DiffLayout::SideBySide`, the bridge configures two editor views:
- The baseline view is a select-only `CodeEditorView` instance backed by base content.
- The modified view is a normal `CodeEditorView` instance backed by the global buffer entry for the working file.
- The modified view uses a decoration config that renders baseline-only rows as visual gaps instead of temporary deletion blocks.
- Text shaping, gutter rendering, hit testing, selection, and copy stay inside each child editor view.
- The bridge determines the pane from the event target and only forwards edit-capable events to the modified view.
Caller routing:
| Caller / behavior | Pane-aware route |
|---|---|
| Cursor focus, local selection, copy from modified side | Modified `CodeEditorView` |
| Copy from baseline side | Baseline `CodeEditorView` with selection-only focus |
| `changed_lines` | Modified `CodeEditorView` |
| Accept diff, save diff, reject-to-modified-buffer operations | Modified `CodeEditorView` |
| Hunk navigation | Bridge row alignment plus modified-side focus |
| Scroll preservation | Bridge scroll anchor |
| Comment rendering and gutter markers | Bridge `HunkAlignment` row map plus the targeted child view |
Resolution: right-pane-only editing is enforced by using a normal editor view only for the modified buffer. The baseline editor is select-only and never exposes an insertion cursor.
### 7. Pane content construction
Side-by-side reuses the existing unified-diff parser but does not reuse inline deleted-line rendering. The side-by-side pipeline is:
1. Parse the unified diff to `DiffHunk[]` using the existing parser. No parser changes are needed.
2. Build a shared diff state from the base content, current modified global buffer content, and ordered hunk lines.
3. Run hunk alignment over the ordered hunk lines. Each `AlignedRow` maps to a row index in both panes. Gap rows do not exist in either source file, but the bridge passes them to the appropriate view as decoration metadata.
4. Configure the baseline view with base buffer rows, delete decorations, hidden lines, and baseline-side alignment metadata.
5. Configure the modified view with the global buffer entry, add decorations, hidden lines, and a decoration config that renders baseline-only rows as visual gaps.
`apply_diffs_if_any` remains the inline path. When `DiffLayout::SideBySide` is active, the bridge uses the pane-content pipeline instead:
- Removed lines render as normal selectable rows in the baseline view.
- Added lines render as normal editable-buffer rows in the modified view.
- Baseline-only rows render as visual gaps in the modified view.
- Modified-only rows render as visual gaps in the baseline view.
- Inline temp-block deletion rendering is disabled for the modified side to prevent deletion bleed.
- Accept, reject, save, and changed-line computation continue to read the modified buffer, matching inline behavior.
Resolution: side-by-side keeps the baseline and modified buffers independent while sharing the diff state needed for aligned rendering.
### 8. Per-pane interaction state
Baseline pane:
- Always read-only.
- Uses a select-only `CodeEditorView` instance backed by base content.
- Supports text selection and copy on all baseline rows, including deleted-line ranges, because those ranges are real rows in the baseline buffer.
- Does not expose an insertion cursor.
- Does not consume keyboard edit events.
- Does not participate in file-backed save.
- Receives delete decorations and hidden-line configuration from the bridge.
Modified pane:
- Uses a normal `CodeEditorView` instance backed by the global buffer entry for the working file.
- Owns the cursor.
- Owns all writeable interactions.
- Follows the existing Code Review rules for accept, reject, save, revert, and hunk navigation.
- Is the only side registered with `FileModel`.
- Uses a decoration config that renders baseline-only rows as visual gaps instead of temporary deletion blocks.
The bridge applies Code Review interaction state to the modified pane and hard-codes baseline interaction state to read-only selection/copy. `FullPane` behavior from other surfaces is not part of v1.
Resolution: this addresses the right-pane-only edit and cursor requirements directly in the editor interaction model.
### 9. RowIndex and hunk alignment
Add or update `app/src/code/hunk_alignment.rs`:
```rust
pub struct DiffHunk {
pub header: UnifiedDiffHeader,
pub lines: Vec<DiffLine>,
}
pub enum DiffLine {
Context(String),
Add(String),
Delete(String),
}
pub struct AlignedHunk {
pub rows: Vec<AlignedRow>,
}
pub struct AlignedRow {
pub left: PaneLine,
pub right: PaneLine,
pub row_index: RowIndex,
}
pub enum PaneLine {
Line { buffer_line: usize, text: String },
Gap,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RowIndex {
Baseline(usize),
Modified(usize),
Gap { after_row: usize },
}
pub struct HunkAlignment {
pub baseline_rows: Vec<RowIndex>,
pub modified_rows: Vec<RowIndex>,
pub row_map: Vec<(Option<RowIndex>, Option<RowIndex>)>,
}
impl HunkAlignment {
pub fn from_diff_hunks(hunks: &[DiffHunk]) -> Self;
}
```
`RowIndex` semantics:
- `RowIndex::Baseline(n)` means this aligned row corresponds to baseline buffer row `n`.
- `RowIndex::Modified(n)` means this aligned row corresponds to modified buffer row `n`.
- `RowIndex::Gap { after_row }` means this aligned row is a render-only gap inserted after aligned row `after_row`. It does not correspond to either side's source line numbering.
The alignment producer emits `RowIndex` values while it walks hunks:
- Context rows produce `Baseline(b)` on the left and `Modified(m)` on the right.
- Paired delete/add modification rows produce `Baseline(b)` on the left and `Modified(m)` on the right.
- Pure deletion rows produce `Baseline(b)` on the left and `Gap { after_row }` on the right.
- Pure addition rows produce `Gap { after_row }` on the left and `Modified(m)` on the right.
The bridge stores the alignment result and passes each editor view only the row metadata for its side:
- `Baseline(n)` maps to a real shaped text row in the baseline view.
- `Modified(n)` maps to a real shaped text row in the modified view.
- `Gap { after_row }` maps to a full-height empty visual row with gutter and background metadata in the opposite view.
- Selection, copy, cursor, save, and find ignore gap rows as source text. Hit testing a gap row resolves to the nearest valid row for scroll anchoring and comment positioning.
The v1 algorithm is single-pass and pairs collapsed delete/add runs before emitting gap rows:
```text
for hunk in hunks:
pending_deletes = []
pending_adds = []
for line in hunk.lines:
if line is Context:
flush_pending_pairs()
emit row(left = context, right = context)
if line is Delete:
if pending_adds is not empty:
flush_pending_pairs()
pending_deletes.push(line)
if line is Add:
pending_adds.push(line)
flush_pending_pairs()
flush_pending_pairs():
pair_count = min(pending_deletes.len, pending_adds.len)
emit pair_count rows with left delete and right add
emit remaining deletes with right Gap
emit remaining adds with left Gap
clear pending_deletes and pending_adds
```
Example:
```text
Input hunk lines:
Context("fn old() {")
Delete(" a();")
Delete(" b();")
Add(" c();")
Add(" d();")
Context("}")
Aligned rows:
1. left Context("fn old() {") | right Context("fn old() {")
2. left Delete(" a();") | right Add(" c();")
3. left Delete(" b();") | right Add(" d();")
4. left Context("}") | right Context("}")
```
Edge cases:
- Pure insertion blocks produce rows with left `Gap` and right `Add`; the baseline view receives gap decorations.
- Pure deletion blocks produce rows with left `Delete` and right `Gap`; the modified view receives gap decorations.
- A `Context` inside a modification resets the pairing window. Deletes before the context are flushed before adds after the context are considered.
- A delete run followed by an add run with unrelated text still pairs by position. Word-level highlighting is out of scope; v1 guarantees row alignment.
Resolution: `line_index` is not a free integer. The API uses `RowIndex` so baseline rows, modified rows, and render-only gaps are distinguishable, and the bridge exposes that aligned row map to both views.
### 10. Bridge scroll sync
Side-by-side scroll sync is owned by the bridge wrapper:
```rust
pub struct SideBySideScrollAnchor {
focused_side: Side,
anchor_row: RowIndex,
anchor_side: Side,
horizontal_scroll_by_side: BTreeMap<Side, ScrollOffset>,
}
impl SideBySideScrollAnchor {
pub fn on_scroll_wheel(&mut self, side: Side, anchor_row: RowIndex);
pub fn corresponding_row(
&self,
side: Side,
row_index: RowIndex,
alignment: &HunkAlignment,
) -> Option<RowIndex>;
}
```
Scroll sync is line-anchored. A wheel event or hunk navigation event in either view updates the shared row anchor, and the bridge asks the other view to reveal the corresponding row from `HunkAlignment`. Horizontal scroll remains per pane.
Cursor movement belongs to the modified side. When cursor navigation changes the modified row, `corresponding_row` computes the nearest baseline row for visibility but does not move a baseline cursor. Hunk navigation updates the shared scroll anchor and the modified-side cursor.
Resolution: the bridge synchronizes views by row anchors, not by matching pixel offsets. If implementation needs event source tagging to avoid recursive updates, that state belongs in the bridge.
### 11. Code Review pane integration
The Code Review path uses the existing Code Review editor host, not per-file `InlineDiffView` instances. The integration:
- Represent side-by-side editor state as either two `CodeReviewEditorState` slices or one slice with `baseline_sub_state` and `modified_sub_state`. The two child views must have distinct lifecycles even when the parent reducer entry point stays shared.
- On Code Review editor construction, compute the effective layout. If `SideBySideDiffLayout` is off, use `Inline`. If it is on, read `code.editor.diff_layout`.
- In inline mode, keep the existing single-editor path.
- In side-by-side mode, render the bridge wrapper inside whichever existing Code Review container component renders the editor today. If that container is `LocalCodeEditorView`, the bridge replaces the single `LocalCodeEditorView` instance for the side-by-side path.
- Subscribe to setting updates in the Code Review host. When `code.editor.diff_layout` changes, rebuild visible editors into the selected layout and preserve scroll position via `app/src/code_review/scroll_preservation.rs`.
- Find-in-diff gains a `Side`-aware iterator over both child editor views when the active diff is side-by-side. Inline returns only the modified side.
- Hidden-line expansion updates the bridge's shared hidden-line and alignment state, then applies the same collapsed ranges to both child views.
There is no `InlineDiffView` migration step in this architecture.
Resolution: this retargets the integration plan to the actual Code Review editor host and makes hidden-line sync a bridge-owned shared-state update.
### 12. AI block-list and inline banner v2 deferral
AI block-list and inline banner integration are out of scope for v1:
- `app/src/ai/blocklist/inline_action/code_diff_view.rs` continues constructing inline diffs.
- `InlineBanner` continues using the existing inline rendering path.
- The v1 settings helper text names Code Review only.
- Telemetry does not claim AI block-list or inline banner adoption.
V2 can extend the same `DiffLayout` setting to those surfaces after the Code Review architecture is validated.
Resolution: this addresses the request to start by only using side-by-side in the Code Review panel.
### 13. Comment threads
`app/src/code_review/comments/` anchors comment threads on `EditorLineLocation`. Comments stay attached to those locations and render under the targeted line. The integration:
- The renderer for a side-by-side row checks each comment's side, captured from existing comment metadata such as `CommentSide` in `app/src/ai/agent/action.rs`, and renders the thread under the matching pane's row.
- The opposite pane shows a small marker glyph in its gutter at the same row to indicate that the other side has a thread there. The marker is non-interactive in this spec.
- Multi-line comment ranges that span both deleted and added regions stay on the side they were authored against.
Because both panes read the bridge's shared `HunkAlignment`, comment placement uses the shared row map and does not need ad hoc coordinate conversion between unrelated diff models.
### 14. Telemetry
Add a `CodeReviewTelemetryEvent::DiffLayoutChanged { from: DiffLayout, to: DiffLayout }` variant in `app/src/code_review/telemetry_event.rs`. `settings_view/code_page.rs` emits the event when the setting changes.
The Code Review host may emit a separate render-applied event if product wants adoption-by-opened-diff metrics, but v1 needs a single owner for the setting-change event to avoid duplicate telemetry.
Resolution: Settings -> Code is the telemetry owner for layout changes. Code Review render code should not emit a second setting-change event.
### 15. Accessibility
Side-by-side adds the following accessibility requirements:
- VoiceOver: the bridge exposes two logical regions with accessible labels: "Original" for baseline and "Modified" for the post-diff pane.
- The modified pane is the only edit-focused region and the only region with a cursor.
- The baseline pane can receive focus for selection/copy if the platform accessibility API can express that without exposing edit actions.
- Aligned rows announce as a single logical row when read together: "Original: <text>; Modified: <text>".
- Keyboard navigation: `Tab` reaches the modified edit region; baseline focus is selection/copy only. `Cmd+Option+Left/Right`, if implemented, cycles the logical pane focus without moving edit ownership away from modified.
- Color contrast: gap-row backgrounds use a dedicated theme token, `diff.gap.background`, that meets 3:1 contrast against the editor background in both light and dark themes.
Open risk: if the platform accessibility tree cannot represent the two bridged editor views with acceptable screen-reader behavior, implementation must escalate before shipping side-by-side beyond dogfood.
Resolution: accessibility coverage remains explicit for the two editor views and their bridge wrapper.
## Test plan
### Unit tests
- `app/src/code/hunk_alignment_tests.rs`:
- Empty diff: row_map contains matching `Baseline(n)` / `Modified(n)` entries for every context row, no gaps.
- Pure addition: rows for the added section are `(Gap { after_row }, Modified(m))`; the baseline view receives matching gap metadata.
- Pure deletion: rows for the deleted section are `(Baseline(b), Gap { after_row })`; the modified view receives matching gap metadata.
- Collapsed modification: `Context Delete Delete Add Add Context` produces four aligned rows: one context row, two paired modification rows, and one context row.
- Context inside a delete/add sequence resets the pairing window.
- Multi-hunk file: alignment composes correctly across hunks separated by unchanged context.
- Large diff (5,000 lines, 200 hunks): completes in under 50ms.
- `app/src/code/editor/side_by_side_bridge_tests.rs`:
- Pane content for `DiffType::Update`: baseline view holds pre-diff content with delete decorations; modified view holds post-diff content with add decorations.
- Pane content for `DiffType::Create`: baseline view is empty with gaps; modified view holds the new file content.
- Pane content for `DiffType::Delete`: modified view is empty with gaps; baseline view holds the original content.
- `apply_diffs_if_any` is not used when `DiffLayout::SideBySide` is active.
- Removed lines render as selectable rows in the baseline view and gaps in the modified view.
- Added lines render as editable-buffer rows in the modified view and gaps in the baseline view.
- Gap rows have the same rendered height on both sides.
- `app/src/code/editor/side_by_side_interaction_tests.rs`:
- The baseline child view is read-only and does not expose an edit cursor.
- The modified child view is the only side registered with `FileModel`.
- Baseline selection copies selectable content, including deleted-line ranges.
- Cursor movement affects the modified side only.
- Layout switch from `Inline` to `SideBySide` and back preserves scroll position to within one row.
- `app/src/code/editor/side_by_side_scroll_tests.rs`:
- Wheel delta updates the bridge scroll anchor and reveals the corresponding row in both panes.
- Cursor move on modified scrolls baseline to the corresponding row.
- Cursor on a `(Gap { after_row }, Modified(m))` row (pure add) scrolls baseline to the next surrounding context line.
- Recursive scroll updates are suppressed by the bridge.
### Integration tests
- `app/src/settings_view/code_page_tests.rs`:
- The Code page renders `DiffLayoutWidget` when `SideBySideDiffLayout` is enabled.
- The widget is hidden when the runtime flag is disabled.
- Selecting "Side by side" writes `code.editor.diff_layout = "side_by_side"`.
- The helper text names Code Review only.
- `app/src/code_review/code_review_view_tests.rs`:
- Single-file diff in side-by-side renders both `CodeEditorView` children inside the bridge.
- Multi-file diff: each file's editor honors the same layout.
- Setting flip while open rebuilds every visible editor into the selected layout and preserves scroll.
- Find-in-diff matches both child editor views.
- Hunk navigation (`f` / `F`) advances the focused hunk on both panes simultaneously while cursor remains on the modified side.
- Comment thread on a baseline-side line renders under the baseline pane's row; modified pane shows the gutter marker.
- Hidden-line expansion updates both child views from the bridge's shared alignment model.
### Accessibility validation
- Snapshot tests assert the accessible labels "Original" and "Modified" for side-by-side panes inside the bridge.
- Keyboard tests cover `Tab` and any pane-switching shortcut selected during implementation.
- Tests assert baseline focus does not expose edit actions or an insertion cursor.
- Theme tests assert `diff.gap.background` meets 3:1 contrast against editor backgrounds in light and dark themes.
- Manual screen-reader smoke test on macOS VoiceOver reads a paired modification row as one logical original/modified row.
### Manual smoke test
- macOS, M1 MacBook Air, dogfood build with `SideBySideDiffLayout` enabled:
- Open Settings -> Code. Change "Diff layout" from "Inline" to "Side by side" and confirm the visible Code Review diff refreshes within 200ms.
- Open Code Review with a 200-file diff. Confirm the active diff renders in two bridged editor views.
- Scroll wheel on each pane. Confirm both panes scroll together without jitter.
- Drag-select on the baseline pane. Confirm selectable content copies, including deleted-line ranges.
- Drag-select on the modified pane. Confirm selection stays in the modified pane.
- Confirm the cursor appears only in the modified pane.
- Cmd-A on each pane. Confirm only that pane's selectable content is selected.
- Resize the window narrow enough that side-by-side is cramped. Confirm horizontal scrollbars on each pane behave independently and the divider stays at 50%.
- Open an AI block-list embedded diff and an inline banner diff. Confirm both still render inline in v1.
- Linux (Ubuntu 24.04), Windows 11: repeat the settings toggle smoke test on each platform to confirm rendering and keybindings.
### Compile-parity checklist
Every site that destructures `DisplayMode` in a `match` must compile after the change. The current call sites include:
- `app/src/code/diff_viewer.rs`: trait helpers; unchanged because `DiffLayout` is a new orthogonal axis.
- `app/src/ai/blocklist/inline_action/code_diff_view.rs`: unchanged in v1; continues inline rendering.
- `app/src/ai/blocklist/block/view_impl/output.rs`: match on `DisplayMode::FullPane`; unchanged.
Every Code Review site that assumes one editor state should be checked. Existing write, save, cursor, and accept/reject paths should keep targeting the modified view. Rendering, comments, find, selection, and accessibility call sites should route through the bridge when side-by-side is active.
## Open questions
1. State management shape: do we keep `CodeReviewEditorState` as one slice with `baseline_sub_state` and `modified_sub_state`, or split into two parallel `CodeReviewEditorState` slices? Single-slice keeps the existing Code Review reducer signature; split-slice cleanly separates the two views' lifecycles.
2. Diff result ownership: does the bridge wrapper own the diff state directly, or is the diff state hoisted into the parent Code Review container? Bridge-owned diff is encapsulated; container-owned diff is reusable by other Code Review consumers.
3. Find state UX: when "Next match" crosses panes, does focus jump match-by-match, or does it stay in one pane until all matches there are visited? Confirm with @kevinyang372 whether this is designed behavior or a bridge-internal detail.
4. Comment thread interaction with the opposite-pane gutter marker (Change 13) is non-interactive in this spec. Whether the marker should be clickable is a UX call for the Code Review SME and a candidate follow-up.
5. The segmented-control primitive used by `DiffLayoutWidget` needs SME confirmation. The current spec references existing settings segmented controls as precedent; the actual primitive name and import path should be confirmed during implementation review.
6. Resizable panel split is out of scope for the first ship per product Non-goals. Whether to revisit this later depends on telemetry and user feedback after the initial release.
## Revision notes
- v4 (this revision): narrowed v1 scope to Code Review only, deferred AI block-list and inline banner to v2, restored the two `CodeEditorView` plus bridge-wrapper architecture, clarified right-pane-only editing and cursor invariants, replaced ambiguous `line_index` with `RowIndex`, retargeted integration to `CodeReviewEditorState` / `LocalCodeEditorView`, and updated open questions for the confirmed bridge direction.
+70
View File
@@ -0,0 +1,70 @@
# GH9816: Configurable code editor line number modes
## Summary
Add a configurable line numbering mode for Warp code editors so users can choose Absolute or Relative line numbers. The setting is independent of Vim mode, defaults to todays absolute numbering, and makes Vim-style vertical motion counts easier to read when users choose relative numbering.
## Problem
Warp code editors currently show only absolute line numbers. Vim users commonly rely on relative-style line numbers to choose motions like `5j` and `12k` without mentally subtracting the current line from nearby line numbers. Because the existing input editor surfaces do not show line-number gutters, this feature should improve the code editor without implying a new gutter in command input.
## Goals
1. Let users choose how line numbers are displayed in code editor gutters.
2. Preserve the current absolute numbering behavior by default.
3. Make relative mode update immediately as the active cursor line changes.
4. Keep the setting usable by all code editor users, not only users who enable Vim keybindings.
5. Avoid adding line numbers to command input editors or rich-text notebook editors as part of this change.
## Non-goals
1. Do not add line-number gutters to the terminal input editor, AI input editor, or other command-entry surfaces.
2. Do not add Vim `:set number`, `:set relativenumber`, or `:set norelativenumber` commands in this iteration.
3. Do not change Vim motion behavior, cursor movement, selections, search, find-references, or diff navigation.
4. Do not change whether a particular code editor surface chooses to show or hide its gutter; the new setting only affects gutters that already render line numbers.
5. Do not redesign the gutter width, diff hunk controls, hidden-section controls, or inline review comment controls beyond the minimum needed to display the selected numbering mode.
## Figma
Figma: none provided. The feature reuses the existing code editor gutter and settings UI patterns.
## Behavior
1. Warp exposes a line numbering mode setting for code editors with exactly two choices:
- **Absolute**: each line shows its absolute, one-based line number. This is the current behavior and the default for all users.
- **Relative**: the active cursor line shows its absolute, one-based line number; every other visible line shows the absolute distance in lines from the active cursor line. Warp does not expose any additional line-number option in this iteration.
2. The setting is available from Settings under the Text Editing area, near the existing code/text editing controls. It is not nested under the Vim-mode toggle and remains visible whether Vim keybindings are enabled or disabled.
3. The setting persists like other public editor settings and is restored for future Warp windows and sessions. If the user has not chosen a value, Warp behaves exactly as it does today: Absolute mode.
4. Changing the setting updates all currently open code editor gutters without requiring the user to reopen files, restart Warp, toggle Vim mode, or refocus the editor.
5. Absolute mode is behaviorally identical to the current code editor gutter:
- The first file line displays `1`, the second displays `2`, and so on.
- Code editor surfaces that start numbering from a caller-provided starting line continue to use that starting line.
- Hidden sections, diff hunk controls, and gutter action buttons behave as they do today.
6. Relative mode uses the active cursor line as the origin while keeping the active line absolute:
- If the cursor is on line 10, line 10 displays `10`, line 9 displays `1`, line 11 displays `1`, line 5 displays `5`, and line 22 displays `12`.
- Moving the cursor, clicking another line, selecting text, or using keyboard navigation recomputes the displayed distances immediately.
- Non-active relative distances are always positive integers; lines above and below the cursor both show positive distances.
- In normal code editor surfaces, Relative mode uses the editors current primary selection head even when the editor has just opened or focus temporarily moves elsewhere; losing focus does not force normal code editor gutters back to Absolute mode.
7. For multiple cursors or multiple selections, the active cursor line is the primary selection head used by the editor for cursor-position reporting. The gutter uses that single active line as the relative origin until a future design intentionally supports multiple relative origins.
8. For visual selections, the active line remains the selection head, not the selection anchor or the full selected range. The displayed numbers update as the selection head moves.
9. The line number mode is independent of Vim mode:
- Users can choose Relative before enabling Vim keybindings.
- Enabling or disabling Vim keybindings does not reset or hide the chosen line numbering mode.
- Vim status bar and clipboard settings remain separate from line numbering.
10. Code editor line numbers correspond to logical file lines, not soft-wrap rows. A long line that visually wraps still has one line number, and wrapped continuation rows do not introduce extra relative counts.
11. Hidden/collapsed code regions keep their existing hidden-section gutter affordances. The hidden section itself does not need to display a relative count for every hidden line.
12. Diff and review gutters keep their current affordances:
- Diff and review editors that are not currently focused continue to show absolute line numbers, even when the global code editor setting is Relative.
- When a diff or review editor is focused and Relative is selected, current-buffer lines across that editor that already display a line number use Relative mode from the active cursor line.
- Removed/temporary diff lines that currently omit a line number continue to omit one unless a separate diff design changes that behavior.
- Diff hunk buttons, comment buttons, hover hit targets, and collapse/expand interactions continue to work.
13. The visual style of line numbers remains consistent with the existing gutter: same font family, size, colors, selection behavior, and alignment unless a small width or alignment adjustment is necessary to prevent relative values from clipping.
14. The gutter reserves enough width for the largest value that can appear in the current mode:
- Absolute must fit the largest absolute line number for the surface.
- Relative must fit both the active lines absolute number and the largest visible relative distance where practical, and must never overlap editor text or gutter controls.
15. Command input editors, terminal prompt editors, AI input editors, and rich-text notebook editors do not show line numbers after this change. Their Vim status indicators and Vim keybindings continue to work as they do today.
16. The settings UI is searchable with terms such as `line number`, `relative line`, `vim`, and `gutter`.
17. If a settings file contains an invalid line numbering value, Warp falls back to Absolute mode using the existing settings validation/error behavior rather than failing to render editors.
## Success criteria
1. A new user or upgraded user with no explicit setting sees the same absolute code editor line numbers as before.
2. Selecting Relative mode shows the active cursor lines absolute number and distances on surrounding numbered code editor lines.
3. Moving the cursor with mouse, arrow keys, search, Vim motions, or goto-line updates relative gutters immediately.
4. The setting is visible and usable when Vim keybindings are disabled.
5. Enabling or disabling Vim keybindings does not change the selected line numbering mode.
6. Terminal input and AI input surfaces still do not render line-number gutters.
7. Diff hunk controls, inline comment buttons, hidden-section controls, and find-references anchoring still work in code editors with each line numbering mode; inactive diff/review editors continue to show absolute line numbers until that editor is focused.
## Validation
1. Manually verify a multi-line file in the code editor with Absolute and Relative modes selected.
2. In Relative mode, move the cursor above and below visible lines using mouse, arrow keys, goto-line, and Vim motions; verify displayed values update correctly.
3. Verify the setting persists after closing and reopening Warp or reloading settings.
4. Verify the setting remains visible when Vim mode is disabled and that toggling Vim mode does not reset it.
5. Verify terminal command input, AI input, and rich-text notebook editors do not gain line-number gutters.
6. Verify code review/diff editors still show diff decorations and gutter buttons correctly in both modes, show absolute line numbers while the diff/review editor is not focused, and apply Relative numbering across the focused editor.
+122
View File
@@ -0,0 +1,122 @@
# GH9816: Tech Spec — Configurable code editor line number modes
## Context
The product behavior is specified in `specs/GH9816/product.md`. The implementation should add a persistent editor setting and apply it to code editor gutters only.
- `app/src/settings/editor.rs:132` defines `AppEditorSettings`, including `vim_mode`, `vim_unnamed_system_clipboard`, and `vim_status_bar` under `text_editing.*`. This is the right settings group for an independent code/text editing line-number mode.
- `app/src/settings/init.rs:53` registers `AppEditorSettings`, so adding a field to that settings group automatically participates in normal startup registration.
- `app/src/settings_view/features_page.rs (2491-2690)` builds the Text Editing category. Today it includes `AutocompleteSymbolsWidget` and conditionally `VimModeWidget`.
- `app/src/settings_view/features_page.rs (5763-5962)` renders `VimModeWidget` and its nested Vim-only subsettings. The new line number mode should not be nested in this widget because the maintainer explicitly called for a setting independent of Vim settings.
- `app/src/code/editor/view.rs (46-245)` defines `CodeEditorViewDisplayOptions`, including `show_line_numbers` and `starting_line_number`.
- `app/src/code/editor/view.rs (1041-1239)` builds `LineNumberConfig` from appearance settings and passes it when line numbers are enabled.
- `app/src/code/editor/view.rs (2068-2267)` creates `EditorWrapper` with `line_number_config`, diff status, saved comments, and gutter behavior.
- `app/src/code/editor/element.rs (277-476)` defines `LineNumberConfig` and `EditorWrapper`.
- `app/src/code/editor/element.rs (500-790)` builds gutter elements from visible editor blocks. Current absolute display is computed with `line_count.as_usize() + line_number_config.starting_line_number.unwrap_or(1)`.
- `app/src/code/editor/element.rs (1048-1247)` renders the final gutter text in `render_gutter_element`.
- `app/src/code/editor/view.rs (1525-1724)` exposes cursor helpers such as `cursor_lsp_position`, `cursor_head_offset`, and offset-to-position conversion that can be used to determine the active cursor line.
- `app/src/terminal/input/common.rs:44`, `app/src/terminal/input/classic.rs (1-220)`, `app/src/terminal/input/universal.rs (1-200)`, and `app/src/editor/view/mod.rs (8546-8745)` show terminal input editors render Vim status and editor content but no line-number gutter. They should remain untouched except for regression testing.
- `app/src/notebooks/editor/view.rs (2466-2664)` renders the rich-text notebook editor with `RichTextElement` and explicitly does not support Vim; it also does not use the code editor gutter.
## Proposed changes
### 1. Add a persisted line number mode setting
In `app/src/settings/editor.rs`, add a new enum near the existing cursor/Vim editor enums:
- `CodeEditorLineNumberMode::Absolute`
- `CodeEditorLineNumberMode::Relative`
Derive the same traits used by nearby public settings enums: `Clone`, `Copy`, `Debug`, `Default`, `Eq`, `PartialEq`, `Deserialize`, `Serialize`, `Sequence`, `schemars::JsonSchema`, and `settings_value::SettingsValue`. Use `#[schemars(rename_all = "snake_case")]` and make `Absolute` the default.
Add a setting to `define_settings_group!(AppEditorSettings, settings: [...])`:
- field name: `code_editor_line_number_mode`
- type: `CodeEditorLineNumberMode`
- default: `CodeEditorLineNumberMode::default()`
- supported platforms: `SupportedPlatforms::ALL`
- sync: `SyncToCloud::Globally(RespectUserSyncSetting::Yes)`
- private: `false`
- TOML path: `text_editing.code_editor_line_number_mode`
- description: `How line numbers are displayed in code editors.`
Add small helpers on the enum:
- `dropdown_item_label(&self) -> &'static str` returning `Absolute` and `Relative`
- optional `search_terms()` or a widget-level search string that covers `line number relative vim gutter`
### 2. Add the settings UI dropdown
In `app/src/settings_view/features_page.rs`:
1. Import `CodeEditorLineNumberMode` and the generated setting type, likely `CodeEditorLineNumberModeSetting` or the actual generated name from `define_settings_group!`.
2. Add `SetCodeEditorLineNumberMode(CodeEditorLineNumberMode)` to `FeaturesPageAction`.
3. No new dedicated telemetry event is required for this setting in this iteration; the settings action should use the existing `FeaturesPageAction` telemetry path like other setters.
4. Add action handling that writes the setting:
- `AppEditorSettings::handle(ctx).update(ctx, |settings, ctx| report_if_error!(settings.code_editor_line_number_mode.set_value(*mode, ctx)))`
- Notify after the write so settings UI and open editors repaint.
5. Add a `code_editor_line_number_mode_dropdown: ViewHandle<Dropdown<FeaturesPageAction>>` field to `FeaturesPageView`.
6. Initialize it with `ctx.add_typed_action_view(Dropdown::new)` and call a helper such as `Self::update_code_editor_line_number_mode_dropdown(...)`.
7. Subscribe to `AppEditorSettings::handle(ctx)` changes or update the dropdown in the existing AppEditorSettings subscription if one is added. The selected item must stay in sync when settings change outside the dropdown, such as through `settings.toml`.
8. Add a `CodeEditorLineNumberModeWidget` to the Text Editing category in `build_page`, adjacent to `AutocompleteSymbolsWidget` and before/after `VimModeWidget`. This ensures it is not conditional on `vim_mode`.
9. Render the widget with `render_dropdown_item`, label it `Code editor line numbers:` or `Line numbering:`, pass the local-only/sync indicator for the generated setting, and point it at `view.code_editor_line_number_mode_dropdown`.
### 3. Pass the selected mode into code editor line-number rendering
Extend `LineNumberConfig` in `app/src/code/editor/element.rs`:
- add `mode: CodeEditorLineNumberMode`
- add `active_line_number: Option<LineCount>` or `active_line_index: Option<usize>`
In `CodeEditorView::line_number_config` (`app/src/code/editor/view.rs (1041-1239)`):
1. Read `let editor_settings = AppEditorSettings::as_ref(ctx)`.
2. Set `mode: *editor_settings.code_editor_line_number_mode.value()`.
3. Compute the active cursor line from the primary selection head so normal code editors can keep using Relative mode immediately after opening and after temporary focus changes:
- Use `self.model.as_ref(ctx).selections(ctx).first().head`.
- Convert the head to a buffer point with the code editor buffer.
- Convert that row to the same `LineCount` convention used by `model.start_line_index(&**block)`.
- Prefer keeping this conversion in a helper on `CodeEditorView` or `CodeEditorModel`, such as `active_cursor_line_for_line_numbers(&self, ctx) -> Option<LineCount>`, to avoid duplicating offset/index assumptions in the wrapper.
4. Also pass whether the editor is currently focused into `LineNumberConfig`. Normal code editors should not require focus to display Relative mode, but diff/review editors should still require editor focus before applying relative line numbers.
5. Keep returning `None` when `show_line_numbers` is false.
### 4. Compute the displayed gutter value per line
In `EditorWrapper::gutter_elements` (`app/src/code/editor/element.rs (500-790)`), replace the current absolute-only `current_line` computation with a helper:
```
fn display_line_number(
line_count: LineCount,
config: &LineNumberConfig,
) -> usize
```
The helper should implement:
- `absolute = line_count.as_usize() + config.starting_line_number.unwrap_or(1)`
- `relative = config.active_line_number.map(|active| active.as_usize().abs_diff(line_count.as_usize()))`
- Absolute mode returns `absolute`.
- Relative mode returns `absolute` when `Some(line_count) == active_line_number`, otherwise `relative.unwrap_or(absolute)`, so editors without an active cursor fall back gracefully.
Use the returned value as the `current_line` passed into `render_gutter_element`.
Important indexing detail: the current codes absolute calculation implies `line_count` is zero-based for display purposes. The implementation must verify the active cursor conversion uses the same convention. A small unit test should cover this directly to avoid off-by-one bugs.
### 5. Keep non-number gutter elements unchanged
Do not display relative numbers for:
- temporary removed diff blocks, which currently pass `None` to `render_gutter_element`
- hidden-section controls, which use `construct_expand_hidden_section_gutter_element`
- surfaces where `line_number_config` is `None`
For diff and review editors, preserve absolute numbering unless the editor is focused and Relative is selected. When focused, numbered current-buffer lines across the editor should apply the selected Relative display from the active cursor line so review context outside the changed hunk uses the same relative origin. Normal code editor surfaces without diff status should use the retained primary selection head as the relative origin even if the editor is not currently focused. Diff hunk and comment interactions should continue to use `EditorLineLocation` and `line_range` exactly as they do today; only the text shown inside eligible numbered gutter elements changes.
### 6. Width and alignment
The existing `GUTTER_WIDTH` is fixed and currently supports absolute numbers plus gutter controls. Do not change it unless testing shows three-digit or larger relative values clip in common cases. Relative mode still shows the active lines absolute number, so any width calculation must account for both absolute active-line values and relative non-active-line distances. If adjustment is needed, prefer the smallest safe change within `app/src/code/editor/element.rs`, and verify diff/comment buttons still fit.
### 7. Do not wire terminal input or notebook editors
No changes are needed in `app/src/terminal/input/*`, `app/src/editor/view/mod.rs`, or `app/src/notebooks/editor/view.rs` to render line numbers. The new setting can live in shared editor settings, but only `CodeEditorView` should consume it.
## End-to-end flow
1. User selects `Relative` from Settings > Text Editing > line numbering.
2. `FeaturesPageAction::SetCodeEditorLineNumberMode(Relative)` writes `AppEditorSettings.code_editor_line_number_mode`.
3. Open `CodeEditorView` instances observe settings changes and re-render.
4. `CodeEditorView::line_number_config` includes the selected mode and active cursor line.
5. `EditorWrapper::gutter_elements` computes each visible current-buffer lines displayed number from the mode.
6. Cursor movement emits the existing selection/content events, causing the view to notify and repaint; relative gutter values update on the next render.
## Risks and mitigations
1. **Off-by-one errors between buffer rows and gutter `LineCount`.** Mitigate with focused tests for cursor on first, middle, and last lines in Relative mode, and with a code comment documenting the chosen convention.
2. **Settings UI accidentally scopes the setting under Vim.** Mitigate by implementing a separate Text Editing widget rather than adding it to `VimModeWidget`s conditional subgroup.
3. **Open editors may not repaint when the setting changes.** `CodeEditorView::new` already subscribes to appearance and font settings; add or reuse an `AppEditorSettings` observation/subscription if necessary so setting changes notify code editor views.
4. **Diff/review gutter regression.** The implementation touches the shared code editor wrapper used by code review surfaces. Mitigate with manual testing in a diff editor, keeping `EditorLineLocation` unchanged, and verifying inactive diff/review editors keep absolute numbering while focused editors apply Relative numbering across numbered current-buffer lines.
5. **Multi-cursor ambiguity.** The product spec defines the primary selection head as the relative origin. Mitigate by using `selections(ctx).first().head`, which matches existing cursor-position helpers.
## Testing and validation
1. Add or update code editor view/element tests to cover the number calculation helper:
- Absolute mode returns the same values as today.
- Relative mode returns absolute on the active line and positive distances for lines above/below.
- Missing active cursor line falls back to absolute values.
- `starting_line_number` still affects absolute and relative active-line display without affecting non-active relative distances.
2. Add settings tests, if the existing settings test harness supports them, to verify `text_editing.code_editor_line_number_mode = "relative"` deserializes and invalid values fall back through normal settings validation.
3. Manually verify product invariants from `specs/GH9816/product.md`:
- Behavior 1-7 in a normal code editor.
- Behavior 8-10 with Vim disabled/enabled, visual selections, and soft-wrapped lines.
- Behavior 11 with hidden/collapsed code regions.
- Behavior 12 in code review/diff views with focused and unfocused sections, hidden sections, and inline comments.
- Behavior 15 in terminal input, AI input, and notebook editors.
4. Run the repositorys standard formatting/check flow for touched Rust files. At minimum, run targeted Rust tests for settings and code editor modules; if feasible, run the broader app test command used by the repository before the implementation PR.
## Parallelization
After the settings enum name is settled, implementation can split across two agents:
1. Settings/UI agent: adds the `AppEditorSettings` enum/field, settings dropdown, action handling, and settings tests.
2. Editor rendering agent: adds `LineNumberConfig` mode/origin support, display calculation helper, code editor tests, and manual diff-editor validation.
These streams should coordinate on the exact enum and field names before parallel edits to avoid merge conflicts.
## Follow-ups
1. Consider Vim command support (`:set number`, `:set relativenumber`) only after the settings-based behavior ships.
2. Consider adding line-number mode telemetry only if product analytics need to measure adoption; this spec does not require new telemetry.
3. Revisit gutter width if future designs add more gutter affordances or larger inline controls.
@@ -0,0 +1,80 @@
# Ask-User-Question Autonomy Speedbump
Linear: [QUALITY-512](https://linear.app/warpdotdev/issue/QUALITY-512/add-ask-user-question-permission-speedbump)
## 1. Summary
When Agent Mode first uses the Ask Question tool on a local client, show a compact inline footer on the Ask-User-Question card that lets the user adjust the active execution profile's Ask Question permission. The footer uses a dropdown with the existing three permission values and links to the AI Autonomy settings page.
## 2. Problem
Users can control how often Agent Mode pauses to ask them questions, but that control is buried in settings. Ask Question is also most noticeable at the moment the agent pauses, so the relevant permission should be surfaced in context the first time the user encounters it. Existing autonomy speedbumps already teach file-read and command execution permissions in context; Ask Question should follow the same pattern.
## 3. Goals
- Surface Ask Question autonomy controls at the moment the tool first appears.
- Let the user update the active execution profile without leaving the conversation.
- Link to the AI Autonomy settings page for users who want the full settings UI.
- Show the nudge only once per local client install/profile state so it does not become noisy.
- Support both normal Ask Question cards and first-use auto-approve skipped Ask Question cases.
- Match the compact visual rhythm of existing autonomy speedbump footers.
## 4. Non-goals
- No new Ask Question permission values.
- No changes to the AI settings page layout.
- No changes to the active Ask-User-Question answer/skip flow.
- No per-conversation or per-repository overrides.
- No global reset UI for speedbumps.
## 5. User experience
### Trigger
The speedbump is seeded when all of the following are true:
- `FeatureFlag::AskUserQuestion` is enabled.
- Agent Mode autonomy is allowed for the workspace.
- The local one-shot setting `should_show_agent_mode_ask_user_question_speedbump` is `true`.
- The completed agent output contains an Ask-User-Question action.
The trigger intentionally includes auto-approve conversations. If Ask Question is skipped because auto-approve is active, the first skipped Ask Question card can still show the footer so the user can discover and adjust the setting.
### One-shot semantics
The one-shot flag is local-only and is not synced through Warp Drive. The flag is consumed once the footer is successfully attached to an Ask-User-Question view. If the agent output is processed before the view exists, the flag remains `true` and is consumed later when the matching view is created and the footer can actually be installed.
The flag is consumed even if the user does not interact with the footer. This keeps the behavior to a single first-use display: if the user notices it and changes the setting, great; if they ignore it, the nudge is still considered displayed and will not reappear on future cards.
### Card placement and layout
- The footer renders as the bottom strip of the Ask-User-Question card.
- The footer is compact, with reduced vertical padding and a smaller dropdown so it feels similar to existing read-file/checkmark speedbumps.
- When the footer is present, the main Ask-User-Question card content does not keep rounded bottom corners; the footer owns the bottom radius so the combined card reads as one attached surface.
- The footer appears for both collapsed and expanded completed cards.
### Footer content
- Left side: short explanatory text and a dropdown.
- Right side: `Manage AI Autonomy permissions` link.
- The settings link opens the AI settings page scoped to the Autonomy section.
### Dropdown behavior
The dropdown options match the settings page order:
- `Never ask``AskUserQuestionPermission::Never`
- `Ask unless auto-approve``AskUserQuestionPermission::AskExceptInAutoApprove`
- `Always ask``AskUserQuestionPermission::AlwaysAsk`
The selected value reflects the active execution profile's current `ask_user_question` permission. Selecting an option immediately updates the active profile, emits telemetry, hides the footer on the current card, and leaves the local one-shot flag consumed.
### Overlay behavior
The dropdown menu renders above surrounding block content. Its options are clickable even when the card is embedded in terminal rich content, and clicking the dropdown underlay dismisses the menu without terminal text selection intercepting the interaction.
## 6. Success criteria
- First normal Ask Question invocation on a local client shows the footer on the resulting card.
- First auto-approve skipped Ask Question invocation also shows the footer on the resulting card.
- The local one-shot flag is not consumed if no matching view exists yet.
- The local one-shot flag is consumed once the footer is attached to the matching view.
- Ignoring the footer does not cause it to reappear on future Ask Question cards.
- Selecting any dropdown option updates the active profile immediately and hides the footer on the current card.
- The dropdown selection stays in sync with external active-profile changes while the footer exists.
- The settings link opens AI settings at the Autonomy section.
- Dropdown options can be clicked and dismissed reliably above terminal/block content.
- Collapsed and expanded cards with the footer render as one attached card with no double-rounded seam.
## 7. Validation
Automated validation:
- `cargo check -p warp`
- `cargo check --all-targets -p warp`
- `cargo nextest run --no-fail-fast -p warp ask_user_question`
- `cargo fmt --all`
- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`
- `git --no-pager diff --check`
## 8. Manual QA checklist
- Reset `ShouldShowAgentModeAskUserQuestionSpeedbump` locally to `true`.
- Trigger Ask Question in a normal conversation and confirm the compact footer appears on the card.
- Trigger Ask Question in auto-approve mode and confirm the skipped card can show the same footer on first display.
- Re-trigger Ask Question after ignoring the footer and confirm the footer does not reappear once the flag has been consumed.
- Select each dropdown option and confirm the active profile's setting changes in AI settings.
- Confirm selecting a dropdown option hides the footer.
- Open the dropdown near other rich content and confirm its menu appears above other content, options are clickable, Escape/outside click dismisses it, and terminal selection does not intercept clicks.
- Confirm collapsed and expanded cards have flattened attached corners with the footer present.
@@ -0,0 +1,86 @@
# Ask-User-Question Autonomy Speedbump — Technical Notes
Linear: [QUALITY-512](https://linear.app/warpdotdev/issue/QUALITY-512/add-ask-user-question-permission-speedbump)
Product spec: `specs/QUALITY-512-ask-user-question-speedbump/PRODUCT.md`
## 1. Overview
This change adds a one-shot Ask-User-Question autonomy speedbump to the existing `AIBlock` speedbump infrastructure. The speedbump installs a compact footer into `AskUserQuestionView`, backed by a `Dropdown<AIBlockAction>` that updates the active execution profile's `ask_user_question` permission.
## 2. Key files
- `app/src/settings/ai.rs` — defines `should_show_agent_mode_ask_user_question_speedbump` as a private local-only setting with `SyncToCloud::Never`.
- `app/src/ai/blocklist/block.rs` — seeds the new speedbump variant, owns the dropdown view, syncs the footer into `AskUserQuestionView`, handles dropdown actions, and consumes the one-shot flag after successful footer installation.
- `app/src/ai/blocklist/block/view_impl.rs` — renders the shared dropdown speedbump footer row.
- `app/src/ai/blocklist/inline_action/ask_user_question_view.rs` — owns the Ask-User-Question card chrome and renders the attached footer in completed/collapsed states.
- `app/src/server/telemetry/events.rs` — adds `ChangedAgentModeAskUserQuestionPermission`.
- `app/src/terminal/block_list_element.rs` — forwards covered mouse events to visible rich-content overlays so dropdown menus remain interactive.
- `crates/warpui_core/src/elements/dismiss.rs` — consumes underlay clicks for dismissable overlays that prevent interaction with other elements.
- `crates/warpui_core/src/elements/selectable_area.rs` — avoids starting terminal selection for mouse down events covered by higher-z-index overlays.
- `app/src/ai/blocklist/block_tests.rs` — covers permission index mapping, first Ask-User-Question action detection, and setting defaults/round-trip.
## 3. Setting
The new setting is intentionally local-only:
- Name: `should_show_agent_mode_ask_user_question_speedbump`
- Default: `true`
- Private: `true`
- Sync: `SyncToCloud::Never`
This keeps the speedbump display tied to the local client state. It also avoids cross-device races where one device could consume the onboarding display for another device.
## 4. Speedbump state
`AutonomySettingSpeedbump` now has:
- `ShouldShowForAskUserQuestion { action_id, shown }`
The `action_id` pins the footer to the Ask-User-Question action that triggered it. The `shown` field is set when the footer is attached, matching the broader speedbump pattern and making the attachment state explicit.
## 5. Trigger and one-shot consumption
`AIBlock::handle_complete_output` uses `first_ask_user_question_action_id(output)` to find the first Ask-User-Question action in the completed agent output. It seeds the speedbump when the feature flag is enabled, autonomy is allowed, and the local one-shot setting is still `true`.
Unlike the original design, auto-approve is not excluded. Auto-approve skipped Ask Question actions can seed the same first-use footer.
The local one-shot flag is not consumed at seed time. `sync_ask_user_question_speedbump_footer` returns `true` only after it finds a matching `AskUserQuestionView` and installs the footer. Callers then invoke `mark_ask_user_question_speedbump_as_shown` only on that successful path. If output completion happens before the view exists, the flag remains `true`; `handle_ask_user_question_stream_update` calls the same sync helper after creating/replacing the view and consumes the flag when installation succeeds.
## 6. Dropdown ownership and action flow
`AIBlock` owns `ask_user_question_speedbump_dropdown: Option<ViewHandle<Dropdown<AIBlockAction>>>`. The dropdown is created lazily and reused for the block lifetime.
Dropdown items dispatch `AIBlockAction::SetAskUserQuestionSpeedbumpPermission(permission)`. The handler:
- Resolves the active execution profile for the terminal view.
- Calls `AIExecutionProfilesModel::set_ask_user_question`.
- Emits `ChangedAgentModeAskUserQuestionPermission` with source `Speedbump`.
- Marks the local one-shot setting false idempotently.
- Clears the speedbump state and footer from the current Ask-User-Question view.
- Notifies the block so the footer hides immediately.
Profile model events refresh the dropdown selected index so external settings changes are reflected while the footer exists.
## 7. Footer rendering
The footer is threaded into `AskUserQuestionView` rather than wrapping the child view externally. This keeps the Ask-User-Question card in charge of its own border, radius, and layout.
`AskUserQuestionView` renders the footer as an attached bottom strip:
- Reduced vertical padding for a compressed speedbump height.
- Compact dropdown sizing.
- Bottom radius applied to the footer strip.
- Main card/header radius flattened at the bottom when a footer is present.
- Footer support for collapsed and expanded completed states.
This avoids a double-rounded seam between the card body and the speedbump footer.
## 8. Overlay event routing
The dropdown menu is rendered through WarpUI overlay infrastructure. A covered mouse event previously could be rejected by `BlockListElement` before the rich-content overlay received it, allowing terminal selection or surrounding content to intercept dropdown clicks.
The fix has three parts:
- `BlockListElement` forwards covered mouse events to visible rich-content views before returning.
- `SelectableArea` does not start a selection when a mouse down event is covered at its z-index.
- `Dismiss::prevent_interaction_with_other_elements` consumes mouse events that hit its underlay.
Together these keep dropdown options clickable and make outside-click dismissal reliable.
## 9. Telemetry
The new telemetry event is `ChangedAgentModeAskUserQuestionPermission` with fields:
- `src: AutonomySettingToggleSource`
- `new: AskUserQuestionPermission`
The speedbump path emits the event with `src = Speedbump` whenever the user selects a dropdown option.
## 10. Validation
Local validation:
- `cargo check -p warp`
- `cargo check --all-targets -p warp`
- `cargo nextest run --no-fail-fast -p warp ask_user_question`
- `cargo fmt --all`
- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`
- `git --no-pager diff --check`
## 11. Follow-ups
- Add deeper regression coverage for overlay routing if a convenient harness exists for rich-content overlays.
- Consider a shared compact dropdown speedbump component if future autonomy speedbumps need dropdowns.
- Consider an internal reset affordance for local-only onboarding speedbumps to simplify QA.
+79
View File
@@ -0,0 +1,79 @@
# PRODUCT — Orchestration Pill Bar
## Summary
When a user is working with an orchestrator agent that has spawned one or more child agents, Warp shows a horizontal "pill bar" above the agent view header listing the orchestrator and each child. Clicking a pill switches the active pane in place to that agent's conversation. When viewing a child agent, the pane title is replaced with a `[Parent] [Child]` breadcrumb path so the user can navigate back to the orchestrator from the same pane.
## Figma
Figma: https://www.figma.com/design/AsF5uAM6L5tUmc11vm9YSi (nodes `4073-19833`, `4073-17179`)
## Goals
- Make the orchestrator → child relationship discoverable from the pane header without opening a separate panel.
- Let the user move between an orchestrator and its children inside a single pane (no implicit splits, no new tabs).
- Keep the existing single-conversation agent view unchanged when no orchestration is in play.
## Non-goals (V1)
- Hover preview popover on a pill (deferred).
- Pin / unpin a child to keep its pane open as a split (deferred).
- 3-dot menu on a pill (Open in new pane / Open in new tab / Stop agent / Kill agent) — deferred.
- Drag-to-reorder pills.
- Any change to non-orchestration conversations.
## Behavior
1. The pill bar only appears in the **fullscreen agent view** (`AgentView` flag on, `agent_view_controller.is_fullscreen()`), and only when the new `OrchestrationPillBar` flag is enabled.
2. The pill bar is shown only when the **active conversation is the orchestrator** — i.e. the conversation that has child agents underneath it. When the user is viewing a child agent, the pill bar is replaced by breadcrumbs in the title (see (10)(14)). When there is no orchestration relationship at all, no pill bar is shown and the pane header renders exactly as before.
3. The pill bar is hidden when the orchestrator has zero children. It only appears once at least one child agent has been spawned.
4. Pill ordering is stable: the orchestrator is always the leftmost pill, followed by child pills in the order the orchestrator registered them (i.e. the order in which they were spawned). Sorting by the first exchange's start time is intentionally avoided because a child whose first exchange has not started yet would otherwise sort to the front and pop into a different position once it began streaming, reshuffling the bar. Pills do **not** reshuffle as their statuses update.
5. Each pill is a horizontal stadium-shaped chip containing:
- A circular avatar (16×16) on the left.
- A label on the right (truncated with an ellipsis past ~110px).
- Internal padding: 4px left of the avatar, 10px right of the label, 6px between avatar and label.
- Pills are 22px tall with a half-stadium corner radius (radius = height/2). Adjacent pills are spaced 6px apart.
6. The orchestrator pill uses the Warp `Oz` glyph on a cyan disc and is labelled with the orchestrator conversation's agent name, falling back to `"Orchestrator"` if no name is set.
7. Each child pill uses:
- A colored disc whose color is deterministic from the agent's name (hash → 6-color palette of `ansi_fg_blue/magenta/cyan/green/yellow/red`).
- The first letter of the agent's name (uppercase), in bold, on top of the disc.
- The agent's name as the label, falling back to `"Agent"` if unset.
Note this is temporary - we'll update this further later.
8. Pill states:
- **Selected** (the pill matches the active conversation): solid foreground background + inverted text color, label rendered in semibold. Cursor is the default arrow. Clicks are no-ops.
- **Hover / active click** (any non-selected pill): a slightly brighter neutral background; cursor becomes the pointing hand.
- **Idle** (non-selected, not hovered): the standard neutral pill background.
9. Clicking a non-selected pill switches the **current pane** to that pill's conversation in place. It does not split the pane or open a new tab. The newly active pill becomes Selected on the next render. After a click on a child pill, the pane header switches from showing the pill bar to showing breadcrumbs (see (10)).
10. While viewing a child agent (the active conversation has a parent), the pane header title area is replaced with a `[Parent] [Child]` breadcrumb path:
- Each crumb is a 24px-tall capsule with a 4px corner radius, 6px horizontal padding, and the same avatar treatment as pills (orchestrator uses Oz glyph + cyan disc; child uses deterministic-color disc + initial letter).
- The separator between crumbs is a `` chevron icon (16×16) in the standard sub-text color.
- The parent crumb's label is the parent conversation's title, falling back to its agent name, and finally to `"Orchestrator"`.
- The trailing (child) crumb is rendered with the brighter "main" text color, no hover, no click.
11. The parent crumb is interactive:
- Hover: applies a neutral hover background and switches to brighter "main" text color; cursor becomes pointing hand.
- Click: navigates the current pane back to the orchestrator. The pane header then switches from breadcrumbs back to showing the pill bar (with the orchestrator pill now Selected).
12. Hover state for both pills and the parent crumb persists across renders. Re-renders triggered by status updates, new exchanges, etc. must not zero out hover state mid-interaction.
13. Long agent names truncate with an ellipsis:
- Pill label: max 110px.
- Crumb label: max 220px.
14. The pill bar's vertical placement does **not** change the pane header title's vertical centering. The pane title and any header buttons (e.g. `ESC for terminal`) remain visually centered within the standard pane header height; the pill bar appears as a separate row below.
15. When the `OrchestrationPillBar` flag is off, none of the above renders. Existing behavior — including the parent-conversation navigation card from the prior orchestration UI — is preserved exactly.
16. The bar redraws when any of the following change for the orchestrator or its children: conversation status, new exchanges, the active conversation, conversation creation, conversation removal/deletion, or entering/exiting the agent view.
17. When entering or exiting the fullscreen agent view, hover state for all pills resets so a stale hover doesn't persist into the next view.
+132
View File
@@ -0,0 +1,132 @@
# TECH — Orchestration Pill Bar
See `PRODUCT.md` in this directory for user-visible behavior. This document covers implementation and validation only.
## Context
The pill bar lives inside the existing pane header chrome rendered for the fullscreen agent view. The header is built in `app/src/terminal/view/pane_impl.rs` (`render_terminal_pane_header`), which composes a 3-column row via `crate::pane_group::pane::view::header::components::render_three_column_header` and then optionally wraps it via `maybe_add_parent_navigation_card`. The wrapped element is returned to `PaneHeader::render` (`app/src/pane_group/pane/view/header/mod.rs`), which constrains the result to `PANE_HEADER_HEIGHT = 34.` for the standard `HeaderContent::Custom` path.
Relevant existing code:
- `app/src/terminal/view/pane_impl.rs (501-556)``maybe_add_parent_navigation_card`, the splice point where the pill bar gets injected below the standard header. Already wraps the header in a `Flex::column` for the pre-existing parent-conversation card.
- `app/src/terminal/view/pane_impl.rs (269-391)``render_header_title`, where the pane title is built. Breadcrumbs short-circuit this when the active conversation has a parent.
- `app/src/pane_group/pane/view/header/components.rs (152-213)``render_three_column_header`. The center column wraps the title in `Align::new(center_row).finish()` so the title vertically centers within the row's stretched height.
- `app/src/pane_group/pane/view/header/mod.rs:52``PANE_HEADER_HEIGHT = 34.` and the `ConstrainedBox::with_height(PANE_HEADER_HEIGHT)` wrap on `HeaderContent::Custom`.
- `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs` — already exposes `parent_conversation_id` and the existing `parent_conversation_navigation_card` used by the legacy orchestration UI.
- `app/src/ai/blocklist/history_model.rs``BlocklistAIHistoryModel` exposes `child_conversations_of`, `conversation`, and the events the pill bar subscribes to.
- `app/src/terminal/view.rs:25419` — existing handler stub for `TerminalAction::SwitchAgentViewToConversation`, calling `enter_agent_view_for_conversation` to navigate the same pane.
- `crates/warp_features/src/lib.rs``FeatureFlag` enum and `DOGFOOD_FLAGS`.
The feature is gated by a new `FeatureFlag::OrchestrationPillBar`. Existing `Orchestration` and `AgentView` flag behavior is preserved when the new flag is off.
## Proposed changes
### 1. Feature flag
Add `OrchestrationPillBar` to `FeatureFlag` in `crates/warp_features/src/lib.rs:725`. All new code paths gate on `FeatureFlag::OrchestrationPillBar.is_enabled()`.
### 2. New view: `OrchestrationPillBar`
New file `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs` exposes:
- `pub struct OrchestrationPillBar` — implements `View` with `Entity::Event = ()`.
- Holds `agent_view_controller: ModelHandle<AgentViewController>` and `mouse_states: HashMap<AIConversationId, MouseStateHandle>` for persistent per-pill hover state (per WARP.md's `MouseStateHandle` rule — inline `MouseStateHandle::default()` would silently break clicks).
- Subscribes to `BlocklistAIHistoryModel` for `UpdatedConversationStatus`, `AppendedExchange`, `SetActiveConversation`, `StartedNewConversation`, and to removal events to drop stale mouse states.
- Subscribes to `AgentViewController` for `EnteredAgentView` / `ExitedAgentView` to clear hover state across view transitions.
- A private `pill_specs(&self, app)` helper that:
- Resolves the active conversation, walks up to its orchestrator via `parent_conversation_id`.
- Returns `None` when the active conversation has a parent (child views render breadcrumbs instead) or when the orchestrator has no children.
- Builds an ordered list: orchestrator first, then children sorted by `first_exchange().start_time`.
- `render_pill(spec, mouse_state, app)` — builds a `Hoverable` whose closure rebuilds the pill on each render (selected vs hovered vs idle styling). Click dispatches `PaneHeaderAction::<TerminalAction, TerminalAction>::CustomAction(TerminalAction::SwitchAgentViewToConversation { conversation_id })`. The action wrapper is required because the pill bar lives inside the pane header chrome — `BackingView::handle_custom_action` unwraps it (mirrors `agent_view_back_button`).
- `render_avatar_disc` — renders the colored circle as a `Stack` of (1) a `ConstrainedBox(Container(bg + corner_radius))` and (2) a centered glyph (letter `Text` or `Icon`). The glyph is centered using nested `Flex::column` / `Flex::row` with both `MainAxisAlignment::Center` and `CrossAxisAlignment::Center` on each axis.
Module wiring: `pub mod orchestration_pill_bar;` in `app/src/ai/blocklist/agent_view/mod.rs` and a `pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar};`.
### 3. Breadcrumb rendering
Same file. `pub fn render_orchestration_breadcrumbs(agent_view_controller, parent_crumb_mouse_state, app) -> Option<Box<dyn Element>>`:
- Returns `None` unless the flag is on, the view is fullscreen, and the active conversation has a parent.
- Builds two `CrumbSpec`s (parent + active child) and wires them into a `Flex::row` with a `ChevronRight` icon separator. Crumbs share the same avatar treatment as pills.
- The parent crumb takes a caller-owned `MouseStateHandle` (must be a field on `TerminalView`, not constructed inline) and dispatches `SwitchAgentViewToConversation` on click. The trailing crumb has no `Hoverable` and no click handler.
We render breadcrumbs manually rather than reusing `crate::ui_components::breadcrumb` because the shared helper does not support a chevron separator or per-crumb avatars.
### 4. New `TerminalAction` variant
`app/src/terminal/view/action.rs`: add `SwitchAgentViewToConversation { conversation_id: AIConversationId }` plus a `Debug` arm. Distinct from `RevealChildAgent` because pill clicks must navigate the current pane in place rather than emit `Event::RevealChildAgent`, which the pane group treats as a request to spawn / reveal a separate pane.
`app/src/terminal/view.rs`: add a handler arm in `handle_action` calling `self.enter_agent_view_for_conversation(None, AgentViewEntryOrigin::ConversationListView, *conversation_id, ctx)`. Add the variant to the `update_agent_view_pane_header`-eligible action list around line 24393.
### 5. `TerminalView` field + construction
`app/src/terminal/view.rs`:
- Add `orchestration_pill_bar: ViewHandle<OrchestrationPillBar>` field on `TerminalView` (next to `agent_view_back_button`, ~2738).
- Construct in `TerminalView::new` alongside `agent_view_controller`. Subscribe to its no-op event so the parent view re-renders when the pill bar notifies (`ctx.subscribe_to_view(&orchestration_pill_bar, |_, _, _, ctx| ctx.notify())`).
### 6. Pane header wiring
`app/src/terminal/view/pane_impl.rs`:
- In `render_header_title`, short-circuit at the top: if `render_orchestration_breadcrumbs(self.agent_view_controller.as_ref(app), self.mouse_states.parent_conversation_header_link.clone(), app)` returns `Some(element)`, return it directly. Returning the element directly (instead of wrapping in `MainAxisSize::Min` Flex) is required: the breadcrumbs row internally uses `Shrinkable` children, and `render_three_column_header` already wraps the title in `Shrinkable + Clipped` which provides a finite main-axis constraint. A `MainAxisSize::Min` wrapper here would forward an infinite constraint and panic the inner `Shrinkable`.
- In `maybe_add_parent_navigation_card`, add an early branch for the new flag:
```rust
if FeatureFlag::OrchestrationPillBar.is_enabled()
&& FeatureFlag::AgentView.is_enabled()
&& self.agent_view_controller.as_ref(app).is_fullscreen()
{
let pinned_header = ConstrainedBox::new(header)
.with_height(PANE_HEADER_HEIGHT)
.finish();
let pill_bar = ChildView::new(&self.orchestration_pill_bar).finish();
return Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(pinned_header)
.with_child(pill_bar)
.finish();
}
```
Pinning the header to `PANE_HEADER_HEIGHT` is **load-bearing**, not cosmetic. `Flex::column` passes `max.y = INFINITY` to its non-flex children (`SizeConstraint::child_constraint_along_axis` in `crates/warpui_core/src/presenter.rs:794`). Without the explicit `ConstrainedBox`, the inner `Align` in `render_three_column_header` collapses to the title's small line-box height and the outer row's `CrossAxisAlignment::Stretch` paints children at offset 0 (top) — the title visibly clings to the top of the row instead of being centered. See `crates/warpui_core/src/elements/flex/mod.rs (467-473)` for the cross-axis offset math and `align.rs (77-89)` for Align's infinite-constraint fallback.
### 7. Mouse state wiring
The breadcrumb's parent crumb needs a persistent `MouseStateHandle`. We reuse `TerminalViewMouseStates::parent_conversation_header_link` (already a field on `TerminalView` for the legacy parent-card link), threaded through `render_orchestration_breadcrumbs`. Per-pill mouse state lives on the pill bar view itself (in its `mouse_states` HashMap, ensured/cleared on history events).
## Testing and validation
### Manual / dogfood verification
To verify in a local build, run an orchestrator (e.g. via `/orchestrate`) that spawns at least two child agents and walk through the invariants from `PRODUCT.md`:
- (1)(3): Confirm the pill bar appears only on the orchestrator's view in fullscreen agent mode and disappears when there are zero children or when not in fullscreen.
- (4): Cause status changes on multiple children (commands finishing, in-progress flips). Pill order must not reshuffle.
- (5)(8): Visual check vs Figma (avatar size, pill height, padding, hover/selected styling).
- (9): Click a sibling child pill — the same pane navigates to it (no split spawned, no new tab). Click the orchestrator pill from a child — same.
- (10)(11): On a child view, breadcrumbs replace the title. Click the parent crumb → returns to orchestrator and pill bar reappears.
- (12): Hover a pill, then trigger a re-render (e.g. wait for a status update). Hover state must persist; cursor must remain pointing-hand.
- (14): Compare title vertical centering against the `ESC for terminal` button on the same row. Both must be centered.
- (15): Toggle `OrchestrationPillBar` off in settings. Header must render exactly as before, including the legacy parent-conversation card path.
- (17): Enter and exit the agent view multiple times. No stale hover bleeds through.
### Layout-regression test
Add a unit test next to `OrchestrationPillBar` that lays out the view in a `warpui::App::test` with at least one child conversation, asserting it does not panic. This is the standard "UI components need layout validation tests" requirement from the `create-pr` skill, and it specifically guards the load-bearing `ConstrainedBox::with_height(PANE_HEADER_HEIGHT)` fix in `maybe_add_parent_navigation_card` (see Risks).
### Behavior-driven coverage to consider
- `pill_specs` returning `None` when the active conversation has a parent and when the orchestrator has zero children — pure logic, easy to unit test against a mocked `BlocklistAIHistoryModel`.
- `pill_avatar_color` being deterministic for a given name (idempotency).
- `orchestrator_label` falling back through `agent_name` to `"Orchestrator"`.
## Risks and mitigations
- **Regression: title vertical centering.** The `Flex::column` wrap introduced by this feature inadvertently broke the title's centering until the header was pinned to `PANE_HEADER_HEIGHT`. The pinning is the only reason centering still works — any future refactor of `maybe_add_parent_navigation_card` that loses the `ConstrainedBox::with_height(PANE_HEADER_HEIGHT)` will regress. Add an inline comment at the call site (already done) and the layout-regression test above.
- **Mouse state lifetime.** Constructing `MouseStateHandle::default()` inline at render time silently zeros out hover state every frame. Per-pill state lives in the view's `mouse_states` map; the parent crumb's state is sourced from `TerminalViewMouseStates`. This pattern is enforced by the existing WARP.md guidance.
## Follow-ups
- Hover preview popover on a pill (small thumbnail of the conversation).
- Pin / unpin a child to keep its conversation open in a parallel split.
- 3-dot menu on each pill: `Open in new pane`, `Open in new tab`, `Stop agent`, `Kill agent`.
- Consider extending `crate::ui_components::breadcrumb` to support per-crumb avatars and a chevron separator so the manual breadcrumb rendering here can collapse into the shared helper.
+79
View File
@@ -0,0 +1,79 @@
# Harness-Specific Model Selection in Orchestration Config
Linear: [QUALITY-643](https://linear.app/warpdotdev/issue/QUALITY-643)
## Summary
When a user selects a non-Oz harness (Claude Code, Codex) in the orchestration config UI, the model picker should show models that the selected harness actually supports, and the chosen model should reach the harness process. Today the model picker always shows Warp's internal model catalog regardless of harness, and those IDs are not recognized by third-party harness CLIs.
Figma: none provided — changes are behavioral within existing orchestration config UI chrome (plan card and run_agents confirmation card).
## Behavior
### Harness picker content
1. The harness picker populates from the server-provided `availableHarnesses` list (via `HarnessAvailabilityModel`), not from a hardcoded client-side list. This ensures the desktop matches the web UI and respects admin-configured harness availability.
2. Each harness entry displays its `display_name` from the server with the corresponding brand icon (Warp logo for Oz, Claude logo for Claude Code, OpenAI logo for Codex, Gemini logo for Gemini CLI).
3. Enabled harnesses are selectable. Disabled harnesses (admin-disabled via org settings) appear in the list with a visual indicator (e.g. greyed text or a "disabled" badge) but cannot be selected.
4. Enabled harnesses appear before disabled harnesses in the list.
5. If the currently-selected harness becomes disabled (e.g. after a server refresh), the picker retains the selection but the UI should indicate the issue.
### Model picker content by harness
6. When the harness picker is set to **Oz** (or is empty/unset), the model picker shows the Warp LLM catalog — the same models shown in single-agent mode. This is the current behavior and must not regress.
7. When the harness picker is set to **Claude Code**, the model picker shows:
- A **"Default model"** entry at the top (value: empty string) meaning "don't override — let the harness use its own default."
- The server-provided Claude Code model catalog (e.g. `best`, `opus`, `sonnet`, `haiku`, `opus (1M context)`, `sonnet (1M context)`, pinned versions like `opus 4.7`, `sonnet 4.6`).
This matches the Oz web UI's harness model selector.
8. When the harness picker is set to **Codex** and the execution mode is **Cloud**, the model picker shows:
- A **"Default model"** entry at the top (value: empty string), same as Claude Code.
- The server-provided Codex model catalog (e.g. `default`, `GPT-5.5`, `GPT-5.4`, `GPT-5.4 mini`). The `default` entry from the server explicitly skips writing the model key to config, which has the same practical effect as the "Default model" entry but is Codex-specific.
This matches the Oz web UI's Codex model selector.
When the execution mode is **Local**, the model picker shows only the **"Default model"** entry. The Codex CLI reads its model from `~/.codex/config.toml`, which is shared global state — writing to it from a child agent would clobber the user's existing config and race with parallel agents. Local Codex children inherit whatever model the user has configured.
9. When the harness picker is set to **Gemini** (currently disabled for orchestration), the model picker follows the same pattern: "Default model" at top, then server-provided Gemini models if any exist.
10. Each model entry displays its `display_name` from the server catalog. The raw `id` (e.g. `"opus"`, `"gpt-5.4"`) is the value stored as the selected model_id and passed to the harness process. No provider icons or model spec sidecars are shown for harness models (they lack that metadata).
### Defaults when no model is specified
11. When the orchestration config is created (via `create_orchestration_config`) or the harness changes and no model_id is specified (empty string), the model picker defaults to:
- **Oz**: the orchestrator's current model (the model the parent agent is using).
- **Non-Oz harnesses** (Claude Code, Codex, Gemini): the "Default model" entry (empty string), meaning the harness uses its own default.
### Model reset on harness change
12. When the user changes the harness, the model_id resets because each harness has its own disjoint model catalog. The reset target is "Default model" (empty string) for non-Oz harnesses, or the first available Warp LLM for Oz. The only exception is the empty string itself ("Default model"), which can persist across non-Oz harness changes.
### Loading and empty states
13. If the harness model catalog has not yet been fetched from the server when the user switches to a non-Oz harness, the model picker shows only the "Default model" entry. Once the catalog arrives, the picker repopulates with the full list, keeping "Default model" selected.
14. If the server returns an empty model list for a harness, the model picker shows only the "Default model" entry. The model_id remains empty.
### Consistency across UI surfaces
15. The orchestration config block (plan card) and the run_agents confirmation card must show the same harness-specific models, using the same server catalog, and behave identically when the harness changes.
16. The `sync_picker_selections` logic (which syncs picker UI state to the edit state) must correctly match harness-specific model IDs against the harness model list, not against Warp's internal LLM catalog, when a non-Oz harness is active.
### Model ID delivery to harness processes
17. When the user selects a model for **Claude Code** and agents are launched, the selected model_id (e.g. `"opus"`) must reach the Claude Code CLI process as the `ANTHROPIC_MODEL` environment variable. This applies to both local child agents and remote agents.
18. When the user selects a model for **Codex** and agents are launched in **Cloud** mode, the selected model_id (e.g. `"gpt-5.4"`) must be written to `~/.codex/config.toml` as the top-level `model` key before the Codex CLI starts. If the selected model is `"default"`, the `model` key must NOT be written (or must be removed if previously present), allowing Codex to use its own default. In **Local** mode, no model override is written — the user's existing `~/.codex/config.toml` is used as-is.
19. When the user selects a model for **Oz**, the existing model_id propagation behavior (Warp internal LLM preference) must not change.
20. When the "Default model" entry is selected (empty model_id) for any non-Oz harness, no model override is injected — the harness process uses its own default.
### Auto-launch behavior
21. The `matches_active_config` check (which determines whether a run_agents call auto-launches without user confirmation) must treat harness-specific model_ids the same as Warp model_ids: an exact string match between the request's model_id and the config's model_id.
+189
View File
@@ -0,0 +1,189 @@
# Harness-Specific Model Selection — Tech Spec
Linear: [QUALITY-643](https://linear.app/warpdotdev/issue/QUALITY-643)
Companion product spec: `specs/QUALITY-643/PRODUCT.md`
## Context
The orchestration config UI (plan card and run_agents confirmation card) lets users pick a harness and model for child agents. Both cards share picker logic in `orchestration_controls.rs`. Two problems exist today:
1. **Harness picker** is hardcoded to `[Oz, Claude, Codex]` — it doesn't read from the server's `availableHarnesses` list, doesn't include Gemini, and doesn't respect admin enabled/disabled state.
2. **Model picker** always shows Warp's internal LLM catalog filtered by provider (Anthropic for Claude, OpenAI for Codex). Those IDs (e.g. `claude-4-6-opus-high`) are not recognized by third-party harness CLIs. The server maintains separate harness-specific model catalogs that the desktop client already fetches and caches in `HarnessAvailabilityModel`, but the orchestration UI doesn't use them.
Additionally, model_id is not delivered to local child harness processes: Claude Code doesn't receive `ANTHROPIC_MODEL`. Codex model delivery uses `~/.codex/config.toml`, which is only safe in cloud/remote environments where the filesystem is isolated — local children must not touch it (see `local_harness_launch.rs:143` comment).
### Relevant files
**Shared picker logic (model/harness dropdowns)**
- `app/src/ai/blocklist/inline_action/orchestration_controls.rs``populate_harness_picker()` (line 380), `populate_model_picker_for_harness()` (line 314), `is_model_in_filtered_choices()` (line 353), `first_filtered_model_id()` (line 368), `sync_picker_selections()` (line 508), `matches_harness_filter()` (line 299)
**UI card views that consume the shared pickers**
- `app/src/ai/document/orchestration_config_block.rs` — plan card; subscribes to `LLMPreferencesEvent` for model refresh
- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs` — confirmation card; `HarnessChanged` handler (line 752) resets model on harness change
**Harness availability and model data (already fetched and cached)**
- `app/src/ai/harness_availability.rs``HarnessAvailabilityModel` singleton with:
- `available_harnesses()``&[HarnessAvailability]` (harness, display_name, enabled, available_models)
- `models_for(harness)``Option<&[HarnessModelInfo]>` (id, display_name)
- `is_harness_enabled(harness)``bool`
- Emits `HarnessAvailabilityEvent::Changed`
**Harness display metadata**
- `app/src/ai/harness_display.rs``display_name()`, `icon_for()`, `brand_color()` per `Harness` variant
**Model ID delivery to harness processes**
- `app/src/ai/agent_sdk/driver/harness/mod.rs``harness_model_env_vars()` (line 373): sets `ANTHROPIC_MODEL` for Claude, no-op for Codex
- `app/src/pane_group/pane/local_harness_launch.rs``prepare_local_harness_child_launch()` (line 77): builds child env_vars but does not include model_id
- `app/src/pane_group/pane/terminal_pane.rs``launch_local_harness_child()` (line 1302): passes `model_id` to `apply_child_model_id_override` which only sets Oz LLM preference
- `app/src/ai/agent_sdk/driver/harness/codex.rs``prepare_codex_config_toml()` (line 573): writes `~/.codex/config.toml` but does not write a `model` key; top-level key name is `"model"` (confirmed by test at `codex_tests.rs:201`)
## Proposed changes
### 1. Populate harness picker from `HarnessAvailabilityModel`
**File**: `orchestration_controls.rs``populate_harness_picker()`
Replace the hardcoded `[Harness::Oz, Harness::Claude, Harness::Codex]` iteration (line 392) with a read from `HarnessAvailabilityModel::as_ref(ctx).available_harnesses()`.
For each `HarnessAvailability` entry:
- Use `harness_display::icon_for()` and `harness_display::brand_color()` for icons (these already cover all variants including Gemini).
- Use `harness_display::display_name()` for the label (the server's `display_name` field could also be used, but the client-side names already match and are guaranteed non-empty before the server responds).
- If `!entry.enabled`: render as disabled (non-selectable, greyed text). The `MenuItemFields` API supports `.with_disabled(true)` or equivalent — check the existing `MenuItem` disabled patterns in the codebase.
- Sort enabled entries before disabled entries.
Also subscribe both card views to `HarnessAvailabilityEvent::Changed` to repopulate the harness picker when the server list updates.
Addresses PRODUCT.md behaviors 15.
### 2. Switch model picker to harness-specific models with "Default model" entry
**File**: `orchestration_controls.rs``populate_model_picker_for_harness()`
Add an `is_local: bool` parameter (or pass the current `RunAgentsExecutionMode`) so the picker can be execution-mode-aware. Replace the current provider-filtered `LLMPreferences` logic with harness-aware branching:
```
let harness = Harness::parse_orchestration_harness(harness_type);
match harness {
Some(Harness::Oz) | None => {
// Current behavior: LLMPreferences filtered by provider
}
Some(Harness::Codex) if is_local => {
// Local Codex: only "Default model" entry (no model delivery possible)
}
Some(harness) => {
// 1. Always add a "Default model" entry first (value: empty string)
// 2. Read HarnessAvailabilityModel::as_ref(ctx).models_for(harness)
// 3. If Some(models): append each HarnessModelInfo as a menu item
// with display_name as label and id as the model_changed action value
// 4. If None: only "Default model" is shown (loading/empty state)
}
}
```
The "Default model" entry should use label `"Default model"` and emit `A::model_changed(String::new())` (empty string). This matches the web UI which adds this entry with value `""`.
When execution mode toggles between Local and Cloud, the `HarnessChanged` / `ExecutionModeToggled` handlers must repopulate the model picker since Codex's available models depend on the mode.
Apply the same Oz-vs-non-Oz branching to:
- `is_model_in_filtered_choices()` — for non-Oz, check model_id against `HarnessAvailabilityModel::models_for()` OR accept empty string (the "Default model" entry). For local Codex, only empty string is valid.
- `first_filtered_model_id()` — for non-Oz, return `Some(String::new())` (the "Default model" entry) as the default.
- `sync_picker_selections()` — for non-Oz, find display_name from `HarnessAvailabilityModel::models_for()` instead of `LLMPreferences`. Map empty model_id to the "Default model" label.
Addresses behaviors 610, 1114, 1516.
### 3. Subscribe both card views to `HarnessAvailabilityEvent::Changed`
**Files**: `orchestration_config_block.rs`, `run_agents_card_view.rs`
Both views already subscribe to `LLMPreferencesEvent::UpdatedAvailableLLMs`. Add an analogous subscription to `HarnessAvailabilityModel`:
- Repopulate the **harness picker** when the harness list changes (behaviors 15).
- Repopulate the **model picker** when harness models arrive, but only when the current harness is non-Oz (behavior 15).
Addresses behaviors 15, 15.
### 4. Propagate model_id to local child harness processes (Claude Code only)
**File**: `local_harness_launch.rs`
Add `model_id: Option<String>` parameter to `prepare_local_harness_child_launch()`. After building `env_vars` from `task_env_vars()`:
- For Claude: merge `harness_model_env_vars(harness, model_id.as_deref())` into env_vars. This sets `ANTHROPIC_MODEL` when model_id is non-empty.
- For Codex: no model delivery for local children. The UI ensures model_id is empty for local Codex (behavior 8), so no action is needed here. The existing code already skips `prepare_codex_environment_config()` for local children (`local_harness_launch.rs:143`) and this spec preserves that constraint.
**File**: `terminal_pane.rs`
Update the call to `prepare_local_harness_child_launch()` in `launch_local_harness_child()` (line 1325) to pass the `model_id` value.
Addresses behaviors 17, 20.
### 5. Write Codex model to config.toml (cloud/remote path only)
**File**: `codex.rs`
Add `model_id: Option<&str>` parameter to `prepare_codex_environment_config()` and `prepare_codex_config_toml()`.
In `prepare_codex_config_toml()`, after `set_codex_openai_base_url()`:
- If `model_id` is `Some(id)` where `id` is non-empty and not `"default"`: `doc["model"] = toml_edit::value(id)`.
- Otherwise: `doc.remove("model")` to clear any pre-existing key.
Add constant `CODEX_MODEL_KEY: &str = "model"`.
The existing test at `codex_tests.rs:201` verifies a pre-existing `model` key is preserved — update it to verify the new write/remove behavior.
Update the caller of `prepare_codex_environment_config()` in `codex.rs` `build_runner()` to pass `model_id`. No changes needed in `local_harness_launch.rs` — local Codex children don't call this function and don't receive model overrides.
Addresses behavior 18.
### 6. No changes needed for remote launch path
The remote launch path already passes `model_id` to the server via `StartAgentExecutionMode::Remote { model_id }` in `run_agents_to_start_agent_mode()`. With the UI now storing harness-native IDs (or empty for "Default model"), the server receives the correct value without translation.
Addresses behaviors 17 (remote), 18 (remote).
## Testing and validation
### Unit tests
**orchestration_controls tests** — new tests:
- Harness picker populated from `HarnessAvailabilityModel`; disabled harnesses shown but not selectable. (Behaviors 14)
- `populate_model_picker_for_harness` with harness="claude": "Default model" entry at top, then harness-specific models from `HarnessAvailabilityModel`. (Behavior 7)
- `populate_model_picker_for_harness` with harness="codex", cloud mode: "Default model" entry at top, then Codex models. (Behavior 8)
- `populate_model_picker_for_harness` with harness="codex", local mode: only "Default model" entry. (Behavior 8)
- `populate_model_picker_for_harness` with harness="oz": Warp LLM catalog (existing behavior). (Behavior 6)
- `is_model_in_filtered_choices` returns false for Warp IDs when harness is non-Oz, true for empty string ("Default model"). (Behavior 12)
- `first_filtered_model_id` returns empty string for non-Oz harness. (Behavior 11)
- Harness change from Claude (model="opus") to Oz: model resets to first Warp LLM. (Behavior 12)
**local_harness_launch tests** — new/updated tests:
- `prepare_local_harness_child_launch` merges `ANTHROPIC_MODEL` into env_vars when harness is Claude and model_id is provided. (Behavior 17)
- `prepare_local_harness_child_launch` does NOT set `ANTHROPIC_MODEL` when model_id is None or empty. (Behavior 20)
**codex config.toml tests** — update existing tests in `codex_tests.rs`:
- `prepare_codex_config_toml` writes `model = "gpt-5.4"` when model_id is `Some("gpt-5.4")`. (Behavior 18)
- `prepare_codex_config_toml` removes existing `model` key when model_id is `Some("default")`. (Behavior 18)
- `prepare_codex_config_toml` removes existing `model` key when model_id is `None`. (Behavior 20)
- Pre-existing non-model keys (openai_base_url, projects, mcp_servers) are preserved in all cases.
**orchestration_config_tests** — existing tests must continue to pass. (Behavior 21)
### Presubmit
Run `cargo fmt`, `cargo clippy`, and `./script/presubmit` before PR.
### Manual validation
- Open orchestration config on a plan card → harness picker shows Oz, Claude Code, Codex (and Gemini if server returns it, possibly disabled).
- Select Claude Code → model picker shows "Default model" at top, then `best`, `opus`, `sonnet`, etc.
- Select Codex (Cloud mode) → model picker shows "Default model" at top, then `default`, `GPT-5.5`, `GPT-5.4`, etc.
- Select Codex (Local mode) → model picker shows only "Default model".
- Toggle Local → Cloud with Codex selected → model picker repopulates with full Codex catalog.
- Select Oz → model picker returns to Warp LLM catalog.
- Change harness from Claude (with "opus" selected) to Oz → model resets.
- Launch local agents with Claude Code + "opus" → verify `ANTHROPIC_MODEL=opus` in child env.
- Launch local agents with Claude Code + "Default model" → verify no `ANTHROPIC_MODEL` in child env.
- Launch cloud agents with Codex + "gpt-5.4" → verify `model = "gpt-5.4"` in `~/.codex/config.toml` inside the cloud env.
- Launch cloud agents with Codex + "default" → verify no `model` key in `~/.codex/config.toml`.
## Parallelization
Not recommended. The changes are tightly coupled — the harness picker (change 1) and model picker (change 2) share state in `orchestration_controls.rs`, the card view subscriptions (change 3) depend on both pickers, and the launch-side changes (45) depend on the correct model_id format flowing from the picker. Total scope is ~7 files with moderate changes each, well suited for sequential execution by a single agent.
+277
View File
@@ -0,0 +1,277 @@
# Associate Orchestration Config with Plan ID — Client Tech Spec
## Problem
The Warp desktop client stores orchestration config as a **single value per conversation**. When the server switches to per-plan snapshots (append-only, one per plan — see `warp-server/specs/QUALITY-657/TECH.md`), the client needs to:
1. Hydrate multiple orchestration configs from conversation history, indexed by `plan_id`.
2. Render a config block on each plan card showing that plan's config.
3. Thread `plan_id` through the `RunAgents` request so the auto-launch match check is plan-scoped.
4. Send per-plan dirty events back to the server.
## Companion spec
Server-side changes are documented in `warp-server/specs/QUALITY-657/TECH.md`. The proto changes (`plan_id` on `RunAgents` field 9, append-only `OrchestrationConfigSnapshot` messages) land in `warp-proto-apis` before this work begins. This spec covers only the Warp desktop client (Rust).
## Relevant code
### Hydration and model
- `app/src/ai/document/ai_document_model.rs (1192-1319)``handle_history_event_for_orchestration_config()` and `scan_conversation_for_orchestration_config()`. Currently calls `.last()` on all snapshot messages to find the single config.
- `app/src/ai/document/ai_document_model.rs (195-199)``dirty_orchestration_events: HashMap<AIConversationId, DirtyOrchestrationEvent>`. One dirty event per conversation.
### Conversation state
- `app/src/ai/agent/conversation.rs (882-909)``orchestration_config()`, `orchestration_status()`, `orchestration_plan_id()`, `set_orchestration_config()`. Single config/status/plan_id stored per conversation.
### Plan card config block
- `app/src/ai/document/orchestration_config_block.rs (109-186)``OrchestrationConfigBlockView`. Keyed by conversation, not by plan.
- `app/src/ai/ai_document_view.rs (1061-1085)` — Renders a single config block if the conversation has an orchestration config.
### Auto-launch / match check
- `crates/ai/src/agent/orchestration_config.rs (52-96)``matches_active_config()`. Compares a `RunAgentsRequest` against a single `OrchestrationConfig`.
- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs (189-268)``should_auto_launch()`. Receives `active_config: Option<(OrchestrationConfig, OrchestrationConfigStatus)>` — no `plan_id` context.
### RunAgents request
- `crates/ai/src/agent/action/mod.rs (187-195)``RunAgentsRequest` struct. No `plan_id` field.
### Dirty sync
- `app/src/ai/blocklist/controller.rs (746-788)` — Takes one dirty event per conversation from `AIDocumentModel`, appends to `inputs`.
- `app/src/ai/agent/api/convert_to.rs (438-450)` — Converts `AIAgentInput::OrchestrationConfigUpdate` to proto, already includes `plan_id`.
### Dispatch
- `app/src/ai/blocklist/action_model/execute/run_agents.rs (77-245)``dispatch_run_agents()`. No `plan_id` handling. Needs to thread `plan_id` from `RunAgentsRequest` into per-child dispatch calls (for logging/telemetry context, not for child execution).
## Current state
The client treats orchestration config as a conversation-level singleton:
- **Hydration**: Scans all messages, takes the `.last()` snapshot, stores one config on the conversation.
- **Plan card**: One config block per conversation. All plan cards share it.
- **Auto-launch**: `should_auto_launch()` receives the single active config. No plan_id filtering.
- **RunAgentsRequest**: No `plan_id` field. The request can't express which plan it's executing.
- **Dirty sync**: One dirty event per conversation. Editing the config block queues one event.
## Proposed changes
### 1. `RunAgentsRequest`: add `plan_id`
Add `plan_id` to the request struct in `crates/ai/src/agent/action/mod.rs`:
```rust
pub struct RunAgentsRequest {
pub summary: String,
pub base_prompt: String,
pub skills: Vec<SkillReference>,
pub model_id: String,
pub harness_type: String,
pub execution_mode: RunAgentsExecutionMode,
pub agent_run_configs: Vec<RunAgentsAgentRunConfig>,
pub plan_id: String, // NEW
}
```
Update the proto-to-struct conversion (wherever `RunAgentsRequest` is built from the `RunAgents` proto) to read `plan_id` from the proto field 9.
Note: `RunAgentsResult::Launched` does not need a `plan_id` field — the client already has `plan_id` from the `RunAgentsRequest` and does not need it repeated on the result.
### 2. Conversation state: per-plan config map
Replace the single config on `AIAgentConversation` with a map:
```rust
// Before (conversation.rs):
orchestration_config: Option<OrchestrationConfig>,
orchestration_status: OrchestrationConfigStatus,
orchestration_plan_id: Option<String>,
// After:
orchestration_configs: HashMap<String, (OrchestrationConfig, OrchestrationConfigStatus)>,
```
Keyed by `plan_id`. Snapshots with empty `plan_id` are ignored (legacy conversations).
New accessors:
```rust
pub fn orchestration_config_for_plan(&self, plan_id: &str)
-> Option<(&OrchestrationConfig, OrchestrationConfigStatus)>
pub fn set_orchestration_config_for_plan(
&mut self,
plan_id: String,
config: OrchestrationConfig,
status: OrchestrationConfigStatus,
) -> bool
```
Remove the old `orchestration_config()`, `orchestration_status()`, `orchestration_plan_id()`, `set_orchestration_config()` accessors.
### 3. Hydration: scan and index by `plan_id`
Change `scan_conversation_for_orchestration_config()` in `ai_document_model.rs`:
**Before**: Finds the `.last()` `OrchestrationConfigSnapshot` message, stores a single config.
**After**: Scans backward through all messages. For each `OrchestrationConfigSnapshot` with a non-empty `plan_id`, stores the first one found during the backward scan (i.e. the most recent) per `plan_id`. Result is a map of `plan_id → (config, status)` set on the conversation.
```rust
fn scan_conversation_for_orchestration_config(messages: &[Message]) -> HashMap<String, (OrchestrationConfig, OrchestrationConfigStatus)> {
let mut configs = HashMap::new();
for msg in messages.iter().rev() {
if let Some(snapshot) = msg.orchestration_config_snapshot() {
let plan_id = snapshot.plan_id();
if !plan_id.is_empty() && !configs.contains_key(plan_id) {
configs.insert(plan_id.to_string(), (
OrchestrationConfig::from_proto(snapshot.config()),
OrchestrationConfigStatus::from_proto(snapshot.status()),
));
}
}
}
configs
}
```
Also update `handle_history_event_for_orchestration_config()` to process incremental snapshot messages the same way — when a new snapshot arrives (via `UpdatedConversationStatus` or `AppendedExchange`), insert/overwrite the entry for that `plan_id` in the map.
### 4. Plan card config block: per-plan rendering
Change `OrchestrationConfigBlockView` to be keyed by `(conversation_id, plan_id)` instead of just `conversation_id`.
In `ai_document_view.rs`, each `AIDocumentView` knows its plan's `document_id` (which is the `plan_id`). Pass it to the config block constructor:
```rust
// Before:
OrchestrationConfigBlockView::new_with_conversation_id(conversation_id, ctx)
// After:
OrchestrationConfigBlockView::new(conversation_id, plan_id, ctx)
```
The config block reads its config from `conversation.orchestration_config_for_plan(plan_id)` instead of `conversation.orchestration_config()`.
A plan card only shows a config block if a config exists for its `plan_id`. Plans without configs show no block (rather than sharing a global config).
### 5. Auto-launch: plan-scoped match check
Change `RunAgentsCardView` construction to look up the config by `plan_id` from the request:
**Before** (line 250-254 of `run_agents_card_view.rs`):
```rust
let active_config = conversation.orchestration_config()
.map(|c| (c.clone(), conversation.orchestration_status()));
```
**After**:
```rust
let active_config = if !state.plan_id.is_empty() {
conversation.orchestration_config_for_plan(&state.plan_id)
.map(|(c, s)| (c.clone(), s))
} else {
None
};
```
`should_auto_launch()` signature stays the same — it already receives `active_config: &Option<(OrchestrationConfig, OrchestrationConfigStatus)>`. The plan-scoping happens at the call site.
`matches_active_config()` in `orchestration_config.rs` is unchanged — it compares request fields against a config. The plan_id filtering is done before calling it.
### 6. Dirty sync: per-plan dirty events
Change the dirty event queue from `HashMap<AIConversationId, DirtyOrchestrationEvent>` to `HashMap<(AIConversationId, String), DirtyOrchestrationEvent>` where the second key element is `plan_id`.
In `controller.rs`, the current `take_dirty_orchestration_event(&conversation_id)` becomes `take_dirty_orchestration_events(&conversation_id)` which returns all dirty events for the conversation (one per plan that was edited). Each is appended as a separate `AIAgentInput::OrchestrationConfigUpdate`.
The proto conversion in `convert_to.rs` is unchanged — each `OrchestrationConfigUpdate` already carries its own `plan_id`.
### 7. Config block editing: plan-scoped dirty events
When the user edits a field in `OrchestrationConfigBlockView`, the `apply_field_change()` method currently calls:
```rust
model.set_orchestration_config(config, status, plan_id);
model.set_dirty_orchestration_event(conversation_id, dirty_event);
```
After the change, this becomes:
```rust
model.set_orchestration_config_for_plan(plan_id, config, status);
model.set_dirty_orchestration_event(conversation_id, plan_id, dirty_event);
```
Each config block edit only affects its own plan's config and queues a dirty event for that plan.
## End-to-end flow
### Hydration (restore)
1. Client opens a conversation with history.
2. `scan_conversation_for_orchestration_config()` scans backward, builds `HashMap<plan_id, (config, status)>`.
3. Each plan card's `AIDocumentView` checks if its `plan_id` has an entry → renders config block if so.
### Agent calls `run_agents` with `plan_id`
1. Server emits `SetRunAgentsToolCall` with `plan_id` and resolved defaults.
2. Client parses `RunAgentsRequest` including `plan_id`.
3. `RunAgentsCardView` construction looks up `conversation.orchestration_config_for_plan(plan_id)`.
4. If config found + approved + fields match → auto-launch (no card shown).
5. If no config or mismatch → show confirmation card.
### User edits config on plan B's card
1. User toggles a field on plan B's config block.
2. `apply_field_change()` updates `orchestration_configs["plan-B"]` on the conversation.
3. Dirty event queued for `(conversation_id, "plan-B")`.
4. On next outbound request, dirty event piggybacked as `OrchestrationConfigUpdate { plan_id: "plan-B", ... }`.
5. Server appends a new `OrchestrationConfigSnapshot` message with `plan_id = "plan-B"`.
### Two plans, independent configs
- Plan A has `local` config. Plan B has `remote` config.
- `run_agents(plan_id="A")` → inherits local. `run_agents(plan_id="B")` → inherits remote.
- Editing plan A's config does not affect plan B's.
## Coordination with server changes
### Proto dependency
The `plan_id` field on `RunAgents` (field 9) must land in `warp-proto-apis` before the client work begins. The `OrchestrationConfigSnapshot` proto already has `plan_id` (field 1) — no proto change needed for that.
### Backward compatibility
- **New client + old server**: Server sends `OrchestrationConfigSnapshot` with empty `plan_id` (singleton model). Client ignores empty `plan_id` snapshots → no config hydrated → every `run_agents` call shows a confirmation card. Functionally correct, just no auto-launch.
- **Old client + new server**: Server appends per-plan snapshots. Old client's `.last()` scan picks up whichever snapshot was appended most recently → may show the wrong plan's config. Acceptable during rollout since the old client doesn't use `plan_id` for match-checking anyway.
- **New client + new server**: Full per-plan behavior.
### Rollout order
1. Proto PR (`warp-proto-apis`): adds `plan_id` field 9 to `RunAgents`.
2. Server PR (`warp-server`): implements per-plan append-only snapshots, `plan_id` on `create_orchestration_config` and `run_agents`.
3. Client PR (`warp`): implements per-plan hydration, config blocks, auto-launch, dirty sync.
Server and client PRs can land in either order after the proto — backward compatibility is maintained in both directions.
## Risks and mitigations
**Risk: Conversations with many plans accumulate snapshot messages.** Client scans backward through all messages on hydration.
*Mitigation:* The backward scan is O(n) over messages but short-circuits per plan_id (first match wins). For typical conversations this is negligible.
**Risk: Old singleton snapshots (empty `plan_id`) are orphaned.** After the client upgrade, they're never hydrated.
*Mitigation:* Desired behavior. The agent will call `create_orchestration_config` with a `plan_id` on its next interaction, creating a proper per-plan snapshot.
**Risk: Behavioral change — `run_agents` without `plan_id` no longer auto-launches.** Today, any `run_agents` call can auto-launch against the singleton config. After this change, `run_agents` without `plan_id` always shows a confirmation card because `active_config` is `None` when `plan_id` is empty. This is intentional — inheritance now requires the agent to specify which plan it's executing — but it changes the default experience for agents that orchestrate without plans.
**Risk: Config block flicker during hydration.** Plan cards may briefly render without config blocks until hydration completes.
*Mitigation:* Hydration runs synchronously in `scan_conversation_for_orchestration_config()` before the plan card view is built. No async gap.
## Testing and validation
### Unit tests
**`orchestration_config.rs` (crate-level):**
- `matches_active_config()` — unchanged; existing tests still pass.
**`ai_document_model.rs`:**
- Hydration with multiple snapshots for different `plan_id`s → map contains one entry per plan.
- Hydration with multiple snapshots for the same `plan_id` → most recent wins.
- Hydration with empty `plan_id` snapshots → ignored.
- Incremental snapshot arrival → updates the correct plan's entry.
**`run_agents_card_view.rs`:**
- `should_auto_launch()` with matching plan config → true.
- `should_auto_launch()` with no config for plan → false.
- `should_auto_launch()` with config for a different plan → false.
**`conversation.rs`:**
- `orchestration_config_for_plan()` returns correct config per plan.
- `set_orchestration_config_for_plan()` doesn't affect other plans.
**`controller.rs` (dirty sync):**
- Editing plan A queues one dirty event; editing plan B queues another.
- Both are sent on the next outbound request.
- Events are cleared after send.
### Manual validation
- Create two plans in the same conversation with different orchestration configs.
- Verify each plan card shows its own config block.
- Verify `run_agents` for each plan inherits from its own config.
- Verify editing one plan's config doesn't affect the other.
- Verify auto-launch works per-plan.
- Verify disapproving one plan's config doesn't block the other.
## Follow-ups
- **Config block visibility without explicit create.** If the user wants to add a config to a plan that doesn't have one, the plan card currently shows nothing. A future enhancement could add an "Add orchestration config" affordance.
- **Garbage collection of stale snapshots.** If a plan is deleted, its snapshot messages remain. Not harmful (they're never matched), but could be cleaned up in a future pass.
+71
View File
@@ -0,0 +1,71 @@
# Enhance credit usage details in the orchestrator tab
Linear: https://linear.app/warpdotdev/issue/QUALITY-671
## Summary
The expanded credit usage footer in agent mode is per-conversation. When the conversation is an orchestrator, the footer hides the real cost of the work it dispatched — credits incurred by child agents are never surfaced on the parent. This feature changes the orchestrator's "Credits spent (total)" row to reflect the *orchestration total* (orchestrator + all locally-known descendants) and adds a click-to-expand per-agent breakdown beneath it.
## Figma
- Frame (overview, collapsed + expanded side-by-side): https://www.figma.com/design/AsF5uAM6L5tUmc11vm9YSi/Agent-orchestration?node-id=4646-33383
- Expanded "Hide details" state: https://www.figma.com/design/AsF5uAM6L5tUmc11vm9YSi/Agent-orchestration?node-id=4636-32699
## Goals
- Show the true end-to-end credit cost of an orchestration run in the parent's expanded usage footer at a glance.
- Let the user drill into per-agent credit attribution without leaving the footer.
- Reuse existing agent identity (avatar, display name) so the breakdown reads consistently with the orchestration pill bar.
## Non-goals
- Server-side billing or pricing changes. The feature is purely a presentation of usage data the server already returns per conversation.
- Rolling up any metric other than credits in v1. Tool calls, files changed, lines +/-, commands, models, context window, and last-response timing all stay self-only. Rollup of other metrics is a possible follow-up.
- Rolling up usage from descendants whose conversation state is not loaded locally (e.g. a remote child running on a worker the user has never opened on this client).
## Behavior
1. On any conversation that has no descendant child agents loaded locally, or whose loaded descendants have all spent zero credits, the expanded credit usage footer renders exactly as it does today. No new UI is added.
2. When the conversation rendering the footer is an orchestrator with at least one locally-loaded descendant that has spent credits, the "USAGE SUMMARY" section's "Credits spent (total)" row is modified as follows:
a. The numeric value (e.g. "33 credits") becomes the orchestration total — the sum of credits spent by the orchestrator plus all of its locally-known descendants (children, grandchildren, etc., transitively).
b. A "View details" link with a chevron-down icon is rendered immediately to the right of the value, on the same row.
c. Clicking "View details" replaces the link with "Hide details" and a chevron-up icon, and reveals a per-agent breakdown list directly below the row (see invariant 5).
d. Clicking "Hide details" collapses the per-agent list and restores "View details".
3. The "Credits spent (last response)" row is unchanged. It always reflects only the orchestrator's own most-recent-block credits, never a rollup.
4. All other rows in the expanded footer ("Tool calls", "Models", "Context window used" in USAGE SUMMARY, plus the entire TOOL CALL SUMMARY and LAST RESPONSE TIME sections) continue to reflect only the orchestrator's own values. They are not rolled up in v1. Credits-only rollup is the locked v1 scope; broader rollup is a possible follow-up.
5. The per-agent breakdown list, when "View details" is active:
a. Contains one row per agent contributing to the rollup. The orchestrator is listed alongside its descendants — there is no separate "self" row.
b. Rows are sorted by credits spent, descending. Ties are broken by spawn order (earlier spawn first).
c. Each row displays: the agent's avatar disc (orchestrator uses the orchestrator avatar; children use the existing per-name color + initial avatar from the orchestration pill bar), the agent's display name (e.g. "Orchestrator", "DesignBot"), and the credit value formatted by `format_credits`.
d. Only agents that have spent > 0 credits are included. Just-spawned or idle agents are omitted (they pop in as soon as they consume credits).
e. When ≤ 5 rows are eligible, all rows are shown.
f. When > 5 rows are eligible, the first 5 are shown followed by a "Show N more" link where N = total_eligible 5. Clicking the link reveals all remaining rows and removes the link from the list (the link does not become "Show fewer").
g. The per-agent list does not have its own toggles, sorting, or hover affordances beyond the row content. No row is clickable in v1.
6. Local UI state that resets when the footer is collapsed (chevron at the top of the footer) and reopened:
a. The "View details" toggle resets to its default closed state. The default for the freshly-opened footer is "View details" (per-agent list hidden).
b. The "Show N more" expansion (when applicable) resets — the list is again truncated to the first 5 rows with the "Show N more" link.
7. While the footer is open, the rollup total, the per-agent list contents (rows, ordering, values), and the "Show N more" count update live as child agents stream new tokens or finish responses. No user action is needed.
8. When a new descendant child first spends a credit while the user is looking at the expanded footer, its row appears in the per-agent list at the position dictated by its credit value (descending sort).
9. When a descendant child is removed/pruned from the local client, its row disappears on the next render, and the rollup total decreases accordingly.
10. Descendants whose conversation state is not loaded locally do not contribute to the rollup and do not appear in the per-agent list. The rollup is a best-effort sum across locally-known agents. In practice this gap is small (server-side usage updates stream to the client), so the v1 surface does not warn the user that some agents may be missing. If real-world discrepancies prove confusing, a server-side rollup query is a follow-up.
11. The collapsed footer pill (the small button with the credit number + chevron) shows the orchestration total when a rollup applies (per invariant 2). When the rollup does not apply (no eligible descendants), the pill shows the orchestrator's own credit number exactly as it does today.
a. The "+N" delta annotation on the pill (current behavior: show the most-recent-response credit count when total ≠ last response) continues to use the orchestrator's own most-recent-block credits. With the rollup active, this delta represents "the credits the orchestrator's last response added to the orchestration total" — still meaningful at a glance.
b. The existing "hide the button entirely when there's no usage data" rule is evaluated against the rollup total when applicable, so the pill appears as soon as any contributing agent has spent a credit (not only when the orchestrator itself has).
12. The "View details" / "Hide details" link and the per-agent list visually match the existing footer's typography, spacing, and color treatment. Avatar discs use the same component used in the orchestration pill bar's pill avatars (per-name deterministic color + uppercase initial; orchestrator uses `Icon::Oz` on `ansi_fg_cyan`).
13. The feature is self-gating: there is no dedicated feature flag. The rollup activates whenever the orchestrator has at least one locally-loaded descendant with non-zero credits; otherwise the row renders exactly as today. The underlying ability to create child agents is gated by `FeatureFlag::OrchestrationV2`, and the expanded footer surface itself is gated by `FeatureFlag::AgentView`, so the rollup is effectively reachable only when both are on — no additional flag is needed.
14. The footer remains keyboard accessible. The "View details" link is reachable via the normal focus order and activatable with Enter/Space. The "Show N more" link is reachable and activatable the same way. Screen reader semantics for the per-agent list mirror existing list semantics in the footer (no new ARIA invention).
15. The rollup is read-only. No billing, telemetry, or persistence changes — it is a view on data already in `AIConversation.conversation_usage_metadata` for the orchestrator and its locally-known descendants.
16. Forked conversations: a fork descended from the orchestrator is treated as a regular descendant. Its post-fork usage contributes to the rollup if its metadata is loaded; otherwise it is ignored like any other unloaded descendant.
17. Settings-mode usage view (the per-conversation history surface, not the agent-mode footer) is unchanged. The rollup applies only to `DisplayMode::Footer`.
+193
View File
@@ -0,0 +1,193 @@
# Tech spec: roll up orchestration credit usage in the agent-mode footer
Linear: https://linear.app/warpdotdev/issue/QUALITY-671
Companion: `specs/QUALITY-671/PRODUCT.md`
## Context
The agent-mode footer this feature extends is rendered in two layers.
- Collapsed footer (credits + chevron):
- `app/src/ai/blocklist/block/view_impl/output.rs:3243``render_usage_button` builds the inline footer pill. It reads `conversation.credits_spent()`, `conversation.credits_spent_for_last_block()`, `conversation.token_usage()`, and `conversation.tool_usage_metadata().total_tool_calls()`. If all are empty, the button is suppressed (`output.rs:3252-3258`). Per PRODUCT invariant 11, the pill's headline credit number is replaced with the rollup total when one applies; the existing `(+N)` last-response delta keeps using the orchestrator's own `credits_spent_for_last_block`.
- Expanded footer (full usage summary):
- `app/src/ai/blocklist/usage/conversation_usage_view.rs``ConversationUsageView` owns the expanded layout.
- `ConversationUsageInfo` (`conversation_usage_view.rs:28-40`) carries `credits_spent`, `credits_spent_for_last_block`, `tool_calls`, `models: Vec<ModelTokenUsage>`, `context_window_usage`, `files_changed`, `lines_added`, `lines_removed`, `commands_executed`.
- `render_unified_layout` (`conversation_usage_view.rs:127`) emits the "USAGE SUMMARY", "TOOL CALL SUMMARY", and "LAST RESPONSE TIME" sections via `render_section_header`, `render_label_text`, `render_value_text` helpers.
- The "Credits spent (total)" row is rendered at `conversation_usage_view.rs:155-159` (non-last-block path) and `:146-159` (last-block-aware path). The rollup work modifies this row.
Per-conversation usage data is populated on `AIConversation`:
- `AIConversation.conversation_usage_metadata` (`app/src/ai/agent/conversation.rs:160`). Populated by `StreamFinished` events for live conversations and by `get_conversation_usage` GraphQL (`crates/graphql/src/api/queries/get_conversation_usage.rs`) on init / hydration. Every locally-loaded child has its own populated metadata.
Orchestration topology lives in `BlocklistAIHistoryModel`:
- `child_conversation_ids_of(&parent_id)` (`history_model.rs:455`) — direct children from the `children_by_parent` index. The index is maintained by `start_new_child_conversation` / `set_parent_for_conversation` (`history_model.rs:397-450`).
- A transitive walker already exists for the pill bar: `descendant_conversation_ids_in_spawn_order` / `collect_descendant_conversation_ids_in_spawn_order` (`app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:133-151`). Lift this to a shared module — do not duplicate.
Agent identity for the per-agent list comes from helpers the pill bar already uses:
- `pill_avatar_color` / `pill_initial` (`orchestration_pill_bar.rs:85-99`) for child avatars.
- `render_orchestrator_avatar_disc` / `render_agent_avatar_disc` (`orchestration_pill_bar.rs:100-131`) for the actual disc element.
- Display name: `AIConversation.agent_name` for children, "Orchestrator" (or the orchestrator's existing user-facing label, TBD during implementation by walking the code that titles the orchestrator pill).
## Proposed changes
### 1. Aggregation helper
Add a new module `app/src/ai/blocklist/usage/rollup.rs` exposing:
```rust path=null start=null
pub struct OrchestrationCreditRollup {
/// Sum of credits across orchestrator + all locally-known descendants.
pub total_credits: f32,
/// Per-agent rows for the breakdown list, sorted by credits descending,
/// ties broken by spawn order (earlier first). Excludes agents with
/// zero credits.
pub per_agent: Vec<PerAgentCreditEntry>,
}
pub struct PerAgentCreditEntry {
pub conversation_id: AIConversationId,
pub display_name: String,
pub avatar: AgentAvatar, // enum: Orchestrator | Child { color, initial }
pub credits_spent: f32,
}
pub fn compute_orchestration_rollup(
parent_id: AIConversationId,
history: &BlocklistAIHistoryModel,
) -> Option<OrchestrationCreditRollup>;
```
Implementation notes:
- Returns `None` if the orchestrator has no loaded descendants OR if every eligible agent (orchestrator + descendants) has zero credits. PRODUCT invariants 1, 7.
- Walks descendants via the existing helper extracted from `orchestration_pill_bar.rs` (move it to `app/src/ai/blocklist/orchestration_topology.rs` or similar shared module; re-export from `usage/rollup.rs`).
- Sums each contributor's `credits_spent` for `total_credits`.
- Builds `per_agent` from the orchestrator + each loaded descendant, filters out zero-credit rows, sorts by `credits_spent` descending with spawn-order tie-break.
- Unknown / unloaded descendants are silently skipped (PRODUCT invariant 10 — no footnote, no warning).
- Pure function — no I/O, no GraphQL — runs synchronously on the local model.
### 2. Render the "Credits spent (total)" row with toggle and list
Modifications in `conversation_usage_view.rs`:
- Extend `ConversationUsageView` with:
- `rollup: Option<OrchestrationCreditRollup>` — passed in by the caller (None in `DisplayMode::Settings`).
- `details_expanded: bool` — local UI state, defaults `false`.
- `show_all_clicked: bool` — local UI state, defaults `false`.
- In `render_unified_layout`, when `rollup.is_some()` and `DisplayMode::Footer`:
- The "Credits spent (total)" value uses `rollup.total_credits` (instead of `usage_info.credits_spent`).
- Append a "View details" / "Hide details" toggle element to the value row. On click, flip `details_expanded` and `notify` the view to re-render.
- When `details_expanded`, emit one row per `PerAgentCreditEntry`, rendered via a new helper `render_per_agent_row(entry, appearance)`:
- Leading avatar disc (1216 px) via `render_agent_avatar_disc` / `render_orchestrator_avatar_disc`.
- Display name (label slot) + credits value (value slot), using the existing label/value helpers.
- When `per_agent.len() > 5` and `!show_all_clicked`, render only the first 5 entries followed by a "Show N more" link row (N = `per_agent.len() - 5`). On click of the link, set `show_all_clicked = true` and re-render.
- In `DisplayMode::Settings` and the non-rollup `DisplayMode::Footer` paths, the existing "Credits spent (total)" rendering is preserved unchanged.
- "Credits spent (last response)" rendering is untouched (PRODUCT invariant 3).
### 3. Wire the rollup into the footer construction
The rollup is consumed in two places:
- **Expanded footer.** The caller that constructs `ConversationUsageView::new(...)` for `DisplayMode::Footer` lives near `render_usage_footer` in `output.rs` (locate via `ConversationUsageView::new` call sites; `render_usage_button` at `output.rs:3243` is adjacent). The caller has access to the `BlocklistAIHistoryModel` because the surrounding view already uses it.
- The footer constructor always uses `ConversationUsageView::new_footer_with_rollup`, which holds the parent conversation id and calls `compute_orchestration_rollup` at render time.
- `compute_orchestration_rollup` returns `None` whenever the orchestrator has no loaded descendants or no eligible credits, so the rollup-aware UI naturally collapses to today's exact behavior.
- Settings-mode (`DisplayMode::Settings`) continues to use `ConversationUsageView::new`, which leaves `parent_conversation_id` unset; `rollup()` then short-circuits to `None`.
- **Collapsed pill** (PRODUCT invariant 11). In `render_usage_button` (`output.rs:3243`):
- Call `compute_orchestration_rollup(conversation.id(), history)` unconditionally.
- When the rollup is `Some(_)`, use `rollup.total_credits` instead of `conversation.credits_spent()` for the pill's headline number and for the "has any usage" suppression check (`output.rs:3252-3258`).
- The `(+N)` last-block annotation block (`output.rs:3271-3291`) is unchanged — it continues to read `conversation.credits_spent_for_last_block()` and compare to the headline number. With rollup active, headline ≫ last block, so the annotation appears whenever the orchestrator has had a recent response. This is the intended behavior per PRODUCT invariant 11a.
- Self-gating: when the conversation has no descendants the helper returns `None` and the pill renders exactly as today.
### 4. Reset UI state on footer collapse / reopen
The expanded footer view is created fresh each time `is_usage_footer_expanded` flips to true (the `ConversationUsageView` is constructed in `render_usage_footer`, not held across collapse). This means `details_expanded` and `show_all_clicked` naturally reset on collapse + reopen because the view instance is rebuilt — satisfying PRODUCT invariant 6.
- Verify this assumption during implementation. If the view instance is cached across collapse cycles, add explicit reset logic on the open transition, or hoist the bools to props that the parent rebuilds.
### 5. Self-gating (no feature flag)
There is no dedicated `OrchestrationCreditRollup` feature flag. The rollup activates whenever `compute_orchestration_rollup` returns `Some(_)` and falls through to today's UI whenever it returns `None`:
- Conversations with no locally-loaded descendants → `None` (no children walked, no rollup UI).
- Conversations where every loaded descendant has zero credits → `None` (zero-credit filter empties `per_agent`).
- Settings-mode views → `rollup()` short-circuits on `display_mode != Footer`.
The upstream ability to spawn child agents is still gated by `FeatureFlag::OrchestrationV2`, and the expanded footer surface itself is gated by `FeatureFlag::AgentView`, so the rollup is reachable only when both already permit the user to create and view an orchestration. Adding a third flag on top of those would only have offered a kill-switch; the self-gating data check provides the same safety net (today's UI is preserved whenever the rollup has nothing to add) without the cleanup burden.
### 6. Live updates from child usage changes
The expanded footer must re-render when any contributing agent's `conversation_usage_metadata` changes. Audit during implementation:
- Confirm there is a `BlocklistAIHistoryEvent` (or per-conversation event) that fires when any conversation's `conversation_usage_metadata` is mutated (via `StreamFinished` handling). If yes, ensure the orchestrator's footer view subscription covers all descendants — most likely already true via the existing history-level subscription used by `orchestration_pill_bar.rs` / `child_agent_status_card.rs`.
- If subscriptions cover descendants only via the parent's own view-bound observers, add a coarse "history changed" observation in `render_usage_footer` so the parent re-renders when any descendant updates.
- Worst case: introduce a fine-grained `ChildUsageUpdated { conversation_id }` event emitted on metadata write in `AIConversation` and subscribe from the parent view.
### 7. Tradeoffs and alternatives
- **Client-side aggregation (chosen).** Each loaded descendant's metadata is already on the client. Walk + sum is O(n) with n bounded by the locally-loaded orchestration tree (small in practice). Limitation: remote-only descendants are silently invisible (PRODUCT invariant 10) — acceptable for v1 given the gap is small in practice.
- **Server-side rollup (rejected for v1).** Add a `conversationUsageRollup(parentId)` GraphQL field that walks the run tree on the server. Pros: covers remote-only descendants. Cons: new query lifecycle, second source of truth that can drift from per-conversation metadata, server work to scope. Recommended follow-up if discrepancies prove confusing.
- **Separate "ORCHESTRATION TOTAL" section (rejected, was the prior strawman).** Adding a new section under USAGE SUMMARY duplicates the credit number and visually fragments the footer. The Figma mock integrates the rollup into the existing "Credits spent (total)" row — cleaner and matches the design.
- **Roll up other metrics too (deferred, follow-up).** PRODUCT v1 scope is credits only. The helper can be widened later to include summed `tool_calls`, `files_changed`, etc.
- **Collapsed pill: orchestration total vs self total.** Chose orchestration total (PRODUCT invariant 11) so the true cost is visible at a glance without expanding. The `(+N)` delta stays as orchestrator's own last-block credits because that is the only meaningful per-response delta available without tracking inter-agent timing.
## Testing and validation
Unit tests in `app/src/ai/blocklist/usage/rollup_tests.rs` (mod-included via `#[cfg(test)] #[path = "rollup_tests.rs"] mod tests;`):
- Orchestrator with no loaded descendants → `compute_orchestration_rollup` returns `None`. (PRODUCT invariant 1)
- Orchestrator + 1 child with credits → rollup `total_credits` = parent + child; `per_agent` has 2 entries sorted descending. (invariants 2a, 5a, 5b)
- Orchestrator + 3 children, mixed credits including a zero-credit child → zero-credit child is excluded; 3 entries returned sorted descending. (invariants 5b, 5d)
- Parent → child → grandchild — rollup includes all three transitively. (invariant 2a)
- 6 contributors → `per_agent.len() == 6`; caller logic verified separately in the renderer test (5 shown + "Show 1 more"). (invariants 5e, 5f)
- 1 contributor with zero credits → returns `None`. (invariant 7)
- Spawn-order tie-break: two children with equal credits → child spawned earlier sorts first. (invariant 5b)
- Unloaded descendant id present in topology but missing from `conversations_by_id` → silently skipped, no contribution to the rollup. (invariant 10)
Renderer tests in `conversation_usage_view_tests.rs`:
- `DisplayMode::Footer` + `Some(rollup)` renders the "Credits spent (total)" value as `rollup.total_credits` and shows "View details ▼". Clicking expands and shows "Hide details ▲". (invariants 2a2d)
- Per-agent list with 5 rows renders all 5, no "Show N more". (invariant 5e)
- Per-agent list with 6 rows renders first 5 plus "Show 1 more"; clicking the link reveals the 6th and removes the link. (invariant 5f)
- Footer collapse + reopen rebuilds the view with `details_expanded == false` and `show_all_clicked == false`. (invariant 6)
- `DisplayMode::Settings` renders no toggle and no per-agent list even if a rollup is passed (defensive). (invariant 17)
- Conversation with no descendants (`rollup() == None`): row renders exactly as today (no toggle, no breakdown). (invariant 13)
- "Credits spent (last response)" row is unchanged regardless of rollup state. (invariant 3)
Collapsed-pill tests in `output_tests.rs` (or equivalent next to `render_usage_button`):
- Conversation with a non-empty rollup: pill headline number equals `rollup.total_credits`. (invariant 11)
- Conversation with rollup `None` (no descendants, or only zero-credit descendants): pill headline number equals `conversation.credits_spent()` (today's behavior). (invariants 11, 13)
- `(+N)` annotation renders using `credits_spent_for_last_block` regardless of rollup state. (invariant 11a)
- "Hide button entirely" suppression is evaluated against rollup total when applicable. (invariant 11b)
Manual verification:
- Start a local orchestration with three children (`oz-local` or in-app orchestrator), wait for each child to consume credits:
- The collapsed footer pill shows the orchestration total, not just the orchestrator's self credits. (invariant 11)
- Expand the footer: the "Credits spent (total)" row shows the same orchestration total. (invariants 2a, 7)
- "View details" reveals the orchestrator + children sorted by credits descending. (invariants 5a, 5b)
- Trigger a child to finish a response mid-view; confirm both the pill number and the expanded rollup update without re-expanding. (invariants 7, 11)
- Spawn 7+ children; confirm the list shows 5 + "Show N more"; click and confirm all rows visible and link gone. (invariant 5f)
- Collapse + reopen the footer; confirm the list is back to truncated (and "View details" is closed). (invariant 6)
- Open the same expanded footer on a non-orchestrator conversation (or one with no loaded descendants) and confirm the row renders exactly as it does today, with no "View details" affordance. (invariant 13)
Run `./script/presubmit` before pushing. Formatting, clippy, and tests must pass.
## Parallelization
Single change, single repo, single owner. The aggregation helper, view extension, and feature-flag wiring touch the same handful of files and require a shared mental model. A parallel split would manufacture coordination overhead with no real wall-clock savings. This is best done by one local agent in this checkout (`/Users/matthew/src/rollup-orch-credit-usage/warp` on branch `matthew/rollup-orch-credit-usage`). No `run_agents` proposed for v1.
## Risks and mitigations
- **Tree walk cost on every render.** Orchestration trees observed in practice are small (≪100 nodes); the walk is O(n) with negligible constants. The collapsed pill renders every paint, so memoize the rollup on `BlocklistAIHistoryModel` (or cache on the parent view) if profiling shows it matters; invalidate on the same event used to drive live updates.
- **Collapsed-pill number jumps when children spawn.** Users watching only the pill might be surprised by sudden growth as children start spending. Acceptable trade for surfacing real cost at a glance — the existing `(+N)` delta still shows the orchestrator's own most-recent response so users can attribute the jump.
- **Display name fallback.** Some children may have `agent_name == None` (e.g. v1 orchestration or pre-naming spawn). Fallback to a short label like "Child agent" or the conversation's `task_id` short form. Pick during implementation; document in `DECISIONS.md` if it matters.
- **Pre-rollup metadata.** Forked or restored conversations may briefly have stale `conversation_usage_metadata` until the next `StreamFinished` or GraphQL hydrate completes. Acceptable: the next render corrects the rollup.
## Follow-ups
- Roll up other metrics (tool calls, code edits) — v1 scope is credits only.
- Server-side rollup query so remote-only descendants are always included (PRODUCT invariant 10).
- Make per-agent rows clickable (open the agent's conversation) — invariant 5g currently rules this out for v1.
- Show a rollup credit chip on the orchestrator pill in the orchestration pill bar (`orchestration_pill_bar.rs` hover card has room at lines 1044-1314).
+98
View File
@@ -0,0 +1,98 @@
# Orchestration Pill Bar Pinning — Tech Spec
Linear: [QUALITY-672](https://linear.app/warpdotdev/issue/QUALITY-672)
PR: [#10777](https://github.com/warpdotdev/warp/pull/10777)
## Context
The orchestration pill bar renders a horizontal row of pills above the agent view header — one for the orchestrator and one for each child agent. With long-running parent agents that spawn many children, the row gets long enough that frequently-used child agents scroll off-screen, and the user has no way to keep them anchored.
The feature adds pinning so frequently-used children stay anchored to the leading section of the bar, with pin state shared across panes and persisted across app restarts.
### Relevant files
**Pill bar rendering**
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs``OrchestrationPillBar` view (per-`TerminalView`). `pill_specs()` builds the spec list, `render_pill()` renders each pill, `View::render()` partitions and lays out the bar.
**Per-conversation persistence**
- `app/src/ai/agent/conversation.rs:262-301``AIConversation::new()` initializer; `new_restored()` rehydrates from `AgentConversationData`.
- `app/src/ai/agent/conversation.rs:2921-3008``write_updated_conversation_state()` builds an `UpdateMultiAgentConversation` `ModelEvent` and sends it to the SQLite writer thread.
- `crates/persistence/src/model.rs``AgentConversationData` (the JSON blob persisted per conversation).
**History model**
- `app/src/ai/blocklist/history_model.rs:462-477``update_event_sequence()`, the existing template for a "mutate one field + persist" method.
- `app/src/ai/blocklist/history_model.rs:1655-1732``RemoveConversation` / `DeletedConversation` events emitted from `remove_conversation_from_memory` and `delete_conversation`.
**Startup and logout**
- `app/src/lib.rs:~1683``BlocklistAIHistoryModel` registered at startup with the restored `multi_agent_conversations` vec.
- `app/src/auth/mod.rs:213-281``log_out()` calls `.reset()` on all singletons that hold user state.
**Icons**
- `crates/warp_core/src/ui/icons.rs``Icon` enum; new SVGs bundled at `app/assets/bundled/svg/`.
## Proposed changes
### 1. Per-conversation persistence
**File**: `crates/persistence/src/model.rs`
Add `pinned: bool` to `AgentConversationData` with `#[serde(default, skip_serializing_if = "is_false")]`. The `default` makes existing rows deserialize cleanly; the `skip_serializing_if` keeps unpinned conversations from bloating the persisted JSON.
**File**: `app/src/ai/agent/conversation.rs`
Add `pinned: bool` to `AIConversation`. Wire it through:
- `new()` initializer (defaults to `false`).
- `new_restored()` reads `conversation_data.pinned`.
- `write_updated_conversation_state()` includes `pinned: self.pinned` in the emitted `AgentConversationData`.
- New accessors `is_pinned()` / `set_pinned(bool)`.
**File**: `app/src/ai/blocklist/history_model.rs`
Add `set_conversation_pinned(conversation_id, pinned, ctx)` that updates the in-memory `AIConversation.pinned` and calls `write_updated_conversation_state(ctx)`. Mirrors the existing `update_event_sequence()` pattern. Early-return with `log::warn!` when the conversation isn't loaded so dropped writes are visible in logs rather than silent.
### 2. Cross-pane singleton (`OrchestrationPinModel`)
**File**: `app/src/ai/blocklist/agent_view/orchestration_pin_model.rs` (new)
New `SingletonEntity`. Holds `pinned: HashSet<AIConversationId>` as an in-memory mirror of the per-conversation `pinned` flag. Emits `OrchestrationPinEvent::PinSetChanged` on toggle and on history-driven prune.
Why singleton, not per-`TerminalView`:
- Pin state needs to be cross-pane (pinning in one pane should immediately reflect in every other pane's bar).
- Centralizes deleted-conversation cleanup so each pill bar doesn't race to clobber sibling panes' sets.
API:
```rust path=null start=null
pub fn new(initial_pinned: HashSet<AIConversationId>, ctx: &mut ModelContext<Self>) -> Self;
pub fn is_pinned(&self, conversation_id: &AIConversationId) -> bool;
pub fn toggle_pin(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext<Self>);
pub fn reset(&mut self); // called from log_out
```
`toggle_pin` flips the in-memory set and calls `BlocklistAIHistoryModel::set_conversation_pinned` to persist. `new` subscribes to `BlocklistAIHistoryEvent::{RemoveConversation, DeletedConversation}` and removes the affected id from `pinned` (emitting `PinSetChanged` only if the set actually changed).
**File**: `app/src/lib.rs`
Register the singleton at startup, after `BlocklistAIHistoryModel`. Seed `initial_pinned` by walking the restored `multi_agent_conversations` vec, deserializing `AgentConversationData` for each, and collecting ids where `data.pinned == true`.
**File**: `app/src/auth/mod.rs`
In `log_out`, call `OrchestrationPinModel::handle(app).update(app, |model, _| model.reset())` alongside the existing `*::reset()` calls. The persisted per-conversation flags are wiped by the existing SQLite reset; this just clears the in-memory mirror so the next user doesn't inherit the previous account's pins.
### 3. Pill bar rendering
**File**: `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs`
- Subscribe to `OrchestrationPinModel`'s `PinSetChanged` event so every pane's bar re-renders together.
- Partition pills in `View::render()`: orchestrator → pinned children (spawn order) → vertical divider (only when both sides are non-empty) → unpinned children (spawn order).
- Add `PillKind::Child`'s pin glyph behavior in `render_pill()`:
- At rest: avatar disc shown for both pinned and unpinned pills.
- On hover: avatar swaps to a pin glyph — outline (`Icon::Pin`) when unpinned, solid (`Icon::PinFilled`) when pinned.
- Pin state is also communicated by position (left of the divider).
**Click-handler scoping (subtle):** Only wrap the avatar/pin-glyph element in `Hoverable` *and* attach the `TogglePin` click handler when `show_pin_glyph` is true. If we wrap unconditionally, the inner `Hoverable` steals clicks during the 300ms `with_hover_in_delay` window — a user clicking the avatar to navigate would land on the toggle. With the conditional wrap, clicks on the avatar (rest state) bubble to the outer pill's navigate handler, and clicks on the pin glyph (hover state) toggle pin.
### 4. Icons
**Files**: `app/assets/bundled/svg/pin-01.svg`, `app/assets/bundled/svg/pin-filled.svg` (new), `crates/warp_core/src/ui/icons.rs`
Add `Icon::Pin` (outline) and `Icon::PinFilled` (solid) variants, bundled from Figma SVGs.
## Testing and validation
### Unit tests
**`orchestration_pin_model_tests.rs`** (new):
- `toggle_pin_in_set_flips_membership_for_each_call` — pure-function helper test for toggle semantics.
- `toggle_pin_in_set_only_affects_target_id` — guards against accidental cross-id mutation.
- `toggle_pin_persists_pinned_state_to_sqlite_event` — e2e: wires up settings + a mock `GlobalResourceHandles` channel, restores a conversation, calls `toggle_pin`, asserts (a) `AIConversation.is_pinned()` flips, (b) an `UpdateMultiAgentConversation` `ModelEvent` is sent with `conversation_data.pinned == true`, (c) a second toggle emits a follow-up event with `pinned: false`.
**`crates/persistence/src/model.rs`** — round-trip tests for `AgentConversationData.pinned`:
- Default deserialization (no `pinned` field in JSON) → `pinned: false`.
- `pinned: false` is skipped in serialized output.
- `pinned: true` round-trips correctly.
### Manual validation
- Start two panes with the same parent conversation. Pin a child in pane A → verify the child immediately moves to the leading section in pane B's bar.
- Pin a child, then quit and relaunch Warp → verify the child is still pinned (persistence).
- Hover a pinned child → solid pin glyph appears, hover background; click → unpinned and moves back across the divider.
- Hover an unpinned child → outline pin glyph appears; click → pinned.
- Click the avatar area of a pinned child during the hover-in delay window → verify navigation happens (not toggle).
- Delete a pinned conversation → verify it's removed from the pin set in all panes (no orphan).
- Log out and log in as a different user → verify pins do not carry over.
### Presubmit
`cargo fmt`, `cargo build -p warp`, `cargo nextest run -p warp orchestration_pin_model`, `cargo clippy -p warp --tests --all-features`.
## Risks and mitigations
**Risk: Click during the 300ms hover-in delay toggles pin instead of navigating.** First implementation had this bug.
*Mitigation:* Inner `Hoverable` (with the toggle handler) is only present when `show_pin_glyph` is true. At rest, clicks bubble to the outer navigate handler. Covered by manual validation; consider an integration test if regressions appear.
**Risk: `set_conversation_pinned` no-ops silently when the conversation isn't loaded into `conversations_by_id`.** This can happen for historical conversations.
*Mitigation:* `log::warn!` on the early-return path so dropped writes are visible. The in-memory `OrchestrationPinModel` set still toggles, so the UI is correct until that conversation rehydrates; the persisted state will simply be missing until the next time the conversation is loaded.
**Risk: Logged-out user's pins persist into the next login.**
*Mitigation:* `OrchestrationPinModel::reset()` is invoked from `log_out` alongside the other singleton resets. The SQLite-level reset that runs alongside logout wipes the persisted `pinned` flags.
**Risk: Forked conversations don't carry pin state forward.** `fork_conversation*` paths in `history_model.rs` build `AgentConversationData` with `pinned: false` regardless of source.
*Mitigation:* Intentional — forks are user-initiated "fresh start" semantics. Not a hidden gotcha because the new conversation has a different `AIConversationId` and isn't expected to share pin state.
## Parallelization
Not used. Single-PR change spanning ~12 files, mostly tightly coupled: the persistence schema change (`AgentConversationData.pinned`), the conversation accessor wiring, and the singleton seed in `lib.rs` all have to land together to avoid an intermediate broken state. The pill-bar UI work depends on the singleton existing. Splitting into sub-agents would just add coordination overhead for a change one engineer can land in a single sitting.
## Follow-ups
- **Drag-to-reorder within the pinned section.** Today pinned pills are in spawn order; the user can't manually reorder.
- **Pin from overflow menu / keyboard shortcut.** Pin is only reachable via hover-click. A right-click menu item or `cmd+shift+P` style binding would help keyboard-first users.
- **Telemetry on pin/unpin actions** to measure feature adoption.
+70
View File
@@ -0,0 +1,70 @@
# Custom Host Picker for Orchestration
Linear: [QUALITY-701](https://linear.app/warpdotdev/issue/QUALITY-701)
## Summary
Adds a host picker to the orchestration UI so a user can choose where their cloud child agents run. Today the host is hardcoded to the default Warp cluster; this lets users target a self-hosted worker host, see the most recently used custom host, and pre-select an admin-configured workspace default. The behavior mirrors the Oz webapp's host selector, adapted to the desktop client's compact picker chrome.
## Design
No Figma mock. Design context lives in this Slack thread: https://warpdev.slack.com/archives/C0AAMT5TKC2/p1778542414211539?thread_ts=1778525276.139389&cid=C0AAMT5TKC2 — this implementation follows that direction but keeps things deliberately simpler for the first cut (compact dropdown with an inline custom-mode editor, reusing the existing orchestration picker chrome). Design polish can be a follow-up once the feature is in users' hands.
## Behavior
### Surface
1. The host picker appears next to the model, harness, and environment pickers in the orchestration UI. It is present in both the orchestrate confirmation card and the plan-card orchestration block. Both surfaces show the same options, the same selection, and the same custom-mode editor.
2. The picker is only visible when the execution mode is Cloud (Remote). In Local mode the host concept is not user-facing.
3. The picker visually matches the other orchestration pickers in the same row: same height, border, corner radius, background, padding, and font.
### List mode
4. By default the picker renders as a dropdown showing the currently selected slug. Clicking it opens a menu with the following entries, in this order:
1. Workspace default slug, when the team has one configured, with a "Default" badge.
2. `warp` (the default Warp cluster), always present.
3. The user's most recent custom host slug, when set, rendered as a plain slug (no badge).
4. A `Custom host…` entry that switches the picker into custom mode.
5. Duplicate rows are suppressed. If the recent custom host equals either `warp` or the workspace default, it does not get its own row.
6. When the user picks `warp`, the workspace default, or a recent slug, the picker closes and the selection is sent to the parent. The selected entry shows in the picker's collapsed state. If the workspace has a configured default, selecting it shows the "Default" badge in the collapsed state too.
7. Clicking outside the open menu, or pressing Escape, closes the menu without changing the selection.
### Custom mode
8. Selecting `Custom host…` swaps the picker top bar for an inline text editor, pre-filled with the current slug (or empty when the current slug is `warp`), and focuses the editor. A small cancel button sits at the right of the editor.
9. Inside the editor the user can type any non-empty slug. Pressing Enter or blurring the editor commits the trimmed value. Pressing Escape or clicking the cancel button reverts to the previous selection without committing.
10. Committing an empty buffer is treated as a revert (no change to the previous selection).
11. Typing `warp` (case-insensitive) and committing collapses back to the standard `warp` selection rather than persisting `warp` as a custom value.
12. When a non-empty, non-`warp` slug is committed, it becomes the current selection and is promoted to the "recent" row in the menu so it stays visible on the next paint. The slug is also persisted (see invariant 17) so it survives across cards and across app restarts.
13. While the editor is in custom mode, the editor's text is vertically centered within the picker box and the box sits at the same y offset as the other pickers in the row.
### Selection model
14. The picker always has a non-empty selection. Empty input from any source (initial state, blur, blank stream) resolves to `warp`.
15. When an external caller sets a slug that doesn't match any known menu option (warp, workspace default, recent), the picker switches into custom mode pre-filled with that slug instead of showing a missing menu entry.
16. The picker exposes the workspace default behavior: when a workspace default is configured and no explicit selection exists yet, the picker pre-selects the workspace default rather than `warp`. A developer-only `WARP_CLOUD_MODE_DEFAULT_HOST` environment variable overrides the workspace default for local testing.
### Persistence and recency
17. When the user commits a custom slug, it is persisted as the "last selected host" so the next plan card or confirmation card shows it as the "recent" entry. `warp` and empty values are never persisted as recent (the warp entry is always present unconditionally).
18. The recent slug is deduplicated against the workspace default. If the user's most recent slug happens to equal the workspace default, the menu shows only the default row; no separate recent row appears.
### Coordination with the rest of the orchestration UI
19. When the user picks or commits a slug, the new value is reflected in the same edit state that powers the other orchestration pickers, and is used by the eventual `RunAgents` dispatch as the `worker_host` field. The plan card additionally persists the new value to the orchestration config snapshot for that plan.
20. The picker's open menu paints above sibling pickers in the row so it doesn't visually collide with the Environment or Base model picker rendered below it. In the confirmation card the menu opens upward (matching the other dropdowns in that card); in the plan card the menu paints in an overlay layer above siblings.
21. When the menu closes (selection, dismissal, or custom-mode commit), parent input focus returns to wherever it was so the user can continue typing without an extra click.
+132
View File
@@ -0,0 +1,132 @@
# Custom Host Picker — Tech Spec
Linear: [QUALITY-701](https://linear.app/warpdotdev/issue/QUALITY-701)
Companion product spec: `specs/QUALITY-701/PRODUCT.md`
## Context
The orchestration UI (orchestrate confirmation card and plan-card orchestration block) hosts a row of pickers — model, harness, environment — that drive child agent dispatch. Until now there was no UI to choose the worker host: the dispatched `RunAgents` request always carried `worker_host = "warp"`, which routes to the default Warp cluster. Customers running self-hosted workers had no way to target them from the desktop client; the Oz webapp's host selector is the only existing entry point.
The picker chrome used by the other orchestration pickers is built around the standard `Dropdown` view in `app/src/view_components/dropdown.rs`, styled via `picker_styles()` in `orchestration_controls.rs`. Both card views construct their picker handles via shared helpers in `orchestration_controls.rs` and store them in `OrchestrationPickerHandles`. The worker-host slug already flows end-to-end as a field on `OrchestrationEditState` and on `RunAgentsExecutionMode::Remote`; the missing piece is the UI control that lets a user change it.
The workspace default slug is already exposed to the client as `defaultHostSlug` on `AmbientAgentSettings` and surfaced via `UserWorkspaces::default_host_slug()`. There is also persisted per-user "last selected host" state in `CloudAgentSettings.last_selected_host`. The Oz webapp's `HostSelector` (`client/packages/agents/src/components/HostSelector.tsx`) is the canonical reference for option ordering, default-host preselection, and recent-host surfacing.
### Relevant files
**New**
- `app/src/ai/blocklist/inline_action/host_picker.rs` — the `HostPicker` view itself (list mode + custom mode).
- `app/src/ai/blocklist/inline_action/host_picker_tests.rs` — unit tests for the pure helpers.
**Shared picker plumbing (modified)**
- `app/src/ai/blocklist/inline_action/orchestration_controls.rs``OrchestrationPickerHandles` gains a `host_picker` handle; new helpers `populate_host_picker`, `resolve_default_host_slug`, `resolve_recent_host_slug`, and `persist_host_selection`; `sync_picker_selections` is taught to drive the host picker.
- `app/src/ai/blocklist/inline_action/mod.rs` — registers the new module.
**Call sites (modified)**
- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs` — confirmation card: builds the picker, opens its menu upward, subscribes to its events, re-dispatches `WorkerHostChanged`.
- `app/src/ai/document/orchestration_config_block.rs` — plan card: builds the picker, opts the inner menu into the overlay layer, subscribes to its events, dispatches `WorkerHostChanged`, persists field changes.
**Reference**
- `client/packages/agents/src/components/HostSelector.tsx` (warp-server) — the webapp's host selector.
## Proposed changes
### 1. New `HostPicker` view
A single non-generic view that internally switches between two render modes:
- **List mode** wraps an inner `Dropdown<InternalAction>` styled identically to the other orchestration pickers (`picker_styles()`). The menu is populated with the workspace default (badged "Default"), `warp`, the most-recent custom slug, and a "Custom host…" entry, in that order. Selecting any known item dispatches `InternalAction::SelectKnown(slug)`; selecting "Custom host…" dispatches `InternalAction::EnterCustomMode`.
- **Custom mode** swaps the dropdown top bar for an inline single-line `EditorView` plus a small cancel button. Enter or blur commits via `commit_custom`; Escape or cancel reverts via `cancel_custom`. The editor is wrapped in a `Flex::column` with `MainAxisAlignment::Center` so the glyphs sit at the vertical center of the picker box (otherwise the row's tight cross-axis constraint forces the editor to fill the height and the text renders flush to the top). The custom-mode container is wrapped in an outer `Container` with vertical margins equal to `DROPDOWN_PADDING`, mirroring the standard `Dropdown` view's outer wrapping so the custom box sits at the same y offset as the other pickers in the row.
The picker emits two public events:
- `HostPickerEvent::HostChanged { slug }` — sent whenever the current selection changes.
- `HostPickerEvent::Closed` — sent whenever the menu closes or the editor blurs, so the parent can refocus its own input.
Public API:
- `set_options(default_host, recent_host, ctx)` — replaces the menu rows.
- `set_selected(slug, ctx)` — sets the displayed slug; unknown slugs switch into custom mode pre-filled with the slug.
- `set_use_overlay_layer(bool, ctx)` — forwarded to the inner dropdown.
- `set_menu_position(element_anchor, child_anchor, ctx)` — forwarded to the inner dropdown.
Two subtleties worth noting in the implementation:
- The inner dropdown's `DropdownEvent::Close` is suppressed while the picker is transitioning into custom mode. If we let it through, the parent card refocuses itself, blurs the editor we just focused, and the resulting commit-on-blur immediately reverts custom mode — making "Custom host…" feel like a no-op.
- When the user types `warp` into custom mode, `commit_custom` collapses back to the standard `warp` selection rather than persisting `warp` as a custom value. This avoids the asymmetric case where `current_slug` is a casing variant of `warp` that doesn't match any menu label.
Pure helpers (`build_menu_items`, `menu_label_for`, `normalize_slug`) live at the bottom of the module and are unit-tested without spinning up a view context.
### 2. Shared orchestration helpers
`OrchestrationPickerHandles` gets a new `host_picker: Option<ViewHandle<HostPicker>>` field. `sync_picker_selections` is taught to call `picker.set_selected(...)` with the current `worker_host` whenever the edit state changes; this handles both initial population and subsequent changes from other pickers (e.g. mode toggle resetting host to `warp`).
Four new free functions in `orchestration_controls.rs`:
- `populate_host_picker(picker, initial_host, ctx)` — reads the workspace default and recent slug, calls `picker.set_options(...)`, then `picker.set_selected(initial_host)`. Empty input falls back to `warp`. Used by both card views during `ensure_pickers`.
- `resolve_default_host_slug(ctx) -> Option<String>` — returns the workspace default slug, honoring the developer-only `WARP_CLOUD_MODE_DEFAULT_HOST` env var override, otherwise reading from `UserWorkspaces::default_host_slug()`. Mirrors the single-agent ambient flow.
- `resolve_recent_host_slug(ctx) -> Option<String>` — returns the persisted last-selected custom slug, deduplicated against `warp` and the workspace default (so the menu doesn't show a duplicate row).
- `persist_host_selection(worker_host, ctx)` — writes the slug to `CloudAgentSettings.last_selected_host`. Skipped for empty values and for `warp` so those never become "recent" entries.
Both card views also pre-fill defaults when restoring a Remote config with an empty host: prefer the workspace default over the bare `warp` fallback so self-hosted teams see their default pre-selected, matching the Oz webapp.
### 3. Confirmation card wiring (`run_agents_card_view.rs`)
`ensure_pickers` constructs a `HostPicker` for the new `host_picker` slot and:
- Calls `picker.set_menu_position(TopLeft, BottomLeft)` so the open menu flips upward, matching the other dropdowns in this card (which use `set_upward_menu_position` for the same reason). Without this the menu visually collides with the Environment / Base model rows below.
- Calls `populate_host_picker` to seed options and selection.
- Subscribes to `HostPickerEvent`: `HostChanged` re-dispatches the existing `RunAgentsCardViewAction::WorkerHostChanged`; `Closed` refocuses the card.
The existing `WorkerHostChanged` handler updates `state.orch.worker_host` and calls `oc::persist_host_selection`, so any path that ends in a host change persists the slug.
### 4. Plan-card wiring (`orchestration_config_block.rs`)
`ensure_pickers` constructs a `HostPicker` for the new `host_picker` slot and:
- Calls `picker.set_use_overlay_layer(true)` so the menu paints above siblings, matching the other pickers in this view (which all opt into the overlay layer).
- Calls `populate_host_picker` to seed options and selection.
- Subscribes to `HostPickerEvent::HostChanged` to dispatch `OrchestrationConfigBlockAction::WorkerHostChanged`, which updates the edit state, calls `oc::persist_host_selection`, and `apply_field_change` (writes the new value into the plan's stored `OrchestrationConfig`).
### 5. No other call sites
The `worker_host` field already exists on `OrchestrationEditState` and on `RunAgentsExecutionMode::Remote`, so no downstream changes (dispatch, server marshalling, auto-launch matching) are needed. The previously-hardcoded `"warp"` value flows through the same code paths as any user-selected slug.
## Testing and validation
### Unit tests (`host_picker_tests.rs`)
The pure helpers are tested directly without a view context. Covers product invariants 4, 5, 11, 14, 16, 18.
- `build_menu_items` with no default and no recent → only `warp` + `Custom host…`. (Behavior 4)
- `build_menu_items` with default set → default row first, badged; then `warp`; then `Custom host…`. (Behavior 4)
- `build_menu_items` with recent set → `warp` first; then recent as plain slug; then `Custom host…`. (Behavior 4)
- `build_menu_items` dedups when recent equals default. (Behavior 5)
- `build_menu_items` dedups when recent equals `warp`. (Behavior 5)
- `build_menu_items` warp entry dispatches `SelectKnown("warp")`. (Behavior 6)
- `build_menu_items` custom entry dispatches `EnterCustomMode`. (Behavior 8)
- `menu_label_for` picks the "Default" badge when the slug matches the workspace default. (Behavior 6)
- `menu_label_for` returns plain slug for `warp`. (Behavior 6)
- `menu_label_for` returns plain slug for an unknown value (custom-mode display). (Behavior 15)
- `normalize_slug` trims whitespace and falls back to `warp` on empty input. (Behavior 14)
### Manual validation
The view-driven behaviors (custom-mode commit, blur, focus return, layer interaction with sibling pickers) are covered by manual smoke testing rather than view-level tests:
- **Behavior 1, 2, 3**: Open a plan with orchestration approved and an orchestrate confirmation card; verify the host picker is present in both surfaces, only in Cloud mode, and visually matches the model / harness / environment pickers.
- **Behavior 4, 5, 6**: With and without `defaultHostSlug` configured (toggle via SQL on the local `organization_settings` table, or via `WARP_CLOUD_MODE_DEFAULT_HOST`), verify the dropdown contents and ordering, the "Default" badge, and the badge appearing in the collapsed top bar.
- **Behavior 8, 9, 10, 11, 12**: Open custom mode, verify the editor is pre-filled and focused; type a slug, press Enter; reopen the menu and verify the slug now appears as a recent entry. Repeat with Escape and with the cancel button. Try committing an empty buffer and the literal string `warp` / `WARP`.
- **Behavior 13**: Visually compare the custom-mode box to its neighbours; the editor text should be vertically centered and the box should sit at the same y as the sibling pickers.
- **Behavior 15**: Use the developer override (`WARP_CLOUD_MODE_DEFAULT_HOST=some-unknown-slug`) and verify the picker boots into custom mode pre-filled with the slug.
- **Behavior 16, 17, 18**: Pick a custom slug, dismiss the card, then reopen another plan / confirmation card; verify the slug appears as the recent entry. With a workspace default set, verify the recent entry deduplicates against it.
- **Behavior 19**: Pick a non-`warp` slug, dispatch the agents, and verify the worker-host slug reaches the worker. End-to-end smoke against a local Oz stack with a self-hosted worker registered as `local-dev` is the canonical check; worker logs show `task_claimed worker_id:"local-dev"` when the custom slug is routed correctly.
- **Behavior 20**: Open the menu in each surface and confirm it doesn't visually overlap the Environment or Base model rows.
- **Behavior 21**: After every menu close or custom-mode commit, the input box of the parent card should regain focus.
### Presubmit
`cargo fmt`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, and the host_picker nextest suite all pass clean. Run `./script/presubmit` before opening the PR.
## Parallelization
Not used. The implementation is a single new view file plus thin wiring at two call sites; the work is sequentially tightly coupled (helpers feed the view, the view feeds both call sites) and small enough that splitting across agents would add coordination overhead without saving wall-clock time.
+108
View File
@@ -0,0 +1,108 @@
# Inline Create-API-Key Flow on Orchestration Cards
Linear: [QUALITY-702](https://linear.app/warpdotdev/issue/QUALITY-702)
## 1. Summary
When an orchestration card asks the user to start additional child agents under a non-Oz harness (Claude Code, Codex, etc.) and the user has no managed API key for that harness yet, the card now lets the user create one without leaving the conversation. A workspace-level modal hosts the same create-key form used by cloud-mode FTUX, scoped to the card's current harness. The card's API-key picker also gains a permanent "+ New API key…" entry users can click any time. Until the user makes an explicit choice — either picking a managed key or clicking "Inherit key from environment" — the Accept button is disabled with a tooltip explaining why.
## 2. Problem
Orchestration cards have always exposed an "API key" picker for non-Oz harnesses, but the picker assumed at least one managed key already existed for the active harness. When it didn't, the dropdown was effectively empty and there was no in-card path to create one. Users had to drop out of the conversation, find the cloud-mode FTUX, create a key, then come back and re-trigger the card. Worse, Accept would silently dispatch with whatever was inherited from the worker environment, which usually wasn't what the user wanted and often failed downstream. This made the very first orchestration attempt under a new harness a dead end for many users.
## 3. Goals
- Let users create a managed API key directly from any orchestration card whose active harness needs one.
- Give users a permanent, discoverable affordance to add a new key even after they already have some.
- Block Accept until the user has either picked a managed key or explicitly chosen to inherit, with a clear, in-context reason.
- Auto-prompt for key creation exactly once per harness/execution-mode combination so the first-run experience is opinionated without becoming naggy.
- Match the cloud-mode create-key UX exactly so users see one consistent form regardless of where they invoke it.
## 4. Non-goals
- No changes to how managed keys are stored, encrypted, or transmitted to harness processes.
- No new key types or harness integrations beyond what cloud mode already supports.
- No changes to the cloud-mode (single-agent) FTUX user experience.
- No changes to Oz, which has no concept of per-harness API keys.
- No per-conversation or per-plan key overrides — keys remain user-scoped and persist via the same `last_selected_auth_secret` setting used by cloud mode.
## 5. User experience
### Picker contents
The auth-secret picker on both the `RunAgents` confirmation card and the plan card's orchestration config block now contains, in this order:
1. **Inherit key from environment** — always present. Selecting it records an explicit "inherit" choice; child agents will pick up credentials from the worker's shell environment.
2. **Each managed key** the user has for the active harness, in the order the server returns them.
3. **+ New API key…** — present for any harness that supports at least one managed-secret type. Selecting it opens the workspace create-key modal scoped to that harness.
While the harness's key list is still loading, the picker shows a single disabled "Loading…" entry alongside Inherit. If the fetch fails it shows "Unable to load secrets" instead.
### Picker trigger label
The label on the closed picker reflects the user's current selection:
- A managed key by name when one has been picked.
- "Inherit key from environment" when the user explicitly chose to inherit.
- "+ New API key…" when the user has made no choice yet and the harness supports managed secrets. (Falls back to the inherit label for harnesses with no managed-secret types.)
The label always renders in the dropdown's default text color — no greyed-out placeholder treatment.
### Auto-open of the create-key modal
The first time a card renders for a non-Oz harness whose managed-key list is loaded and empty, the workspace pops the create-key modal automatically. This happens at most once per card per harness/execution-mode combination. Cancelling or skipping the modal leaves the picker on "+ New API key…" and the Accept gate firing; switching harness or toggling Local/Cloud resets the one-shot so the new harness gets its own fresh prompt.
The auto-open is suppressed for cards that are not in an interactive confirmation state: cards that are denied, already auto-launching, currently spawning, restored from history, or whose action is already finished or running async. The auto-open also waits for the secrets list to actually resolve to "loaded and empty" — it does not fire while the list is in flight, has not been fetched, or failed.
### Accept gate
The Accept button is disabled when the user has not yet made an auth-secret choice (the picker shows "+ New API key…"). The button's tooltip explains why, e.g. "Pick an API key or choose to inherit from the environment before accepting." Picking either a managed key or explicitly choosing Inherit immediately re-enables Accept.
### Create-key modal
The modal is workspace-owned and blocks the rest of the UI while open. Internally it hosts the same `AuthSecretFtuxView` component used by cloud-mode FTUX, parameterized with the card's current harness. The modal lets the user:
- Choose a key type (when the harness has more than one).
- Enter the key value and a display name.
- Submit, cancel, or skip (skip is hidden in this modal mode — the picker's existing "Inherit key from environment" entry plays that role).
When submission succeeds, the modal closes, the new key is persisted as the active selection for that harness via the same `last_selected_auth_secret` setting cloud mode uses, and the originating card automatically adopts the new key as its selection. The Accept gate immediately clears.
On cancel, the modal closes and the card's state is left untouched (picker stays on "+ New API key…" so the user can try again). On submission failure, the modal stays open with an inline error so the user can correct and retry.
### Harness switching
When the user changes the harness on a card, the one-shot guard resets and the new harness's selection state is re-read from persisted settings. If the new harness also has no managed keys, the modal will auto-open again — once — for that harness.
## 6. Success criteria
- A card for a non-Oz harness with zero managed keys auto-opens the create-key modal exactly once.
- Cancelling the modal does not re-pop it on the next render or notify cycle for the same card.
- The picker always shows "+ New API key…" as an actionable entry for harnesses with at least one managed-secret type.
- Selecting "+ New API key…" opens the modal regardless of whether managed keys already exist.
- Accept is disabled with an explanatory tooltip whenever the picker shows "+ New API key…".
- Successful key creation auto-selects the new key on the originating card and re-enables Accept.
- Cancelling the modal leaves the card's selection unchanged and the Accept gate still firing.
- Switching harness or toggling Local/Cloud on a card resets the one-shot auto-open guard for the new state.
- Cloud-mode (single-agent) FTUX behavior is unchanged end to end.
- Restored cards, denied cards, spawning cards, auto-launched cards, and terminal-state cards never auto-open the modal.
## 7. Validation
### Automated
- `cargo check -p warp`
- `cargo fmt`
- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`
### Manual
- Clear all managed Claude Code keys, then ask the agent to orchestrate with Cloud + Claude Code. Confirm the modal auto-opens once. Cancel it; confirm it does not re-pop. Click "+ New API key…" in the picker; confirm the modal re-opens.
- Create a key in the modal; confirm the picker auto-selects it and Accept enables.
- Cancel the modal; confirm the picker stays on "+ New API key…" and Accept stays disabled with a hover tooltip.
- Explicitly select "Inherit key from environment"; confirm Accept enables.
- Switch the card's harness from Claude Code to Codex (with no Codex keys present); confirm the modal auto-opens once for Codex.
- Toggle the card from Cloud to Local and back; confirm the auto-open re-arms for the new mode.
- Open a plan card with an approved orchestration config that uses a non-Oz harness with no keys; confirm the same auto-open + picker behavior on the plan card's inline config block.
- Restore a conversation containing a previously-displayed orchestration card; confirm no modal pops.
- Run the cloud-mode FTUX flow end to end; confirm it is unchanged.
+150
View File
@@ -0,0 +1,150 @@
# Inline Create-API-Key Flow on Orchestration Cards — Technical Notes
Linear: [QUALITY-702](https://linear.app/warpdotdev/issue/QUALITY-702)
Product spec: `specs/QUALITY-702/PRODUCT.md`
## 1. Overview
This change extends the existing orchestration card auth-secret picker so that users with no managed keys for the active harness can create one without leaving the conversation. The cloud-mode create-key view is decoupled from its previous tight binding to cloud-mode state and re-hosted inside a workspace-level blocking modal. Both orchestration card surfaces (the `RunAgents` confirmation card and the plan card's inline orchestration config block) gain a new picker entry and a new action variant that bubble a "create new key" request up to the workspace. The card's auth-secret selection is reshaped from an `Option<String>` + sibling bool into a three-state enum that makes the picker label, the Accept gate, and persistence all match the product spec.
## 2. Key files
### Reused create-key view, now decoupled
- `app/src/terminal/view/ambient_agent/auth_secret_ftux_view.rs` — takes a `harness: Harness` at construction instead of an `AmbientAgentViewModel` handle; exposes `set_harness`; replaces direct model mutations with `AuthSecretFtuxViewEvent::{Created, Cancelled, Skipped, Failed}` events; gains a `with_skip_hidden(bool)` toggle so the workspace modal can suppress the Skip button (Inherit lives on the picker in this context).
- `app/src/terminal/view/ambient_agent/auth_secret_ftux_dropdown.rs` — same shape change: takes a harness directly, exposes `set_harness`, and removes the previous `subscribe_to_model(AmbientAgentViewModel)` dependency.
- `app/src/terminal/view/ambient_agent/mod.rs` — re-exports updated.
### Cloud-mode re-wiring (preserves existing UX)
- `app/src/terminal/input.rs` — constructs the FTUX view/dropdown with the cloud-mode selected harness and subscribes to the new lifecycle events. The `Created/Cancelled/Skipped/Failed` handlers perform the same side effects (persist selected secret, mark FTUX completed, write `last_selected_auth_secret`, etc.) that the view used to perform inline.
### Orchestration card surfaces
- `app/src/ai/blocklist/inline_action/orchestration_controls.rs` — shared picker logic. Introduces `AuthSecretSelection`, threads it through `OrchestrationEditState`, adds the `+ New API key…` menu entry, adds `apply_create_new_auth_secret_requested` and `apply_created_auth_secret_if_matches`, and adds the `create_new_auth_secret_requested` variant to the `OrchestrationControlAction` trait.
- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs` — confirmation card. Implements the new trait variant, wires the workspace modal dispatch, subscribes to `HarnessAvailabilityEvent::AuthSecretCreated`, and owns the one-shot auto-open guard.
- `app/src/ai/document/orchestration_config_block.rs` — plan card inline config block. Same wiring as the confirmation card for the picker, action handler, and `AuthSecretCreated` adoption.
### Workspace modal host
- `app/src/workspace/action.rs` — adds `WorkspaceAction::OpenCreateAuthSecretModal { harness }`.
- `app/src/workspace/view.rs` — owns a `ModalViewState<Modal<AuthSecretFtuxView>>`; opens it in response to the new action; subscribes to the FTUX view's lifecycle events to close the modal and persist the new selection.
### Button affordance plumbing
- `app/src/view_components/compactible_action_button.rs` — adds `set_disabled` and `set_tooltip` so existing single-state buttons can re-derive their state from a parent gate.
- `app/src/view_components/compactible_split_action_button.rs` — delegates `set_disabled`/`set_tooltip` to both the primary and the menu button so the entire split button reflects the gate.
## 3. `AuthSecretSelection` enum
`OrchestrationEditState` previously carried `auth_secret_name: Option<String>` plus an `auth_secret_explicit_inherit: bool` sibling. That two-field encoding had several subtle issues: `None + false` and `None + true` had different meanings, the proto carried only the name, and the picker label / Accept gate / persistence each had to special-case both fields.
The new enum collapses these into a single value:
```
pub enum AuthSecretSelection {
Unset, // no choice yet — picker shows "+ New API key…", Accept disabled
Inherit, // user explicitly chose to inherit — Accept enabled
Named(String), // user picked a managed key by name — Accept enabled
}
```
`AuthSecretSelection::from_optional_name(Option<String>)` maps wire-format payloads (where absent always means "no choice yet") into the enum. `OrchestrationEditState::auth_secret_name()` returns the `Named` payload (or `None` for the other two variants) so dispatch code that only cares about the on-wire field doesn't have to match the full enum.
Only `Named(_)` is persisted via `CloudAgentSettings.last_selected_auth_secret`. `Inherit` and `Unset` are per-session, per-harness UI state.
## 4. Picker contents and label derivation
`populate_auth_secret_picker_for_harness` rebuilds the dropdown's items each time the harness or secrets list changes. The ordering is:
1. "Inherit key from environment" — always present; dispatches `auth_secret_changed(None)` on click.
2. Loaded managed keys (or a single disabled placeholder for Loading/Failed states).
3. A separator and a "+ New API key…" entry, but only for harnesses whose `auth_secret_types_for_harness(...)` is non-empty.
The picker's trigger label is computed directly from `AuthSecretSelection`:
- `Named(name)` → that name.
- `Inherit` → "Inherit key from environment".
- `Unset` with a create-new-capable harness → "+ New API key…".
- `Unset` otherwise → "Inherit key from environment".
The label always uses the dropdown's default text color. A previous iteration tried to override the trigger color to dim the placeholder; that was removed because the override path re-entered the dropdown's view while still inside the dropdown's own dispatched action and tripped warpui's "Circular view update" guard.
## 5. Action trait and handler wiring
`OrchestrationControlAction` (implemented by both `RunAgentsCardViewAction` and `OrchestrationConfigBlockAction`) gains:
```
fn create_new_auth_secret_requested() -> Self;
```
Both implementers add a `CreateNewAuthSecretRequested` variant and handle it identically: call `oc::apply_create_new_auth_secret_requested(...)` to reset the selection to `Unset` and clear the persisted name (so cancelling the modal does not silently leave a stale name selected), parse the active harness, and dispatch `WorkspaceAction::OpenCreateAuthSecretModal { harness }`. The card then refreshes the Accept gate and notifies.
`apply_auth_secret_change` and `apply_create_new_auth_secret_requested` deliberately do not re-enter the picker view (no `populate_*` or `sync_*` calls inside them) — those helpers are invoked from inside the dropdown's own dispatched action, and re-entry would trip the same circular-update guard noted above. The dropdown updates its own displayed label as part of its menu click; the orchestrator's job is just to record state and persist.
## 6. Workspace modal
`WorkspaceAction::OpenCreateAuthSecretModal { harness }` is dispatched only by the two card action handlers. The workspace view owns a `ModalViewState<Modal<AuthSecretFtuxView>>` constructed lazily when the action arrives. The modal is parameterized with the requested harness; `AuthSecretFtuxView::with_skip_hidden(true)` removes the Skip button (the picker's "Inherit key from environment" entry already plays that role outside the modal).
The workspace subscribes to the view's lifecycle events:
- `Created { harness, name }` — persists the new key as the active selection for that harness via `CloudAgentSettings.last_selected_auth_secret`, then closes the modal. The settings write happens before the modal close so the subsequent `HarnessAvailabilityEvent::AuthSecretCreated` event finds the persisted value already in place.
- `Cancelled` / `Skipped` — closes the modal without side effects. The originating card's selection is unchanged (still `Unset`).
- `Failed { error }` — leaves the modal open and renders an inline error via the view itself.
The two card views subscribe to `HarnessAvailabilityEvent::AuthSecretCreated` and call `oc::apply_created_auth_secret_if_matches(...)` so the freshly-created key is adopted as the active selection on the card without waiting for a manual repopulate.
## 7. Auto-open one-shot guard
Each card owns `has_auto_opened_create_modal: bool`. `maybe_auto_open_create_modal` is the single chokepoint that:
1. Returns early if the guard is set.
2. Returns early if the card is not in an interactive confirmation state (denied, auto-launched, spawning, restored from history, action already finished or running async).
3. Returns early if the active harness has no auth-secret picker (e.g. Oz).
4. Returns early if `auth_secret_selection` is not `Unset`.
5. Returns early if the harness's secrets list is anything other than `Loaded(secrets)` with `secrets.is_empty()`. `NotFetched`, `Loading`, and `Failed` are deliberately treated as "not yet decidable" — the `HarnessAvailabilityEvent::AuthSecretsLoaded` subscription re-fires the check once secrets actually arrive.
6. Sets the guard and dispatches `WorkspaceAction::OpenCreateAuthSecretModal { harness }`.
The guard is reset:
- At construction (set to `false`).
- In `update_request` whenever the harness, model, or execution mode changes via streaming.
- In `try_auto_launch_on_stream_complete` (the stream-complete snapshot is the authoritative final state and gets a fresh evaluation).
- In the `ExecutionModeToggled` and `HarnessChanged` action handlers.
`maybe_auto_open_create_modal` is invoked from those same code paths and from the `AuthSecretsLoaded` / `AuthSecretsFetchFailed` subscription handlers.
## 8. Accept gate and tooltip
`oc::accept_disabled_reason_with_auth(&state.orch, ctx)` extends the existing `OrchestrationEditState::accept_disabled_reason` with a new branch: when `auth_secret_selection` is `Unset` for a harness that exposes the picker, it returns a human-readable reason. Both card views call this helper from a small `refresh_accept_button_state` method which sets `disabled` and `tooltip` on the Accept button (a `CompactibleSplitActionButton` on the confirmation card; the plan card uses the same gate to render an inline validation error instead of a disabled button).
`refresh_accept_button_state` is called from every action handler and from every model-subscription handler that touches `state.orch`, including the `AuthSecretCreated`, `AuthSecretsLoaded`, and `AuthSecretsFetchFailed` branches. `set_disabled` / `set_tooltip` on the button are cheap no-ops when the value hasn't changed.
`CompactibleSplitActionButton::set_disabled` / `set_tooltip` delegate to both the primary and the menu trigger so the split button reads as a single gated affordance.
## 9. FTUX view decoupling details
Previously the cloud-mode FTUX view kept an `Rc<dyn AmbientAgentViewModel>` and read the selected harness from it inside `render` / event handlers. Side effects on submit were performed directly against the model (`set_harness_auth_secret_name`, `mark_harness_auth_ftux_completed`, the `last_selected_auth_secret` write, and the cloud-mode-specific `set_harness Oz` post-action).
The decoupling moves the harness into a plain `Harness` field with a `set_harness(harness, ctx)` setter that the parent invokes when the cloud-mode harness selector changes. Side effects are no longer performed inside the view; instead it emits `AuthSecretFtuxViewEvent::{Created{harness, name}, Cancelled, Skipped{harness}, Failed{error}}` and the host decides what to do.
Cloud-mode UX is preserved by `input.rs` subscribing to these events and performing exactly the side effects that used to be inline. The workspace modal subscribes to the same events and performs the modal-specific behavior (close + persist) described above. The same applies to `AuthSecretFtuxDropdown`.
## 10. Cloud-mode parity
Two cloud-mode behaviors are mirrored on the orchestration cards:
- **Default-selection logic.** `resolve_default_auth_secret_for_harness` only promotes a persisted `last_selected_auth_secret` value; it does not fall back to "first loaded secret". This matches both warp-server's webapp (`HarnessAuthSecretSelector` + `use-agent-form-state.ts`) and cloud-mode's `auth_secret_selector.rs::maybe_restore_auth_secret_from_settings`. Without an explicit choice, the picker stays on `+ New API key…` (or Inherit on harnesses with no managed types).
- **Persistence shape.** Selecting a managed key on either card writes to the same `CloudAgentSettings.last_selected_auth_secret` map keyed by `harness.config_name()` that cloud mode reads on its next launch. Selecting Inherit clears that key; switching to `Unset` (via `+ New API key…`) also clears it so cancelling the modal does not leave a stale name persisted.
## 11. Validation
### Automated
- `cargo check -p warp`
- `cargo fmt`
- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`
### Manual
Covered in `PRODUCT.md §7`.
## 12. Follow-ups
- Consider extracting the workspace-owned modal into a small reusable host (it currently lives inline on `Workspace`); a second consumer would justify the abstraction.
- Consider a small visual treatment for the picker's `+ New API key…` entry (e.g. a leading plus icon) once the rest of the orchestration picker visuals are finalized.
- Long-term, the cloud-mode FTUX view's "Skipped" path could be removed entirely now that the workspace modal hides Skip and the orchestration picker exposes Inherit directly.
+92
View File
@@ -0,0 +1,92 @@
# QUALITY-715: Do not auto-open details panel for orchestration child shared sessions
# Context
Linear issue: https://linear.app/warpdotdev/issue/QUALITY-715/dont-open-agent-info-side-panel-by-default. The issue has no additional description or comments; the required behavior is that opening a shared session child agent from the parent's orchestration UI should not show the conversation details side panel by default. Regular shared session viewers, including direct links to a child shared session, should keep the current default and open the panel.
The relevant implementation is in the Warp client worktree at `/Users/matthew/src/dont-open-agent-info-sidepane/warp` on branch `matthew/dont-open-agent-info-sidepane`. No `warp-server` or `warp-proto-apis` changes are expected for the preferred client-side fix.
The side panel is `ConversationDetailsPanel`, owned by `TerminalView`. `TerminalView` tracks `is_conversation_details_panel_open` and `has_auto_opened_conversation_details_panel` in `app/src/terminal/view.rs:2830`. The panel renders only when `is_conversation_details_panel_open` is true and `can_show_conversation_details_ui_from_model` says details are available (`app/src/terminal/view.rs:26677`). The toggle action updates the same boolean and fetches panel data (`app/src/terminal/view.rs:26095`), so the feature should only change initial auto-open behavior, not remove the ability to open the panel manually.
Shared ambient agent session viewers currently auto-open the panel from `TerminalView::on_session_share_joined` after the viewer joins an ambient-agent shared session (`app/src/terminal/view/shared_session/view_impl.rs:687`). That method calls `maybe_auto_open_conversation_details_panel` for every `SessionSourceType::AmbientAgent` when `FeatureFlag::CloudMode` is enabled (`app/src/terminal/view/shared_session/view_impl.rs:708`). `maybe_auto_open_conversation_details_panel` unconditionally sets `is_conversation_details_panel_open = true` the first time it runs (`app/src/terminal/view/ambient_agent/view_impl.rs:960`).
One shared-session child path is driven by `OrchestrationViewerModel` and `PaneGroup`. `OrchestrationViewerModel::apply_children_fetch` polls `GET /agent/runs?ancestor_run_id={parent_task_id}`, creates a local child `AIConversation`, links it to the parent, marks it as `is_viewing_shared_session`, and emits `EnsureSharedSessionViewerChildPane` when a child `session_id` becomes available (`app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs:228`). `PaneGroup::ensure_shared_session_viewer_child_pane` creates a hidden shared-session viewer for that child session, restores the child conversation, and enters agent view with `AgentViewEntryOrigin::SharedSessionSelection` (`app/src/pane_group/mod.rs:3428`). The child viewer then reaches the same `on_session_share_joined` auto-open path as any other ambient shared session (`app/src/terminal/shared_session/viewer/terminal_manager.rs:799`), which is why the panel currently opens by default for child views.
Not all child agent views originate from `OrchestrationViewerModel`. Local parent agents create child conversations through `StartAgentExecutor`, which emits `Event::StartAgentConversation` (`app/src/ai/blocklist/action_model/execute/start_agent.rs:303`), handled by `dispatch_start_agent_conversation` in `app/src/pane_group/pane/terminal_pane.rs:1470`. The local child path uses `create_hidden_child_agent_conversation` in `app/src/pane_group/child_agent.rs:131` for fresh StartAgent children, and `PaneGroup::create_hidden_child_agent_pane` for restored local, remote, and viewer-side placeholder children (`app/src/pane_group/mod.rs:3216`). Navigation to these children still happens through `RevealChildAgent`, `SwapPaneToConversation`, `OpenChildAgentInNewPane`, and `OpenChildAgentInNewTab` after `ensure_hidden_child_agent_pane_for_conversation` materializes the child pane if needed (`app/src/pane_group/pane/terminal_pane.rs:1425`, `app/src/pane_group/mod.rs:3076`).
The important distinction is opening context, not task shape. A child task can be opened directly from its own shared-session link, in which case it should behave like a standalone ambient shared-session viewer and keep the details panel open by default. The panel should only be suppressed when the child view is entered as an auxiliary child pane owned by the parent's orchestration UI, such as a pill-bar click, child status-card reveal, or split-off from the parent orchestration viewer.
# Requirements
1. When the user opens an orchestration child agent from the parent's orchestration UI in a shared-session viewer, the child viewer should start with the conversation details panel closed, regardless of whether that child was materialized by `OrchestrationViewerModel` or by the local parent-agent child pane path.
2. The panel toggle must remain available for that child viewer whenever details are available, so the user can still open the panel manually.
3. Direct links to a child agent's shared session should keep the current default and open the conversation details panel.
4. Existing default behavior must remain unchanged for regular non-child ambient shared session viewers.
5. Existing default behavior must remain unchanged for local cloud-mode runs and non-shared ambient agent views.
6. The fix should be client-only because the behavior depends on client-side navigation context.
# Design options
## Option A: Explicit per-view auto-open policy set by parent orchestration UI paths
Add a `ConversationDetailsPanelAutoOpenPolicy` enum to `TerminalView`, defaulting to the current behavior. Parent-owned child pane creation/reveal paths set the policy to suppress the initial details-panel auto-open before the child viewer joins or enters its ambient session. Direct shared-session links never set the policy, even if the target task is a child.
Tradeoffs:
- Correctly distinguishes direct child links from parent-context child navigation.
- Does not rely on `parent_conversation_id`, `parent_run_id`, or `is_viewing_shared_session`, which describe what the conversation is rather than how the user opened it.
- Slightly more stateful than deriving from metadata.
- Requires setting the policy at every parent-owned child pane materialization path that can reach the ambient auto-open code.
## Option B: Derive suppression from shared-session child metadata at auto-open time
Add a helper on `TerminalView` that checks the active conversation in `BlocklistAIHistoryModel` and the terminal model's shared-session state. Return false from the auto-open path when the active conversation has `parent_conversation_id().is_some()` and the view is a shared-session viewer.
Tradeoffs:
- Minimal state and no persistence/schema changes.
- Covers multiple child creation paths because it does not depend on the creation site.
- Incorrectly suppresses direct child shared-session links, because those links also represent child tasks in shared-session viewers.
- Not recommended unless product decides every child shared-session surface should suppress the panel, including direct links.
## Option C: Change `on_session_share_joined` to inspect server task parent metadata
Use the `AmbientAgentTask.parent_run_id` field (`app/src/ai/ambient_agents/task.rs:234`) to suppress auto-open when the joined task is known to be a child.
Tradeoffs:
- Could cover direct links to child shared sessions if product later decides direct links should suppress the panel.
- More asynchronous and likely requires adding a fetch-before-auto-open path, because `on_session_share_joined` only receives `SessionSourceType::AmbientAgent { task_id }`.
- Risks delaying or flickering the panel for regular viewers while task metadata loads.
- Not recommended for QUALITY-715 because direct child links should keep opening the panel.
# Proposed changes
Implement Option A. Suppression should be an explicit property of the `TerminalView`'s opening context.
1. In `app/src/terminal/view.rs`, add `ConversationDetailsPanelAutoOpenPolicy` with variants `DefaultOpen` and `DefaultClosed`. Store it as a private `conversation_details_panel_auto_open_policy` field on `TerminalView`, initialized to `DefaultOpen` in `TerminalView::new`.
2. Add a method on `TerminalView`, for example `suppress_initial_conversation_details_panel_auto_open(&mut self)`, that sets the policy to `DefaultClosed` before the join-time auto-open can run. This method should not close the panel if the user has already opened it manually; it only affects future calls to `maybe_auto_open_conversation_details_panel`.
3. Update `maybe_auto_open_conversation_details_panel` in `app/src/terminal/view/ambient_agent/view_impl.rs` to preserve existing one-shot behavior and consult the policy:
- if `has_auto_opened_conversation_details_panel` is already true, return;
- always set `has_auto_opened_conversation_details_panel = true` before applying the policy;
- if the policy is `DefaultClosed`, return without setting `is_conversation_details_panel_open` and without calling `fetch_and_update_conversation_details_panel`;
- otherwise keep the current behavior: set `is_conversation_details_panel_open = true`, set `has_auto_opened_conversation_details_panel = true`, fetch details, and notify.
4. Set the suppress policy in parent-owned child pane creation paths:
- `PaneGroup::ensure_shared_session_viewer_child_pane`, before or in the same `new_terminal_view.update` closure that restores the child conversation and enters agent view (`app/src/pane_group/mod.rs:3428`).
- Do not rely on `PaneGroup::create_hidden_child_agent_pane`'s `child_conversation.is_viewing_shared_session()` placeholder branch as the primary fix. That branch creates a loading placeholder when the user clicks before `OrchestrationViewerModel` has a `session_id`; the placeholder is discarded and replaced by `ensure_shared_session_viewer_child_pane` when the real child shared-session viewer becomes joinable.
- Set the policy at the hidden ambient-agent child pane creation helper `PaneGroup::insert_ambient_agent_pane_hidden_for_child_agent`. This single suppression site covers both restored remote child panes (`PaneGroup::create_hidden_child_agent_pane` remote-child branch, which delegates to this helper) and freshly-spawned remote children created by the local-orchestrator `StartAgentExecutionMode::Remote` path (`launch_remote_child` in `app/src/pane_group/pane/terminal_pane.rs`). The view is suppressed before the environment setup loading screen and any subsequent `AmbientAgentViewModelEvent::SessionReady`/`FollowupSessionReady` event can run.
- Do not set the policy in ordinary fresh local child creation (`create_hidden_child_agent_conversation`) unless implementation identifies a concrete local-parent shared-session viewer or Cloud Agent child path that reaches `maybe_auto_open_conversation_details_panel`. The current fresh local child pane path creates a local child agent pane, not a shared-session viewer or remote Cloud Agent child, so setting the policy there would be unnecessary and risks changing non-shared behavior.
5. Do not set the policy when opening a shared session directly from a child session link. Direct links enter through the normal shared-session viewer creation path (`create_shared_session_viewer` and `on_session_share_joined`) without a parent orchestration owner, so they should keep the default auto-open behavior.
6. Do not suppress based only on `parent_conversation_id`, `AmbientAgentTask.parent_run_id`, or `conversation.is_viewing_shared_session()`. Those are task/conversation properties and cannot distinguish direct child links from parent orchestration UI navigation.
7. Do not change `TerminalAction::ToggleConversationDetailsPanel`. Manual toggles should continue to set `is_conversation_details_panel_open`, fetch data, and render the side panel for child viewers.
8. Do not change `on_session_share_joined`'s ambient-agent check for regular viewers. The default auto-open behavior for direct child links and non-child ambient shared sessions remains driven by the existing `FeatureFlag::CloudMode` and `SessionSourceType::AmbientAgent` condition.
9. Do not add a server or proto field for this fix.
# Testing and validation
Add unit coverage in the Warp client.
1. In `app/src/terminal/view/shared_session/view_impl_tests.rs` or a nearby `TerminalView` test module, add a test that sets the new suppress policy, calls `maybe_auto_open_conversation_details_panel`, and asserts:
- `is_conversation_details_panel_open` remains false,
- `has_auto_opened_conversation_details_panel` becomes true,
- calling `TerminalAction::ToggleConversationDetailsPanel` still opens the panel when details are available.
2. Add a direct-link regression test: create a shared ambient viewer for a child conversation/task without setting the new suppress policy, call `maybe_auto_open_conversation_details_panel`, and assert the panel opens by default. This test is the guard against deriving suppression from child metadata.
3. Add a regression test for the `OrchestrationViewerModel` path: create or restore a child conversation marked `is_viewing_shared_session`, run `ensure_shared_session_viewer_child_pane` or the smallest test-visible equivalent, and assert the resulting child `TerminalView` uses `DefaultClosed` and does not auto-open the panel.
4. Add a regression test or manual validation case where a suppressed parent-owned child pane already exists, then the same child is opened through its own direct shared-session link. The direct-link `TerminalView` should still open the details panel by default, proving the suppression state is per-view and not tied globally to the child conversation/task.
5. If implementation adds suppression to any additional local-parent shared-session viewer path, add targeted coverage for that exact path. Do not add broad tests that assume ordinary local child panes should suppress auto-open.
6. Add or update a regular shared ambient viewer test in `app/src/terminal/view/shared_session/view_impl_tests.rs` that proves a non-child ambient shared session still auto-opens the panel by default.
7. Ensure manual-toggle assertions create a state where conversation details are actually available, otherwise a toggle may set `is_conversation_details_panel_open` without rendering useful panel content.
8. Run focused tests first:
- `cargo test -p warp-app terminal::view::shared_session::view_impl_tests`
- `cargo test -p warp-app pane_group::mod_tests`
9. Run the relevant broader client validation required for a Warp client PR after focused tests pass. If local runtime makes a full presubmit impractical, run the repo-standard Rust formatting/check/test commands that cover the touched modules and document any skipped command with the reason.
Manual validation:
1. Start or use an orchestrated cloud-agent shared session that has at least one child agent with a `session_id`.
2. Open the parent shared session in the Warp desktop viewer.
3. Confirm the parent/non-child ambient session still opens with the conversation details panel by default.
4. Select a child from the orchestration pill UI for a server-discovered child and confirm the child view opens with the main transcript visible and the conversation details panel closed.
5. Repeat with a local-parent child path if available, such as a local parent agent that creates a child through StartAgent/local orchestration, and confirm its shared-session child view also opens with the panel closed.
6. Open the same child through its own shared-session link and confirm the conversation details panel opens by default.
7. Click the pane-header conversation details toggle in the parent-context child view and confirm the panel opens and shows the child agent's metadata.
8. Switch back to the parent and another regular shared session to confirm their default behavior was not regressed.
# Parallelization
Parallel child agents are not recommended for implementation. The change is small, tightly coupled to `TerminalView` state, shared-session join behavior, and the child-pane materialization path. Splitting implementation and tests across agents would add merge overhead without meaningful wall-clock savings.
If the work grows to include direct child-session links, then split the work into two sequential phases rather than parallel branches: first land the known orchestration viewer fix, then investigate whether task metadata is available early enough to suppress auto-open for standalone child URLs.
# Risks and mitigations
- Risk: suppressing auto-open also prevents manual access to the panel. Mitigation: only gate `maybe_auto_open_conversation_details_panel`; do not change render availability or `ToggleConversationDetailsPanel`.
- Risk: setting `has_auto_opened_conversation_details_panel = true` when suppressing could block a later desired automatic open. Mitigation: this is intentional only for parent-context child views because the requirement is that they do not default open; direct links and regular viewers keep the `DefaultOpen` policy.
- Risk: missing a parent-owned child creation path would leave the panel open. Mitigation: cover `ensure_shared_session_viewer_child_pane`, and only add broader local-parent coverage if a concrete local-parent shared-session viewer path is found to reach the auto-open code.
- Risk: over-broad suppression would change direct child links. Mitigation: never derive suppression from child metadata alone; direct-link tests must assert the panel still opens by default.
# PR notes
Create the PR from branch `matthew/dont-open-agent-info-sidepane` in the Warp client repo. The PR should reference QUALITY-715, describe the client-only change, and include the focused test results plus manual validation. If no documentation changes are needed, state that the behavior is an internal default-state adjustment with no user-facing docs impact.
+36
View File
@@ -0,0 +1,36 @@
# TECH: Orchestrated agents can read plans by explicit ID
## Context
Orchestrated child agents need to read parent-created plans when explicitly given a plan ID. Plan availability is backed by Warp Drive rather than inherited launch metadata or copied markdown.
## Architecture
### Publish parent plans before fan-out
Before `RunAgentsExecutor` dispatches children, it asks `AIDocumentModel` to publish every document owned by the parent conversation.
- `AIDocumentModel` keeps its cached backing identity synchronized with `CloudModel`: live `ObjectSynced` events update matching plans by stable `ai_document_id`, initial-load completion reconciles restored documents, and explicit sync/publication calls reconcile defensively before creating or waiting.
- Unbacked plans start Warp Drive creation.
- Plans already being created refresh their queued or client-backed notebook content before remaining in the wait set.
- Server-backed plans immediately queue their latest content for update instead of waiting for the normal two-second document save throttle.
- Newly created or saving plans receive concurrent, bounded waits for server backing.
- Publication failures and timeouts are logged per plan, and child launch always continues.
- Cancelling `run_agents` during publication removes the pending launch, so publication completion cannot fan out children. Cancellation after fan-out preserves the existing spawning aggregation lifecycle.
`RunAgentsRequest.plan_id` remains the orchestration-config key. It is not treated as inherited child context, and no plan IDs or plan content are added to child launch requests, prompts, server task metadata, or driver options.
### Read plans by explicit ID
`ReadDocumentsExecutor` first reads requested IDs from the local `AIDocumentModel`. If a requested plan is absent, it attempts Warp Drive hydration only when the acting conversation participates in orchestration:
- the conversation identifies itself as a child through a local parent conversation ID or remote parent run ID; or
- `BlocklistAIHistoryModel` knows child conversations for it.
Hydration uses `AIDocumentModel::hydrate_saved_plan_from_warp_drive`, then retries the complete read so result ordering and all-or-nothing behavior remain unchanged. Missing IDs in non-orchestrated conversations, or IDs still missing after hydration, return the existing clear `Document(s) not found` error.
Hydrated plans retain ordinary `EditDocumentsExecutor` behavior. No inherited-document write policy or stale-revision policy is introduced.
## Testing and validation
Focused Rust coverage verifies:
- `RunAgentsExecutor` starts publication for every plan owned by the parent conversation before dispatch, without publishing unrelated plans;
- saving-plan publication refreshes content edited after Warp Drive creation began;
- already server-backed plans do not wait when their local document has a stale client sync ID;
- cancellation during the publication wait prevents child dispatch;
- a remote child with only a parent run ID can lazily hydrate and read a requested saved plan;
- a non-orchestrated conversation retains the missing-document error;
Validation uses `cargo fmt` and targeted `cargo check`. Do not run the app, nextest, or presubmit for this change.
## Risks and mitigations
Warp Drive creation or update can be slow or unavailable.
- Publication uses bounded waits only for plans that are not yet server-backed.
- Failures and timeouts are logged per plan and never block child launch indefinitely.
Explicit-ID discovery depends on the child receiving a relevant plan ID in its task prompt.
- No implicit plan selection is attempted.
- A missing or unavailable ID produces a clear `read_plans` error.
+76
View File
@@ -0,0 +1,76 @@
# Session Sharing for Orchestrated Agent Sessions
## Summary
When a shared agent session has spawned child agents, parent-scoped session views should show the existing orchestration pill bar so viewers can inspect the orchestrator and its direct children. Direct child session links remain child-scoped and do not open the parent orchestration view.
## Problem
Remote cloud agents are already viewed through shared-session viewers, and local orchestrated sessions already use a pill bar to switch between parent and child conversations. The missing product definition is where that existing pill bar should appear across session-sharing entrypoints and local/remote topologies.
## Goals
1. A viewer opening a parent/orchestrator session can inspect the orchestrator and all direct child agents from the existing orchestration pill bar.
2. Sharing a parent session makes the direct child sessions accessible from that parent session.
3. Opening a direct child session link stays scoped to that child session.
4. The parent-scoped pill bar appears consistently in native Warp and the web/WASM shared-session viewer, with platform-specific affordances following the existing pill bar behavior.
5. The behavior is consistent across local-local, local-remote, remote-remote, and remote-local orchestration topologies from the viewers perspective.
## Figma
Figma: none provided. Use the existing orchestration pill bar behavior and visual treatment. This spec does not redefine the pill bars layout, status badges, hover cards, ordering, truncation, or other interaction details.
## Behavior
### Terms and scope
1. An orchestrator session is an agent session whose active agent spawned one or more direct child agents.
2. A child session is the session for one direct child agent spawned by an orchestrator.
3. A parent session link targets the orchestrator session. A child session link targets one child session.
4. “Local” means the agent is running on the users current client when the user owns the session. “Remote” means the agent is running in a cloud or driver process and is viewed through a shared-session viewer. In remote-local flows, the child is local to the remote driver process, but still remote from the users client.
5. Only one level of orchestration is supported: one orchestrator plus its direct child agents. Child sessions are treated as leaf sessions for this feature.
### Sharing rules
6. All remote agent sessions are automatically shared, whether the remote agent is an orchestrator or a child.
7. Sessions that are local to the users client are not shared until the user explicitly shares them through an existing share entrypoint, such as the share modal, pane/header action, context menu, or copy-sharing-link action.
8. When a parent/orchestrator session becomes shared, its direct child sessions are also accessible to viewers of that parent session so the parent-scoped pill bar can display and open them.
9. Parent sharing applies to children that already exist when sharing starts and to direct children spawned while the parent share remains active.
10. A viewer who can access a parent session link is allowed to access the direct child sessions exposed through that parent view. Opening or copying a direct child link from that context is acceptable, but the direct child link remains child-scoped.
11. Sharing or opening a direct child session does not implicitly share or navigate to the parent or sibling child sessions.
12. Direct child links may still show human-readable names for agents referenced in that childs transcript. Agent names are considered orchestration metadata that may be mutually visible between parent and child sessions when those sessions are otherwise accessible.
### Viewer entrypoints and pill bar presence
13. When a user starts or opens a remote orchestrator from the `/cloud-agent` flow in native Warp, the resulting shared-session viewer is parent-scoped and shows the existing orchestration pill bar when the orchestrator has direct children.
14. When a user opens an orchestrator session from the Oz web UI, the web viewer is parent-scoped and shows the existing orchestration pill bar when the orchestrator has direct children.
15. When a user opens a `warp://shared_session/...` or web shared-session link for an orchestrator, native Warp or the web viewer opens a parent-scoped view and shows the existing orchestration pill bar when the orchestrator has direct children.
16. When a user explicitly shares a local orchestrator session, the resulting parent session link opens a parent-scoped view and shows the existing orchestration pill bar when the orchestrator has direct children.
17. When a user opens a direct child session link in native Warp or on the web, Warp opens the child-scoped shared-session view. The child-scoped view does not show the parent/orchestrator pill, sibling child pills, or parent orchestration navigation.
18. “Open in desktop” or equivalent handoff actions preserve the link target. A parent link remains parent-scoped after handoff; a child link remains child-scoped after handoff.
19. Shared-session viewers that are created internally so a parent pill can display a remote child do not create additional visible browser tabs, windows, or user-facing links by themselves.
20. If the viewer joins a parent-scoped session before any child has been spawned, the pill bar appears after the first direct child becomes known. A short delay is acceptable.
21. If a parent-scoped viewer is open while additional direct children spawn, the pill bar updates to include those children.
22. On web/WASM, the pill bar uses the existing web-compatible subset of pill bar behavior. Native-only pane-management actions are not required on web.
23. Viewer pill selection is local to that viewer. Switching pills in a shared-session viewer should not force the sharer or other viewers to switch conversations. Any initial selected-conversation sync should follow existing shared-session behavior.
### Conversation body behavior
24. When viewing the orchestrator pill, the viewer sees the orchestrator session transcript, including orchestration cards, child launch requests, lifecycle updates, and other parent-session activity included by the share.
25. When viewing a child from a parent-scoped view, or when opening a child-scoped direct link, the viewer sees that childs session transcript according to normal shared-session behavior.
26. Within orchestrator and child conversation bodies, user-visible references to agents by internal ID resolve to the correct human-readable agent name wherever the existing pill bar has enough metadata to do so. This includes send-message-to-agent blocks, received-message-from-agent blocks, and lifecycle/status blocks. If a name cannot be resolved, use the same fallback behavior as the existing pill bar or conversation renderer.
27. Since child sessions are leaf sessions for this feature, any orchestration-looking artifacts inside a child transcript render as ordinary transcript content and do not create a second-level pill bar or parent/child navigation.
28. Completed child sessions remain reachable from the parent pill bar and show their final available transcript and status.
29. If a child session exists but is not ready to join yet, selecting it from the parent pill bar shows the existing loading or pending behavior rather than stale parent content.
30. If a child session cannot be loaded because of a network, permission, or session creation failure, the child view shows an unavailable or error state using existing shared-session error patterns. The parent view and other direct child sessions remain usable.
31. If the parent or a child session ends while a viewer is inspecting the orchestration, ended-session behavior follows existing shared-session behavior for the active session.
### Topology-specific behavior
32. Local-local: when a local orchestrator starts local children, nothing is shared until the user shares the parent or a child. Sharing the parent makes the local children accessible from the parent link and visible in the parent-scoped pill bar. Sharing a child directly opens only that child.
33. Local-remote: when a local orchestrator starts a remote child, the local user can inspect the child from the local orchestration UI. The remote child session is automatically shared. If the user shares the local parent, the parent link shows the parent and the remote child in the parent-scoped pill bar.
34. Remote-remote: when a remote orchestrator starts remote children, the parent and children are automatically shared. Opening the parent link shows the parent-scoped pill bar. Opening a child link shows only that child.
35. Remote-local: when a remote orchestrator starts a child that is local to the remote driver process, both sessions are still remote from the users client and are automatically shared. Opening the parent link shows the parent-scoped pill bar. Opening a child link shows only that child.
36. Mixed child modes: if one orchestrator has both local-to-parent children and remote children, the parent-scoped pill bar presents the direct children together using the existing pill bar behavior. The viewer should not need to understand where each child is executing to navigate the orchestration.
### Permissions, privacy, and roles
37. A viewer who can open the parent link can inspect all direct children exposed through that parent link without needing separate child links.
38. A parent link must not reveal sessions outside the parents direct child set.
39. A child link must not provide navigation to the parent or sibling child sessions.
40. Human-readable agent names may appear in parent and child conversation bodies when the transcript references those agents.
41. A viewers role in a child session reached through the parent link must not exceed the viewers effective role for the parent share.
42. If a viewer has an interactive/executor role, existing shared-session controls apply only to the currently active session. The pill bar itself is navigation, not an agent-control surface.
43. Request-access, role-change, participant presence, reconnect, and ended-session behavior should follow existing shared-session behavior for the active session.
44. Copying a session sharing link should preserve the current scope. A parent share action copies a parent-scoped link; a child share action copies a child-scoped link.
### Loading and reconciliation
45. Parent-scoped viewers reconcile the direct child list while the parent session is live so newly spawned children appear and lifecycle status changes are reflected through the existing pill bar.
46. If network connectivity is lost, the pill bar keeps the last known direct children and statuses. On reconnection, the viewer reconciles with the current direct child list and statuses.
47. If a direct child finishes before a viewer joins the parent session, the child still appears in the parent pill bar when the parent share has permission to expose it.
48. If a direct child session was never successfully created or shared, the child can still appear as an errored or unavailable child if the parent transcript or orchestration metadata indicates the child launch failed.
49. If parent and child state disagree temporarily, the UI should prefer a stable, non-destructive presentation: keep known direct child pills visible, update statuses when confirmed, and avoid dropping a child from the list solely because a refresh is delayed.
## Non-goals
1. This spec does not add support for more than one level of orchestration.
2. A direct child link does not provide a breadcrumb or “back to parent orchestration” experience.
3. The parent pill bar is not a bulk control surface for cancelling, restarting, messaging, or otherwise managing child agents.
4. This spec does not change the visual design or detailed interaction model of the existing orchestration pill bar.
5. This spec does not introduce a combined export, fork, or replay artifact for an entire orchestration group.
+276
View File
@@ -0,0 +1,276 @@
# Session Sharing for Orchestrated Agent Sessions
## Context
See `specs/QUALITY-726/PRODUCT.md` for user-visible behavior. This spec maps the product invariants onto the existing orchestration pill bar and shared-session viewer infrastructure, and fixes the gaps observed across the eight share-parent / share-child × local/remote permutations.
The orchestration pill bar in shared-session viewers was originally built for remote-remote (cloud-spawned orchestrator + cloud children) in `specs/orch-pill-bar-web/TECH.md`. That design already covers `apply_children_fetch`, per-child hidden viewer panes, REST polling, and pill click navigation. This spec extends those mechanisms to the other topologies (local-local, local-remote, remote-local, remote-remote child link) and fixes agent-name resolution in both the pill bar and conversation bodies.
### Where the pill bar currently renders
- Render gate (shared by native and viewer pill bars): `app/src/terminal/view/pane_impl.rs:503-552` (`maybe_add_parent_navigation_card`), keyed on `FeatureFlag::OrchestrationPillBar` || `FeatureFlag::OrchestrationViewerPillBar` plus `AgentView` fullscreen.
- Pill data: `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs` reads `BlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_order` via `pill_specs` (`orchestration_pill_bar.rs:555-580`). `pill_specs` returns `None` when the orchestrator has no descendants; the pill bar collapses to `Empty` in that case.
- Shared-session viewers discovery side: `OrchestrationViewerModel` REST-polls children (`app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs:1-416`). Its construction site is `app/src/terminal/shared_session/viewer/terminal_manager.rs:778-816`, currently gated on `SessionSourceType::AmbientAgent { .. }`.
### Where children are made shareable
- Remote children are always shared by the server: spawning a remote child mints an `ai_tasks` row and a `SessionSourceType::AmbientAgent { task_id }` shared session.
- Local children created from a local orchestrator inherit sharing via `inherit_share_for_local_child` in `app/src/pane_group/pane/terminal_pane.rs:228-248`, **but only when the host terminals `SessionSourceType` is `AmbientAgent`**. Manual local shares (created via the share modal at `app/src/terminal/view/shared_session/view_impl.rs:1864-1890`) do not get an `AmbientAgent` source, so they fail this gate and children stay unshared.
- After QUALITY-726, the hosts orchestrator `task_id` rides on a sibling `source_task_id` field, not on the `SessionSourceType::User` variant itself — see *Sidecar source_task_id design* below.
### Where agent IDs are resolved in conversation bodies
- `conversation_id_for_agent_id` in `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-45` first checks `BlocklistAIHistoryModel::conversation_id_for_agent_id` (which uses the `agent_id_to_conversation_id` index keyed by `AIConversation::orchestration_agent_id`; see `history_model.rs:961-988`, `history_model.rs:1029-1033`, `agent_id_key` at `history_model.rs:2228-2232`), and falls back to `find_conversation_id_by_server_token`.
- `AIConversation::orchestration_agent_id` is populated when the local conversation is given a server conversation token or a run id (`history_model.rs:961-988`, `history_model.rs:994-1027`).
- `BlocklistAIHistoryModel::start_new_child_conversation` (`history_model.rs:398-433`) sets `agent_name`, `parent_agent_id`, and the parent/child relationship.
### Where the task ↔ session link is established server-side
- Today, when a local-to-driver child of a remote orchestrator starts sharing, no client tells the server which `session_id` is bound to that childs `ai_tasks` row. The remote driver mints the local `ai_tasks` row at spawn time and the local terminal manager mints the shared session, but the two arent linked on the server side. This is the root cause for the remote-local-share-parent (cases 5/6) hang in the pill bar.
- For live session sharing, the viewer joins child sessions by the child tasks `session_id` (surfaced on the client as `AmbientAgentTask::session_id`, populated from `RunItem.session_id` on the server). Other linkages (e.g. `ai_tasks.agent_conversation_id`) are restore-oriented and orthogonal to live shared-session join; we do not need them for QUALITY-726.
### Observed gaps
1. **Local-local: share parent.** Native viewer shows the parent transcript but no pill bar; child names resolve in native, show as `Unknown` on web. Cause: `OrchestrationViewerModel` is not initialized because the shared session source is not `AmbientAgent`; web viewer has no local history index, so agent-id lookups miss; children are not registered into the viewer history.
2. **Local-local: share child.** Child shows without pill bar; child name references show `Orchestrator` (native) or `Orchestrator/Unknown` (web). Cause: child link is a leaf view (expected), but in-transcript references resolve through `parent_agent_id` fallbacks and the missing agent index, so other agents render with the parents name placeholder.
3. **Local-remote: share parent.** Native viewer shows the parent transcript but no pill bar; web shows `Unknown` child names. Cause: same `AmbientAgent`-gate problem as (1); the remote child is shareable, but the parent viewer doesnt know to discover it.
4. **Local-remote: share child.** Child shows without pill bar; same name-resolution issues. Cause: same root cause as (2) plus remote child links dont pre-load the agent index for sibling references.
5. **Remote-local: share parent.** Pill bar shows, but clicking child stays on loading. Cause: `OrchestrationViewerModel` registers the child but `AmbientAgentTask.session_id` is never populated for local-to-driver children (driver creates the session locally and never reports it via REST).
6. **Remote-local: share child.** Never gets past loading. Cause: same as (5); no `session_id` to join.
7. **Remote-remote: share parent.** Pill bar works, child loads. This is the happy path designed for in `specs/orch-pill-bar-web/TECH.md`.
8. **Remote-remote: share child.** Child loads without pill bar (expected), but name references are not resolved. Cause: child link is leaf (expected); transcript-side agent name index is missing as in (2)/(4).
### Relevant files
- Pill bar UI: `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs`; `pill_specs` at lines 555-580.
- Render gate: `app/src/terminal/view/pane_impl.rs:503-552`.
- Viewer pill bar discovery model construction: `app/src/terminal/shared_session/viewer/terminal_manager.rs:778-816`. The viewer model itself: `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs`.
- Per-child hidden viewer panes: `app/src/pane_group/mod.rs:3270-3548` (`create_hidden_child_agent_pane`, `ensure_shared_session_viewer_child_pane`).
- Local sharing cascade: `app/src/pane_group/pane/terminal_pane.rs:183-248` (`host_terminal_shared_session_source_type`, `inherit_share_for_local_child`).
- Share initiation: `app/src/terminal/view/shared_session/view_impl.rs:521-584` (`attempt_to_share_session(source_type: SessionSourceType, ...)`). Call sites that currently pass `SessionSourceType::default()`: `app/src/terminal/view.rs:20928` (`StartRemoteControl`), `app/src/pane_group/mod.rs:2627` (`ShareSessionModalEvent::StartSharing`), `app/src/terminal/view/use_agent_footer/mod.rs:259` (`UseAgentToolbarEvent::StartRemoteControl`), and the test sites in `view_tests.rs:1032/1107/1178`. Cloud-agent path that already passes `AmbientAgent { task_id }`: `app/src/ai/agent_sdk/driver/terminal.rs:133-141`.
- Conversation-body agent name resolution: `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-129`, `app/src/ai/blocklist/history_model.rs:961-1033`, `app/src/ai/blocklist/history_model.rs:398-450`.
- Existing local-event-to-server sync home: `app/src/ai/blocklist/task_status_sync_model.rs` (subscribes to local events and fires `update_agent_task` with the standard viewer/remote-child guards). Trait signature in `app/src/server/server_api/ai.rs:920-927`; the actual `fire_update` site is `task_status_sync_model.rs:188-200`.
- Source type plumbing: `session_sharing_protocol::sharer::SessionSourceType` (external crate; today: `User` (unit) and `AmbientAgent { task_id }`).
## Proposed changes
The implementation has four threads. They can be implemented in parallel and merged together because they touch largely disjoint files; see Parallelization.
### Thread A — Cascade sharing from manually shared local orchestrators
Goal: invariants 8, 9, 32 (local-local share parent), 33 (local-remote share parent).
#### Sidecar `source_task_id` design
The existing `session_sharing_protocol::sharer::SessionSourceType` has two variants on `main`:
- `User` (unit, default) — the session was started by a user directly.
- `AmbientAgent { task_id: Option<String> }` — the session was started in the course of spinning up an ambient agent.
A manually shared local conversation is conceptually still a `User`-initiated share — the same person clicked “share session” as for any other manual share. Whats new is that modern local conversations are always associated with a server-side `ai_tasks` row by `task_id`, regardless of whether the conversation happens to act as an orchestrator.
An earlier iteration of QUALITY-726 turned `User` into a struct variant `User { task_id: Option<String> }` to carry that id. That broke wire compatibility with pre-QUALITY-726 viewers that only understood the bare `"User"` JSON form. Thread A keeps `User` strictly unit and instead carries the orchestrator `task_id` on a sidecar field of the payloads that already cross the wire:
```rust path=null start=null
// session-sharing-protocol/src/sharer.rs
pub struct InitPayload {
// ...existing fields...
#[serde(default)]
pub source_type: SessionSourceType, // strict `User` | `AmbientAgent { task_id }`
/// Orchestrator `task_id` for this share. Set whenever the conversation
/// has an `ai_tasks` row, regardless of whether `source_type` is `User`
/// or `AmbientAgent`. Old clients omit this; new code reads it directly.
#[serde(default)]
pub source_task_id: Option<String>,
// ...
}
// session-sharing-protocol/src/viewer.rs::DownstreamMessage::JoinedSuccessfully
#[serde(default)]
pub source_task_id: Option<String>,
```
The `SessionManifest` in `session-sharing-server` adds the same `source_task_id` field so the server can plumb the value from `InitPayload` to `JoinedSuccessfully`.
This preserves the existing semantic split:
- **`User`** still means “a user initiated this share.” The orchestrator `task_id` lives in `source_task_id`.
- **`AmbientAgent { task_id }`** still means “cloud-executed agent session.” The variant continues to carry the canonical ambient `task_id`. New sharer code MAY mirror that value into `source_task_id` so downstream readers can use a single field; legacy AmbientAgent producers without the mirror keep working because viewers fall back to the variant.
Concrete client places that key off `AmbientAgent` for *cloud-execution* semantics (not orchestration semantics) must continue to match only `AmbientAgent`:
- `app/src/tab.rs:833``Indicator::AmbientAgent` paints the tab badge.
- `app/src/terminal/view/shared_session/view_impl.rs:720``ai_context_menu.set_is_in_ambient_agent(true)`.
- `app/src/terminal/view/shared_session/view_impl.rs:766-771` — auto-opens the conversation details panel for `CloudMode` viewers.
- `app/src/terminal/view/shared_session/view_impl.rs:817-833` — viewer-driven-sizing skip and shareable-object retention on session end.
- `app/src/terminal/shared_session/viewer/terminal_manager.rs:786` — marks the `TerminalView` as an ambient-agent session view (read in many places).
- `is_ambient_agent_session()` / `is_shared_ambient_agent_session()` and `passive_suggestions/maa.rs` cloud-specific paths.
No behavior change is required for any of those sites: a manual `User` share carrying `source_task_id: Some(_)` continues to fall through every `matches!(source_type, SessionSourceType::AmbientAgent { .. })` check, so no cloud UI activates for a local orchestrator share.
Orchestration-discovery sites read the sidecar instead of the variant. There is no single `SessionSourceType::orchestrator_task_id()` helper anymore; callers read `source_task_id` from the payload (or the model field that mirrors it) and fall back to `AmbientAgent.task_id` only when interoperating with legacy producers. The viewer-side construction site at `terminal_manager.rs:778-816` needs an explicit restructure, not just a one-line gate swap:
- Lift `task_id` parsing out of the existing `match &source_type` block. Read the new `source_task_id` field on `NetworkEvent::JoinedSuccessfully`, falling back to `AmbientAgent.task_id` when the sidecar is `None`. The current code (`terminal_manager.rs:778-783`) only parses when `SessionSourceType::AmbientAgent { task_id }` matches.
- The cloud-only side effects — `mark_terminal_view_as_ambient_agent_session_view` (`terminal_manager.rs:788-790`), `ActiveAgentViewsModel::register_ambient_session` (`794-796`), and the `if matches!(&source_type, SessionSourceType::AmbientAgent { .. })` outer guard at `786` — must stay `AmbientAgent`-only.
- The `OrchestrationViewerModel::new` construction (`terminal_manager.rs:798-816`) moves *outside* the ambient-only guard and runs whenever `enable_orchestration_polling && FeatureFlag::OrchestrationViewerPillBar.is_enabled() && slot.is_none() && resolved_task_id.is_some()`. Pass the resolved `task_id` (sidecar-first) into `OrchestrationViewerModel::new`.
- `pane_group/pane/terminal_pane.rs:183-248` `host_terminal_shared_session_source_type` returns both the active source type AND the hosts `source_task_id`; `inherit_share_for_local_child` cascades when the host has any resolved orchestrator `task_id` (sidecar or `AmbientAgent.task_id`).
- Cascade rule: when the host carries an orchestrator `task_id`, cascade to a local child as the **same variant kind**. `User` host with `source_task_id: Some(parent_task_id)` → child gets `User` with `source_task_id: Some(child_task_id)`. `AmbientAgent { Some(parent_task_id) }` host (cloud orchestrator) → child gets `AmbientAgent { Some(child_task_id) }` (unchanged from today). A host whose orchestrator `task_id` is still `None` (pre-`StreamInit`) does not cascade; once `StreamInit` upgrades the hosts stored `source_task_id` to `Some(_)`, subsequent local children cascade.
#### Wire compatibility
Leaving `SessionSourceType::User` strictly unit means existing readers — including pre-QUALITY-726 viewers — keep parsing the source type without code changes. The sidecar `source_task_id` is additive, gated on `#[serde(default)]`, and ignored by older deserializers that dont know about it.
- **Sharer → server.** The sharer threads `source_task_id` into `InitPayload`. Older sharers omit the field entirely; the servers `#[serde(default)]` falls back to `None`, and the server still reads `source_type.AmbientAgent.task_id` for legacy AmbientAgent producers when the sidecar is absent.
- **Server → viewer.** The server emits `source_task_id` on `JoinedSuccessfully` from the manifest. Older viewers ignore the unknown field; new viewers consume it as the canonical orchestrator `task_id` for the share.
- `From<&SessionSourceType> for LegacySessionSourceType` at `sharer.rs:210-217` only needs to recognize the strict unit `User` (matches `main`); no struct-variant wildcard required.
- `session-sharing-server` vendors its own copy of the protocol crate under `protocol/` rather than depending on the published git crate. The sidecar field must land in both repos in lockstep; otherwise the servers untagged deserializer would drop the field silently and downstream viewers would never see it. This dual-repo update tax is a known maintenance hazard; see *Follow-ups*.
#### Always stamp the conversations `task_id` at share time
- Modern local conversations are already associated with a server-side `ai_tasks` row: `AIConversation::task_id` is populated from the first responses `StreamInit.run_id` (`app/src/ai/agent/conversation.rs:1695-1699`). Thread A reuses this existing id rather than minting a new task at share-time.
- The share initiation API is `TerminalView::attempt_to_share_session(source_type: SessionSourceType, source_task_id: Option<String>, ...)` at `app/src/terminal/view/shared_session/view_impl.rs:521-584`. Update each existing caller that currently passes `SessionSourceType::default()` to instead pass `SessionSourceType::User` plus the conversations task id in `source_task_id`. The concrete call sites are enumerated above in *Relevant files*.
- `start_sharing_session` in `app/src/terminal/local_tty/terminal_manager.rs:1306-1480` is the underlying implementation; it already stores the `source_type` on the `TerminalModel` via `set_shared_session_source_type` (line 1330-1332). Add a sibling `shared_session_source_task_id: Option<String>` field on `TerminalModel` with a matching setter, set during `start_sharing_session`.
- Pre-first-response edge case: if the user shares a brand-new local conversation before any response has arrived, the conversation has no `task_id` yet. Options:
- Carry the share intent and stamp the task id on `StreamInit`; the share starts with `source_task_id = None` and upgrades to `source_task_id = Some(...)` once `task_id` is known. The upgrade path mutates the models stored sidecar and re-emits it to existing viewers via the active sharer `Network`.
- Or block share acceptance until the conversation has a `task_id` (simpler, less ideal UX).
- Recommend the first: it preserves “share immediately” UX and reuses the existing event that already mutates the conversation.
- Conversations that genuinely have no `task_id` (legacy / unanchored) continue to share with `source_task_id: None` and stay invisible to orchestration discovery.
#### Cascade to local children
- Single cascade rule: `inherit_share_for_local_child` cascades when the host carries an orchestrator `task_id` (resolved from `source_task_id`, with `AmbientAgent.task_id` as a fallback for cloud orchestrators that already populate it). A host that is sharing pre-`StreamInit` (no resolved task_id) does *not* cascade, since the hosts viewer cannot enumerate children via REST without the host task id; cascaded children would just hang as loading placeholders. Once `StreamInit` upgrades the hosts stored `source_task_id` to `Some(_)` (per the pre-first-response handling above), subsequent local children cascade.
#### Catch-up cascade for pre-existing children
- The cascade in `inherit_share_for_local_child` only fires at child-pane *creation* time. If the user shares an orchestrator that already has spawned children, those pre-existing child panes were created with `IsSharedSessionCreator::No` and stay unshared even after the parent share goes active. The parent viewer's pill bar lists the children (warp-server has their task rows) but the materialization gate in `OrchestrationViewerModel` never trips because `session_id` is never populated on the child task rows.
- Fix: `PaneGroup` subscribes to `BlocklistAIHistoryEvent::LocalSharedSessionEstablished` (Thread D's event). When the parent's local share goes active, `transitively_share_existing_local_children` iterates direct child agent panes in this group, computes the cascaded source type via `inherit_share_for_local_child`, and dispatches `attempt_to_share_session` on each child that isn't already in a sharer/viewer state. Cascaded children are recorded in `transitively_shared_child_panes` so the host's stop-share cascade also stops them.
- Multi-level: grandchildren are picked up transitively. Each newly-shared child's own `LocalSharedSessionEstablished` event re-enters the subscriber, which then cascades to its direct children. Direct-only iteration per event keeps each cascade decision local to one host pane.
- The cascade carries the childs own `task_id` alongside the hosts variant kind. `User` host with `source_task_id: Some(parent_task_id)` → child gets `User` + `source_task_id: Some(child_task_id)`. `AmbientAgent { Some(parent_task_id) }` host (cloud orchestrator) → child gets `AmbientAgent { Some(child_task_id) }` (unchanged from today). The childs `task_id` is provided by its launch path (`launch_local_no_harness_child` / `launch_local_harness_child`, `terminal_pane.rs:1694-1989`).
- Calling auto-cascaded child shares `User` is semantic shorthand: the *cascade root* was user-initiated, descendants inherit that family for cloud-UI-avoidance purposes, not for strict provenance accuracy. Downstream code that distinguishes user-initiated vs cascaded shares (none today) can use a separate signal if it needs to.
- The local childs `IsSharedSessionCreator::Yes { source_type }` flows through `insert_terminal_pane_hidden_for_child_agent` (`app/src/pane_group/mod.rs:4405-4433`) into the local terminal manager.
- This means an originally single-agent share that later spawns child agents will surface those children in a pill bar without needing any new “upgrade share” step: as long as the host has its `task_id`, the cascade fires, the viewers discovery model picks the child up on the next poll, and the pill bar appears.
- **Stop-share cascade.** When the hosts manual share stops (`stop_sharing_session` in `local_tty/terminal_manager.rs` and the existing `StopSharingCurrentSession` action path), any local children whose share was created via this cascade must also stop sharing. Implementation: track each cascaded childs `PaneId` in a `transitively_shared_child_panes: HashSet<PaneId>` on `PaneGroup`, populated at cascade time in `inherit_share_for_local_child`; on stop, iterate and call `stop_sharing_session` on each. Cloud-spawned children that share independently (`AmbientAgent` host path) are not affected by this stop because they were never in the cascade set. This satisfies `PRODUCT.md` invariant 12.
- Server-side authorization is unchanged: viewers of the parent already have view access to descendant tasks (see `specs/orch-pill-bar-web/TECH.md:127-135`). REST child discovery (`GET /agent/runs?ancestor_run_id=`) already locates local children via the task-id hierarchy populated by `launch_local_no_harness_child` / `launch_local_harness_child`; no extra server-side linkage is required for discovery.
#### Non-orchestrator shares
- A non-orchestrator user share now carries `source_type = User` plus `source_task_id: Some(...)` whenever the conversation has a `task_id`. The viewer-side `OrchestrationViewerModel` will issue one REST descendant fetch and get an empty list; with no children to register in `BlocklistAIHistoryModel`, the pill bar gate in Thread C collapses to `Empty`. See Thread C for the polling-cost handling.
- A non-orchestrator share with no `task_id` (rare — pre-first-response or legacy conversation) stays `source_task_id: None` and triggers no orchestration discovery at all.
- Pill bar render gate at `pane_impl.rs:517-521` already triggers from `FeatureFlag::OrchestrationViewerPillBar`, so no additional rendering change is required once children are registered in `BlocklistAIHistoryModel` by `OrchestrationViewerModel`.
### Thread B — Resolve sibling agent names in conversation bodies
Goal: invariants 26 (parent + child) and 40.
Two independent gaps; do them as separate edits rather than one combined helper:
##### B1. Populate `agent_id_to_conversation_id` for viewer-created children
- Currently `OrchestrationViewerModel::apply_children_fetch` (`orchestration_viewer_model.rs:235-358`) calls `start_new_child_conversation` and then `conversation.set_task_id(task_id)` via `history.conversation_mut(&id)`. `set_task_id` (`conversation.rs:790-792`) updates the field on the conversation only — it does **not** update `BlocklistAIHistoryModel::agent_id_to_conversation_id`. The index is normally populated through `assign_run_id_for_conversation` (`history_model.rs:994-1027`), which is the function `agent_id_key` keys off (`history_model.rs:2228-2232`).
- Fix: in `apply_children_fetch`, after `start_new_child_conversation`, call `BlocklistAIHistoryModel::assign_run_id_for_conversation(conversation_id, run_id, Some(task_id), terminal_view_id, ctx)` using `AmbientAgentTask.run_id` instead of (or in addition to) `set_task_id`. This populates the index on first poll, so transcript references to that child resolve to its `agent_name`.
##### B2. Backfill `parent_agent_id` on viewer-created children
- Today `apply_children_fetch` calls `start_new_child_conversation`, which internally reads the orchestrators `orchestration_agent_id` and sets `parent_agent_id` on the child (`history_model.rs:406-414`). When the orchestrators id is `None` at child-creation time, the childs `parent_agent_id` stays unset and the existing `parent_conversation_id` fallback (`orchestration_conversation_links.rs:120-129`) cannot resolve back to the parent.
- Fix: when the orchestrator conversation receives its own `ConversationServerTokenAssigned` event, iterate previously-tracked viewer-created children whose `parent_agent_id` is unset and call `conversation.set_parent_agent_id(orchestrator.orchestration_agent_id())` directly. No change to the `agent_id_to_conversation_id` index is needed for this leg — that index is keyed by the conversations *own* id, not its parents.
##### B3. Sibling references in conversation bodies (downstream of B1 + B2)
- With B1 + B2 done, received-message-from-agent / send-message-to-agent / lifecycle blocks resolve correctly through the existing renderer (which calls `conversation_id_for_agent_id`, `orchestration_conversation_links.rs:33-45`). No new renderer code is required.
- Confirm no other call site silently substitutes “Orchestrator” for an unresolved id. The current renderer treats missing entries as “unresolved” at `orchestration_conversation_links.rs:120-129`; audit other resolution paths (lifecycle status block, hover card details, breadcrumb) to make sure they do the same.
##### B4. Child-link sibling preload (cases 2/4/8)
- Deferred to a follow-up. The original design created live sibling conversations on the child-link viewers terminal, which polluted `live_conversation_ids_for_terminal_view` and emitted `StartedNewConversation` events for placeholders. A future change should add a name-only resolution path that doesnt go through `start_new_conversation`.
### Thread C — Render the pill bar in parent-scoped viewers for all topologies
Goal: invariants 2427, 31, 38, 51, 52 from `PRODUCT.md`.
- The viewer-side gate restructure described in Thread A is what makes Thread C work end-to-end. Specifically: lift `task_id` parsing out of the AmbientAgent-only `match` at `terminal_manager.rs:778-783`, keep the ambient-only side effects guarded at `terminal_manager.rs:786-797`, and move `OrchestrationViewerModel::new` (`798-816`) so it runs whenever `source_type.orchestrator_task_id().is_some()` plus the feature-flag and slot guards.
- The pill bar continues to hide when there are no children. With Thread As always-stamp policy, the model spins up for every shared session that has a `task_id`, but its REST descendant fetch returns no rows for non-orchestrators, `descendant_conversation_ids_in_spawn_order(orchestrator_id)` stays empty, and `OrchestrationPillBar::pill_specs` returns `None` (see `orchestration_pill_bar.rs:555-580`), so the pill bar collapses to `Empty`. The only cost is one initial REST fetch per viewer of a non-orchestrator share.
- Polling cost handling. `OrchestrationViewerModel`s polling state machine (`orchestration_viewer_model.rs:116-186`) today distinguishes only “active” vs “idle” cadence and uses `polling_handle.is_none()` in `maybe_kick_polling` (lines 168-172) to mean “a kick fetch is already in flight — skip to prevent pile-up”. To add a genuine “stopped because empty” state, introduce an explicit flag on the model:
- Add `idle_due_to_no_children: bool` (false by default).
- In `apply_children_fetch`, when the resulting `children` map is empty, set `idle_due_to_no_children = true`, abort the polling handle, and *do not* schedule another timer.
- In `maybe_kick_polling`, treat `idle_due_to_no_children` as a resume signal: if it is true and the new exchange is on the orchestrator, clear the flag and call `fetch_children`. The existing `polling_handle.is_none()` guard alone is not enough — it would conflate the new stopped state with the existing “in-flight” state. The `AppendedExchange` subscription itself stays in place.
- In any subsequent `apply_children_fetch` that does discover children, clear `idle_due_to_no_children` before scheduling the next poll.
- For native parent-scoped viewers (the user who is doing the sharing): the hosts own `TerminalView` already renders the pill bar via `FeatureFlag::OrchestrationPillBar` and `BlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_order`, which already knows about the users local children. No new model needed on the sharer side.
- For native parent-scoped *viewers* on a second client (the user joining the share from another device, native build): the path is the same shared-session-viewer `TerminalManager` as the web case, just compiled native. Once Thread As gate restructure runs, `OrchestrationViewerModel` constructs and discovery proceeds normally.
- For the web/WASM viewer: same path. Confirm `OrchestrationViewerModel`s REST client (`ServerApiProvider`) is WASM-safe (`wasm_view.rs:180-196`). The pill bar itself has WASM-incompatible pane-management helpers; those are already guarded behind `#[cfg(not(target_family = "wasm"))]` in `orchestration_pill_bar.rs:1670-1750`.
### Thread D — Surface local-to-driver children inside a remote orchestrators pill bar
Goal: invariants 35 (remote-local share parent) and 49.
- Today `OrchestrationViewerModel::apply_children_fetch` (`orchestration_viewer_model.rs:235-358`) waits for `AmbientAgentTask.session_id` before emitting `EnsureSharedSessionViewerChildPane`. For a remote-local child, the local-to-driver child does not get its session id reported via the REST `agent/runs` endpoint, so the materialization never fires and the user sees a perpetual loading pane.
- Root cause: the remote driver mints a local `ai_tasks` row for the local-to-driver child (via the same `launch_local_no_harness_child` path as local-local) and shares that session, but it does not yet register the shared session id on the server side `ai_tasks` row that the viewer can poll.
- Implementation options:
1. Driver-side server update: when the local-to-driver child starts sharing (via the cascade from Thread A on the driver), report the new `session_id` on the childs `ai_tasks` row. This is the same `update_agent_task` path used by the SDK driver. Adds one fire-and-forget RPC per local child shared session.
2. Viewer-side fallback discovery: when the viewer notices a non-terminal child whose `session_id` stays `None` for longer than X seconds, attempt to discover it via a different endpoint. This is a workaround and still depends on the driver having reported the session id.
- Recommend option 1.
- Once `session_id` flows through, the existing `EnsureSharedSessionViewerChildPane` path in `pane_group/mod.rs:3440-3548` joins the child session and the pill UX matches remote-remote.
- **Trigger.** Subscribe to a new lightweight event emitted from `local_tty/terminal_manager.rs` at the point where the sharers `Network` reports a successful share creation — specifically alongside `manager.started_share(...)` in the existing `SharedSessionCreatedSuccessfully` handling. Add a new `BlocklistAIHistoryEvent::LocalSharedSessionEstablished { conversation_id, session_id }` and emit it from that site. The new subscriber lives in a dedicated sibling model at `app/src/ai/blocklist/local_shared_session_link_model.rs`, separate from `TaskStatusSyncModel`, so the two concerns (task-status sync vs session-link sync) stay decoupled.
- **RPC.** The trait signature is `update_agent_task(task_id: AmbientAgentTaskId, task_state: Option<AgentTaskState>, session_id: Option<SessionId>, conversation_id: Option<String>, status_message: Option<TaskStatusUpdate>) -> ...` (`app/src/server/server_api/ai.rs:920-927`). The call is:
```rust path=null start=null
ai_client
.update_agent_task(
task_id,
/* task_state */ None,
/* session_id */ Some(session_id),
/* conversation_id */ None,
/* status_message */ None,
)
.await
```
Wrap it in the same fire-and-forget pattern as `TaskStatusSyncModel::fire_update` (`task_status_sync_model.rs:188-200`).
- **Guards.** Apply the same viewer/remote-child/missing-id checks already used in `TaskStatusSyncModel`:
- Skip when the conversation is a viewer (`conversation.is_viewing_shared_session()`).
- Skip when the child is itself a remote child placeholder (`conversation.is_remote_child()`).
- Skip when `task_id` or `session_id` is `None`.
- Dedupe per `(task_id, session_id)` in an in-memory `HashSet`; reconnect/restart paths should not re-fire. The server treats repeated updates as idempotent; dedupe is a network-traffic optimization.
#### Server-side gates required by Thread D
Two `warp-server` predicates that previously assumed cloud-only execution must be relaxed before Thread D's `update_agent_task(session_id)` will land for local children:
- **`updateSharedSessionLinkQuery` state gate** (`model/ai_run_executions.go`). The query previously required `state = 'RUNNING'`, but local executions transition directly from `CLAIMED` to `ENDED` without ever passing through `RUNNING` (only the cloud worker's `markExecutionRunning` path advances state). Predicate must accept `state IN ('CLAIMED', 'RUNNING')` so the update lands for local children. `ENDED` stays excluded to block stale-session writes against terminal rows.
- **`convertTasksToItems` REST stale-link guard** (`router/handlers/public_api/agent_webhooks.go`). The `/agent/runs?ancestor_run_id=` handler previously stripped `session_id` for any non-active run without GCS transcript data. Local runs have no GCS transcript and reach terminal state quickly; the guard must exempt LOCAL-execution rows so child `session_id` continues to surface to the viewer after the child finishes. The stale-link concern motivating the original guard is cloud-sandbox-specific.
### Cross-cutting: keep the existing pill bar visual + interactions unchanged
- `PRODUCT.md` invariant 22 explicitly defers visual/interaction design to the existing pill bar. No changes to `orchestration_pill_bar.rs` are required for QUALITY-726 beyond making sure the gating expressions in Threads A/C produce a non-empty pill set in each topology.
- Continue to gate the entire feature behind `FeatureFlag::OrchestrationViewerPillBar` so partial rollouts dont break manual shares for users who dont have the flag.
### Out of scope
- Multi-level orchestration (children with children) — `PRODUCT.md` non-goal 1.
- Bulk controls (cancel/restart/message) from the parent pill bar — non-goal 3.
- Visual redesign of the pill bar — non-goal 4.
- Combined export/replay artifacts — non-goal 5.
- Web viewer pane management actions remain native-only and are not added here.
## Testing and validation
Map each affected `PRODUCT.md` invariant to a concrete test or manual verification. Numbers in parentheses reference `specs/QUALITY-726/PRODUCT.md`.
### Unit tests
- Thread A: `terminal_pane_tests.rs` (or sibling) — `inherit_share_for_local_child` returns `Yes { User, source_task_id: Some(child_task_id) }` when the host is sharing as `User` with `source_task_id: Some(_)`, returns `Yes { AmbientAgent { task_id: Some(child_task_id) }, source_task_id: Some(child_task_id) }` when the host is sharing as `AmbientAgent { task_id: Some(_) }`, and returns `No` when the host has no resolved orchestrator `task_id`. Covers (32), (33).
- Thread A: share-modal handler integration — verify that any call to `attempt_to_share_session` on a conversation with a `task_id` passes `SessionSourceType::User` plus `source_task_id: Some(...)`, regardless of whether the conversation currently has child agents. Add a pre-first-response variant that asserts the sidecar is upgraded on `StreamInit` once `task_id` becomes available. Add a regression test that a conversation with no `task_id` stays at `source_task_id: None`. Covers (8), (9).
- Thread A: no-cloud-UI regression — a `User` viewer with `source_task_id: Some(...)` must not get the `Indicator::AmbientAgent` tab badge, must not auto-open the conversation details panel, and must not flip `set_is_in_ambient_agent(true)` on the AI context menu. Cover with focused unit tests on `tab.rs:833`, `view_impl.rs:720`, and `view_impl.rs:766-771`.
- Thread A: stop-share cascade — starting a manual share on a host with a `task_id`, spawning a local child (which cascades), then stopping the hosts share, should also stop the cascaded childs share. A cloud-spawned `AmbientAgent`-cascaded child remains unaffected. Covers `PRODUCT.md:12` and Risks L7.
- Thread A: wire-compat tests in the `session-sharing-protocol` crate — (1) deserializing the legacy `"User"` payload still produces `SessionSourceType::User` and an `InitPayload` round-trip leaves `source_task_id` populated when present; (2) `serde_json::to_string(&SessionSourceType::User)` continues to produce the bare `"User"` form so pre-QUALITY-726 readers can parse it; (3) an `InitPayload` without `source_task_id` deserializes with `source_task_id: None` (backward compat for older sharers); (4) `From<&SessionSourceType> for LegacySessionSourceType` round-trips both `User` and `AmbientAgent` to their legacy unit counterparts.
- Thread C: `orchestration_viewer_model_tests.rs` — a viewer of a `User` share with `source_task_id: Some(...)` and no descendants sets `idle_due_to_no_children = true`, aborts the polling handle after the first empty fetch, and resumes polling on the next `AppendedExchange` event on the orchestrator. A subsequent fetch that discovers children clears the flag and returns to active cadence. Covers the polling-cost mitigation.
- Thread B (B1): `history_model_tests.rs` — after `apply_children_fetch` calls `assign_run_id_for_conversation(child_id, run_id, ...)`, `conversation_id_for_agent_id(run_id)` returns `Some(child_id)`. Covers (26).
- Thread B (B2): `history_model_tests.rs` — a child whose orchestrators server token arrived after the child was created has its `parent_agent_id` backfilled when `ConversationServerTokenAssigned` fires for the orchestrator; `parent_conversation_id(child, ctx)` resolves to the orchestrator afterwards. Covers (26).
- Thread D: a `LocalSharedSessionEstablished` (or equivalent) event with `task_id` + `session_id` triggers exactly one `update_agent_task(task_id, None, Some(session_id), None, None)` RPC; verify the dedupe set blocks a second identical event. Add the standard viewer-guard / remote-child-guard / missing-id negative cases. Covers (35), (49).
### Integration tests
- Local-local share-parent: extend `view_impl_tests.rs` (mirrors existing `JoinedSharedSession` tests) to assert the pill bar renders with all known children once the orchestrator is shared and at least one child exists. Covers (32).
- Local-remote share-parent: combine a local orchestrator with a remote child; assert the parent viewers pill bar lists the remote child and selecting it materializes the existing child pane. Covers (33).
- Remote-local share-parent: stub `update_agent_task(session_id)` to be present, assert pill click materializes the child viewer pane. Covers (35).
- Child-link views (2, 4, 8): assert child transcript references resolve to siblings display names via `conversation_id_for_agent_id`, not `“Orchestrator”` or `“Unknown”`. Covers (14), (26), (40).
### Manual validation
For each of the eight topology × scope combinations in the users exploration, validate:
1. Parent link → parent transcript renders, pill bar visible when ≥1 direct child, child references show correct agent names.
2. Child link → child transcript only, pill bar hidden, sibling references show correct agent names.
3. Web/WASM viewer parity for both link types (no native-only affordances expected on web).
4. Behavior on parent share stop, child finishes, child errored, reconnect — pill bar persists with last known state.
Track results in a check matrix in the PR description.
### Regression coverage
- Add `task_status_sync_model_tests.rs`-shaped coverage for the new `session_id` link (Thread D): positive case (local child gets a `session_id`, RPC fires once), viewer guard (`is_viewing_shared_session = true` → no RPC), remote-child guard (`is_remote_child = true` → no RPC), missing-id guards, and dedupe on repeated `(task_id, session_id)`.
- `orchestration_pill_bar_tests.rs` should keep its existing rendering coverage; no behavior changes required there.
## Parallelization
The four threads touch largely independent code areas. They run as parallel local sub-agents with separate worktrees once Thread A0 lands, then merge into a single PR (or 1-PR-per-thread). The strict sequencing is: A0 (protocol crate) → A/B/C/D in parallel.
All worktrees live under the shared task directory `~/src/orch-shared-sessions/`, alongside the existing `warp` and `warp-server` worktrees on this branch. Thread A0 adds a sibling `session-sharing-protocol` worktree. Each warp-side thread gets its own warp worktree so the four threads can run in parallel without conflicting on the same working copy.
- **Thread A0** — protocol crate changes + warp rev bump (Thread A prerequisite).
- Files owned: external `session-sharing-protocol` crate (`sharer.rs`, `viewer.rs` if needed for legacy payload audits) and the warp-side `Cargo.toml` git rev pin at `Cargo.toml:249` (applied via the `warp-thread-a` worktree as part of A landing).
- Worktree: `~/src/orch-shared-sessions/session-sharing-protocol` (new sibling of the existing `warp` and `warp-server` worktrees).
- Branch: `matthew/QUALITY-726-protocol`.
- Depends on: none.
- Must merge to the protocol crate (and have its commit hash available) before A or C can compile on the warp side.
- **Thread A** — local share cascade + reuse of orchestrator `task_id` (warp client).
- Files owned: `app/src/pane_group/pane/terminal_pane.rs`, `app/src/pane_group/mod.rs` (cascade tracking + stop-share iteration), `app/src/terminal/view/shared_session/view_impl.rs` (`attempt_to_share_session` callers), `app/src/terminal/local_tty/terminal_manager.rs` (`start_sharing_session` + source type upgrade on `StreamInit`), call sites that currently pass `SessionSourceType::default()` (see *Relevant files*), and the `Cargo.toml:249` git rev bump that picks up Thread A0s commit, new tests.
- Worktree: `~/src/orch-shared-sessions/warp-thread-a`.
- Branch: `matthew/QUALITY-726-thread-a`.
- Depends on: Thread A0.
- Shared gate logic with Thread C: the viewer-side gate restructure at `terminal_manager.rs:778-816` is owned by Thread A (it lives in a Thread-A-owned file); Thread C consumes the helper / restructured gate and adds the polling-cost mitigation.
- **Thread B** — agent-id index population (B1) + parent_agent_id backfill (B2). B4 (child-link sibling preload) is deferred; see §B4.
- Files owned: `app/src/ai/blocklist/history_model.rs`, `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs` (audit only), `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs`, tests.
- Worktree: `~/src/orch-shared-sessions/warp-thread-b`.
- Branch: `matthew/QUALITY-726-thread-b`.
- Depends on: none beyond A0 (the source type change does not affect Thread Bs code paths).
- **Thread C** — polling-cost mitigation + render-gate verification.
- Files owned: `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs` (idle-due-to-empty flag, polling state machine), `app/src/terminal/view/pane_impl.rs` (gate audit only — no expected changes), integration tests.
- Worktree: `~/src/orch-shared-sessions/warp-thread-c`.
- Branch: `matthew/QUALITY-726-thread-c`.
- Depends on: Thread A (consumes the viewer-side gate restructure landed in Thread A).
- Coordination with Thread B: both threads touch `orchestration_viewer_model.rs`. Cs state-machine changes are in the polling/timer section (`116-186`); Bs changes are in `apply_children_fetch` (`235-358`) and the join handshake. Merge order does not matter, but rebase the second-to-merge thread on top of the first.
- **Thread D** — driver-side `session_id` link.
- Files owned: `app/src/terminal/local_tty/terminal_manager.rs` (`SharedSessionCreatedSuccessfully` emission point + new event), `app/src/ai/blocklist/local_shared_session_link_model.rs` (new dedicated subscriber model), tests.
- Worktree: `~/src/orch-shared-sessions/warp-thread-d`.
- Branch: `matthew/QUALITY-726-thread-d`.
- Depends on: none beyond A0 (the new `update_agent_task(session_id)` call uses an existing trait signature parameter).
Execution mode: all five threads run locally. The repo builds and integration tests run on the developers machine; no remote-only resources are involved.
```mermaid
graph TD
A0[Thread A0: session-sharing-protocol crate + Cargo.toml rev bump]
A[Thread A: share cascade + reuse orchestrator task_id]
B[Thread B: agent-id index + parent_agent_id backfill]
C[Thread C: polling-cost mitigation + render-gate verification]
D[Thread D: driver session_id link]
Merge[Combined PR / merge point]
A0 --> A
A0 --> C
A --> C
A0 --> B
A0 --> D
A --> Merge
B --> Merge
C --> Merge
D --> Merge
```
If launched as sub-agents, each one should:
- Stay in its assigned worktree under `~/src/orch-shared-sessions/`.
- Run `./script/presubmit` (warp) or the crate-equivalent (`cargo test`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `cargo fmt --check` in `session-sharing-protocol`) before reporting back.
- Report the branch name, changed files, and a list of failing or skipped tests.
- Not merge into `matthew/orch-shared-sessions` directly; the orchestrator (the user, or a separate merge step) integrates the threads into one branch.
## Risks and mitigations
- **Risk:** Cascading sharing from a manually-shared host to every local child it spawns could surprise users who expect a single-pane share. Mitigation: the cascade only fires for local children of a host that is currently sharing AND has a `task_id`; the user can see the cascaded children in the orchestration pill bar; stop-share on the host cascades to a stop-share on the children (see Thread A “Stop-share cascade”); document the behavior in the share modal copy.
- **Risk:** A user shares before any response has come back, so the conversation has no `task_id` yet. Mitigation: stamp the share with `source_task_id: None` initially and upgrade to `source_task_id: Some(...)` on the first `StreamInit` event. The cascade does not fire until the upgrade happens, so children spawned in the pre-stamp window are not auto-shared; surface this in the share modal copy if it becomes a UX problem in practice.
- **Risk:** Every non-orchestrator user share now triggers one initial REST descendant fetch in the viewer, since `source_task_id: Some(...)` is now the new default whenever the conversation has an `ai_tasks` row. Mitigation: the explicit `idle_due_to_no_children` flag in `OrchestrationViewerModel` (see Thread C) ensures the model stops polling after an empty descendant fetch and only resumes on a real `AppendedExchange`. The single initial fetch per viewer is acceptable.
- **Risk:** Driver-side `session_id` link introduces a new fire-and-forget RPC. Mitigation: dedupe on `(task_id, session_id)` and apply the standard viewer/remote-child/missing-id guards (see Thread D) so it only fires when the local client actually owns the shared session.
- **Risk:** Adding `source_task_id` to `InitPayload` and `JoinedSuccessfully` adds new fields to the wire. Mitigation: both are `#[serde(default)]`, so old producers/consumers ignore them silently. `SessionSourceType::User` stays unit-shaped, so legacy viewers continue to parse the source type without changes.
- **Risk:** Forgetting to migrate an orchestration-discovery site from `matches!(source_type, SessionSourceType::AmbientAgent { .. })` to the new sidecar-read path would leave the local-local pill bar broken even after the rollout. Mitigation: read `source_task_id` via a single helper on the payload/model, audit and migrate every existing match site, and add a clippy-friendly comment at the `AmbientAgent`-only sites (tab indicator, AI context menu, details-panel auto-open, viewer-driven sizing skip) explaining why they intentionally stay variant-matched.
- **Risk:** `TelemetryEvent::JoinedSharedSession { session_id, source_type }` (`view_impl.rs:773-779`) still emits the variant kind, but the orchestrator `task_id` is no longer carried inside the variant. Mitigation: include `source_task_id` as a sibling field on the telemetry event so analytics dashboards can filter on the task id without parsing the variant payload.
## Follow-ups
- Once Threads A and D are in place, evaluate whether the `OrchestrationViewerModel` polling cadence is still appropriate for live local-local shares (every 5s may be excessive when all updates are also flowing through the shared-session WebSocket). A follow-up could switch to a WebSocket-driven update for local-local at the cost of larger protocol changes.
- Consider unifying the dispatcher state-machine on the server side so local and cloud executions share a single life-cycle for session-link writes. The CLAIMED-vs-RUNNING split is currently implicit in worker behavior; an explicit `state IN ('CLAIMED', 'RUNNING')` predicate captures the intent but doesn't address why local executions never advance past CLAIMED in the first place.
- Once the upstream `session-sharing-protocol` commit is published and consumed by both warp and session-sharing-server, drop the vendored copy in session-sharing-server entirely and depend on the git crate. Removes the two-place-update tax for future wire changes.
+108
View File
@@ -0,0 +1,108 @@
# QUALITY-731 — Agent name round trip client spec
## Context
QUALITY-731 is a shared-session viewer bug: the orchestrator's own client labels children with `agent_run_configs[i].name`, but a viewer reconstructs child conversations from server task records and currently does not have access to that short name. As a result, viewer-side pills, hover cards, breadcrumbs, status cards, and transcript participant labels fall back to `title`, which can be a long descriptive sentence or a truncated prompt.
The fix sources the orchestrator's short label from the existing `agent_config_snapshot.name` field instead of introducing a parallel top-level `name` field on the task or request types. This aligns with the paired warp-server spec, which uses `AgentConfigSnapshot.Name` as the canonical home for the orchestrator-supplied label.
## Scope
This PR delivers the orchestrator → server → viewer round-trip plus the single highest-value display surface: the orchestration pill bar in `OrchestrationViewerModel::apply_children_fetch`. Other surfaces that still render `task.title` (or `entry.display.title`) directly are left for follow-up work, tracked under "Out of scope (follow-ups)" below. The wire contract and `display_name()` helper this PR introduces are the prerequisites those follow-ups will consume.
Relevant existing client surfaces:
- `app/src/server/server_api/ai.rs``SpawnAgentRequest.config: Option<AgentConfigSnapshot>` already exists. `CreateAgentTaskInput.agent_config_snapshot: Option<String>` (serialized JSON) already exists. Both REST and GraphQL channels can carry an `AgentConfigSnapshot` payload today.
- `app/src/ai/ambient_agents/task.rs``AgentConfigSnapshot.name: Option<String>` (`#[serde(default, skip_serializing_if = "Option::is_none")]`) already exists. `AmbientAgentTask.agent_config_snapshot: Option<AgentConfigSnapshot>` already deserializes from the server.
- `app/src/ai/blocklist/action_model/execute/run_agents.rs``RunAgentsExecutor` fans out each `RunAgentsAgentRunConfig`; `cfg.name` is the source of truth on the client side.
- `app/src/pane_group/pane/terminal_pane.rs``launch_remote_child` builds a `SpawnAgentRequest` with an `AgentConfigSnapshot` for `config`. `launch_local_no_harness_child` and `launch_local_harness_child` build the local Oz / harness child task via `AIClient::create_agent_task` and the harness's `local_child_task_config(harness)`.
- `app/src/pane_group/pane/local_harness_launch.rs``prepare_local_harness_child_launch` constructs the `local_child_task_config` snapshot.
- `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs``apply_children_fetch` calls `task.display_name()` for the child conversation's `agent_name`. That helper currently reads from the QUALITY-731 v1 `AmbientAgentTask.name` field.
- `app/src/ai/blocklist/history_model.rs``start_new_child_conversation` writes its `name` argument directly into `AIConversation::agent_name`.
- `app/src/ai/agent/conversation.rs``agent_name()` backs orchestration label surfaces; `title()` falls back to `fallback_display_title`.
- `app/src/ai/conversation_details_panel.rs``ConversationDetailsData::from_task` reads `task.title` for the side-pane header.
Design options considered:
- Parallel scalar fields on the task (the QUALITY-731 v1 approach: `AmbientAgentTask.name`, `SpawnAgentRequest.name`, `AIClient::create_agent_task` `agent_name`, GraphQL `CreateAgentTaskInput.agentName`). Required two name-like fields on the wire and in the model. Rejected per reviewer.
- Reuse `agent_config_snapshot.name` (selected). No new fields. Outbound paths stamp the orchestrator name inside the existing `AgentConfigSnapshot { ... }` builder. The viewer's `display_name()` reads from `agent_config_snapshot.name`. Backward-compatible with any task that already populates the field through some other means.
## Proposed changes
### Outbound request wiring
Stamp the orchestrator-supplied short name into the existing `AgentConfigSnapshot` payload at request-construction boundaries. Trim whitespace at the construction site; treat empty/whitespace-only as absent.
1. `launch_remote_child` in `app/src/pane_group/pane/terminal_pane.rs` builds the `AgentConfigSnapshot` it puts on `SpawnAgentRequest.config`. Add `name: ...` to that struct literal with the trimmed `request.name`, filtered for empty. No more top-level `request.name` clone or `SpawnAgentRequest.name` field.
2. `launch_local_no_harness_child` (local Oz path) in `terminal_pane.rs` currently passes `None` for the config snapshot to `create_agent_task`. Replace with `Some(AgentConfigSnapshot { name: trimmed(request_name), ..Default::default() })`.
3. `launch_local_harness_child` / `prepare_local_harness_child_launch` in `app/src/pane_group/pane/local_harness_launch.rs` build their snapshot via `local_child_task_config(harness)`. Extend `local_child_task_config` to take `agent_name: Option<String>` and stamp it inside the returned snapshot. Drop the QUALITY-731 v1 `agent_name` parameter on `AIClient::create_agent_task` and the trim inside its impl; the construction site is now the single source.
4. `agent_sdk/ambient.rs` CLI `agent run-cloud` (REST) already sets `config.name = args.name`. No change needed.
5. `build_handoff_spawn_request` (handoff) and `spawn_agent` (standalone cloud-mode) don't supply a name today. No change.
### Inbound response read
Rewrite `AmbientAgentTask::display_name(&self) -> &str` in `app/src/ai/ambient_agents/task.rs`. Lookup order:
1. Trimmed `agent_config_snapshot.as_ref().and_then(|c| c.name.as_deref())` when present and non-empty.
2. Trimmed `title` when non-empty.
3. The literal `"Agent"`.
`OrchestrationViewerModel::apply_children_fetch` keeps its existing `let name = task.display_name().to_string();` line. The downstream `conversation.set_fallback_display_title(task.title.clone())` call remains so the descriptive title stays available via `AIConversation::title()` fallback.
### Removals (QUALITY-731 v1 rollback)
Source code:
- `app/src/server/server_api/ai.rs`: remove `SpawnAgentRequest.name` field and its serde attrs. Remove the `agent_name: Option<String>` parameter from the `AIClient::create_agent_task` trait method and its impl, including the trim block and the `agent_name` line inside `CreateAgentTaskVariables`.
- `app/src/ai/ambient_agents/task.rs`: remove the `AmbientAgentTask.name: Option<String>` field and its serde default. Keep `display_name()` as a helper, but rewrite its body per the above section.
- `app/src/pane_group/pane/terminal_pane.rs`: remove the `request.name.clone()` plumbing in `launch_remote_child`, the `agent_name_for_create = Some(request_name.clone())` line and the corresponding 5th positional arg in `launch_local_no_harness_child`, and the `agent_name_for_task = Some(request_name.clone())` line + 5th arg in `launch_local_harness_child`.
- `app/src/pane_group/pane/local_harness_launch.rs`: remove the `agent_name: Option<String>` parameter from `prepare_local_harness_child_launch` and the 5th positional arg threaded into `create_agent_task`. The `#[allow(clippy::too_many_arguments)]` attribute on this function becomes unnecessary; remove it if so.
- `app/src/ai/conversation_details_panel.rs`: keep the deferral comment QUALITY-731 v1 added on `from_task`; the surface remains on `task.title` (unchanged behavior).
- `crates/warp_graphql_schema/api/schema.graphql`: remove the `agentName: String` field + docstring under `CreateAgentTaskInput`.
- All `name: None` literals on `SpawnAgentRequest { ... }` builders added in QUALITY-731 v1 (in `agent_sdk/ambient.rs`, `terminal/view/ambient_agent/model.rs`, `terminal/view_tests.rs`, `agent_sdk/mcp_config_tests.rs`, `ambient_agents/spawn_tests.rs`, `terminal/view/ambient_agent/model_tests.rs`): remove.
- All `name: None` literals on `AmbientAgentTask { ... }` test fixtures added in QUALITY-731 v1 (in `agent_conversations_model_tests.rs`, `cloud_conversation_continuation_tests.rs`, `conversation_ended_tombstone_view_tests.rs`, `view_impl_tests.rs`, `spawn_tests.rs` `task_with` helper, `pane_group/mod_tests.rs`, `conversation_details_panel_tests.rs`, `orchestration_event_streamer_tests.rs`): remove.
Tests:
- `app/src/server/server_api/ai_tests.rs`: remove `spawn_agent_request_serializes_name_when_present`, `spawn_agent_request_omits_name_when_none`, and the `name: None` line in the `make_spawn_agent_request` fixture.
- `app/src/ai/ambient_agents/task_tests.rs`: rewrite the five `display_name_*` tests to construct an `AmbientAgentTask` with an `agent_config_snapshot.name` value (or `None`) instead of a top-level `name`. Keep the same precedence-coverage shape (name+title, name=None falls back to title, name=whitespace falls back to title, empty title returns "Agent", trimming).
- `app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs`: rewrite the four `registers_child_agent_name_*` tests to populate `task.agent_config_snapshot.name` instead of `task.name`. The `make_task` / `make_task_with_name` helpers move the name into the config snapshot fixture.
- `app/src/pane_group/pane/local_harness_launch_tests.rs`: rewrite `prepare_local_codex_child_forwards_agent_name_to_create_agent_task` and `prepare_local_codex_child_passes_none_agent_name_when_unset` to assert that `local_child_task_config` (or the constructed snapshot) carries `name` correctly. Or drop them in favor of a single test on `local_child_task_config` directly.
## End-to-end flow
```mermaid
flowchart LR
A[run_agents cfg.name/cfg.title] --> B[RunAgentsExecutor]
B --> C[StartAgentRequest name + Remote title]
C --> D[SpawnAgentRequest config.name + title]
C --> E[createAgentTask agentConfigSnapshot.name for local harness]
D --> F[warp-server agent_config_snapshot.name + title]
E --> F
F --> G[GET /agent/runs returns agent_config_snapshot.name + title]
G --> H[OrchestrationViewerModel]
H --> I[display_name = agent_config_snapshot.name]
H --> J[fallback_display_title = title]
```
## Testing and validation
Unit/client tests:
- `app/src/ai/ambient_agents/task_tests.rs`: `display_name()` precedence (snapshot.name > title > "Agent") and trim behavior, including whitespace-only title.
- `app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs`: viewer registration of orchestrator name via `agent_config_snapshot.name`, fallback to title (using distinct snapshot/title values so the two channels are distinguishable), "Agent" final fallback, whitespace-only title gating of `set_fallback_display_title`.
- `app/src/pane_group/pane/local_harness_launch_tests.rs`: `local_child_task_config` carries the orchestrator name in its snapshot, trims whitespace, returns `None` for Oz/Unknown harnesses. `normalize_orchestrator_agent_name` covers the trim/empty-vs-Some contract.
- The construction-site wiring in `launch_remote_child` (the `SpawnAgentRequest.config.name` field is stamped with the result of `normalize_orchestrator_agent_name(&request.name)`) is covered indirectly by the `normalize_orchestrator_agent_name` unit tests + visible inspection of the struct literal in `terminal_pane.rs::launch_remote_child`. A dedicated test for the assembled `SpawnAgentRequest` would require factoring out a `build_spawn_request` helper, which is deferred as out of scope here (the function takes a `&mut PaneGroup` + `ViewContext<PaneGroup>` and resolves runtime skills + snapshot-disabled flag through them, none of which are unit-testable as-is).
Manual validation:
1. Start or load an orchestrated shared session where the orchestrator's outbound spawn populates `agent_config_snapshot.name = "frontend-tests"` and a long descriptive `title`.
2. Open the session as a viewer.
3. Verify the pill label, hover card participant label, breadcrumb, child status card, and transcript participant all show `frontend-tests`.
4. Verify the long title remains available as `AIConversation::title()` fallback wherever the existing fallback path is used.
5. Verify a child whose orchestrator did not set a name still shows the skill-derived default (provided by the server).
6. The conversation details side pane intentionally remains on `task.title` per the QUALITY-731 v1 deferral.
Commands to run after implementation:
- Targeted Rust tests for any modules touched, for example:
- `cargo test -p warp -- ai::ambient_agents::task`
- `cargo test -p warp -- terminal::shared_session::viewer::orchestration_viewer_model_tests`
- `cargo test -p warp -- pane_group::pane::local_harness_launch_tests`
- `cargo fmt`
- `./script/presubmit` before pushing (skip the `command-signatures-v2` step locally only if the corepack/yarn-4 setup blocks it on this machine; CI is authoritative).
- Manual UI verification against a local client connected to a server with the paired pivot changes.
## Parallelization
Server agent: local, `/Users/matthew/src/roundtrip-agent-name/warp-server`, branch `matthew/roundtrip-agent-name`, base `origin/matthew/restore-remote-orch-conversations`, draft PR target #11223. Owns server-side rollback + helper + REST/GraphQL contract drops.
Client agent: local, `/Users/matthew/src/roundtrip-agent-name/warp`, branch `matthew/roundtrip-agent-name`, base `origin/master`, draft PR target #11090. Owns client-side rollback + outbound snapshot stamping + `display_name()` rewrite.
Sequencing:
- Both PRs can be force-pushed in parallel — the wire contract (use existing `agent_config_snapshot` envelope, drop QUALITY-731 v1 parallel fields) is fully agreed upfront.
- End-to-end manual validation must wait for both branches to be on the pivoted contract.
PR hygiene:
- Force-push removes the QUALITY-731 v1 commits from each PR. Rewrite the PR descriptions to call out the pivot and link to the paired PR.
- Mark the `Warp Agent Mode` checkbox on the client PR template.
- Keep both PRs in draft.
## Out of scope (follow-ups)
The pivot delivers the wire contract and the orchestration viewer pill. The following surfaces still render `task.title` (or the denormalized `entry.display.title`) directly and would benefit from a follow-up that fans `display_name()` through them, but each requires an additional plumbing decision (the entry-based ones in particular don't have access to `agent_config_snapshot` today):
- `app/src/ai/agent_conversations_model/entry.rs``AgentConversationEntry` is hydrated from `ListConversationsItem` and only carries `display.title` today. Routing `display_name()` here means denormalizing `agent_config_snapshot.name` onto the entry (or fetching the task) before render time.
- `app/src/ai/conversation_details_panel.rs::from_agent_conversation_entry` — same source as above; downstream of the entry.
- `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs` — the tombstone reuses `task.title` for its header and reuses `agent_config_snapshot.name` as `skill_name`; the latter is now mislabeled (an inline `QUALITY-731 follow-up` comment marks this). Splitting orchestrator-supplied agent name from skill-spec rendering belongs to a follow-up.
- `app/src/ai/agent_sdk/ambient.rs` (~line 845) — CLI/SDK-side surface that already reads task records; can adopt `display_name()` once the helper is publicly reachable from that path.
- `app/src/workspace/view/conversation_list/item.rs` and `app/src/workspace/view.rs` — workspace conversation list labels flow from `entry.display.title`; same plumbing decision as the entry-based surfaces.
The `ConversationDetailsData::from_task` side-pane header is intentionally not in scope: product still evaluates whether to show both the short name and the descriptive title. The inline deferral comment remains in place.
## Risks and mitigations
- `display_name()` change: anywhere that read `AmbientAgentTask.name` directly (instead of going through the helper) must be moved to the helper to pick up the new source. Mitigation: deleting the field forces a compile error at every direct read; fix them at the call site.
- Existing test fixtures with explicit `name: None` will no longer compile. Mitigation: blanket-revert the `name: None` lines as part of the same change.
- A future caller that sets both `agent_config.name` and an orchestrator name via some other channel: server enforces the always-override precedence; client only stamps when an orchestrator name is provided. No client-side collision.
- The details panel surface stays on `task.title` per the v1 deferral. Mitigation: the deferral comment in `conversation_details_panel.rs` remains.
- Whitespace-only `task.title` would desync `agent_name()` (trimmed → `"Agent"`) from `title()` (untrimmed) if the viewer fallback gate were not also trimmed. Mitigation: the gate in `OrchestrationViewerModel::apply_children_fetch` calls `task.title.trim().to_string()` before checking and before storing on the conversation. A dedicated test (`registers_child_agent_name_does_not_set_fallback_for_whitespace_only_title`) locks this in.
- Force-push removes the v1 commits from the PR history; existing reviewer comments on those commits stay attached to the orphaned commits. Mitigation: PR description rewrite explains the pivot and links to the v1 review history.
+166
View File
@@ -0,0 +1,166 @@
# QUALITY-768: Restore orchestration session state
## Context
Restarting Warp left orchestration sessions in four distinct broken states. Local-local orchestration came back without the pill bar, and `send_message_to_agent` / `messages_received` rows rendered the child as `"Unknown agent"`. Local-remote orchestration restored the placeholder child pane as the empty `"New agent conversation"` shell instead of the cloud transcript that was actually streaming server-side. A meaningful share (~50% in some traces) of local-no-harness Oz child conversations were silently dropped at read time entirely. And a cloud-parent orchestration session (a shared-session viewer pane locally) survived the first restart correctly but lost its cloud-mode shape on the next snapshot — the second restart re-materialised the parent as a plain local terminal with the cloud session's env-setup blocks replayed and no orchestration UI. Underneath all four symptoms, the on-disk eviction policy was splitting orchestration trees across restarts, so even a healthy read path would re-encounter partial state on the next boot.
The subsystem-level explanation of restoration ↔ orchestration lives in the architecture report at `/Users/matthew/src/orch-restore/restoration-and-orchestration.md`. The PR body summarises the user-visible behaviour. This spec is the implementation-level account of the four code-level decisions that ship in this PR; an earlier read-time optimistic-stub filter was superseded by upstream PR #11814 and dropped during the rebase — see change 3 below.
### Relevant code
- `crates/persistence/src/schema.rs:11``agent_conversations` table.
- `crates/persistence/src/schema.rs:20``agent_tasks` table.
- `crates/persistence/src/model.rs (935-1006)``AgentConversation::is_restorable` (the multi-root acceptance rules introduced by upstream PR #11814 cover the optimistic-stub case).
- `app/src/persistence/agent.rs (38-233)` — disk-side prune entry point and `select_conversations_to_evict`.
- `app/src/ai/restored_conversations.rs:17``RestoredAgentConversations`, the consume-once startup store.
- `app/src/ai/blocklist/history_model.rs:55-68``MAX_HISTORICAL_CONVERSATIONS`.
- `app/src/ai/blocklist/history_model/conversation_loader.rs:64``convert_persisted_conversation_to_ai_conversation_with_metadata`, the single conversion entry point used by both startup consumers.
- `app/src/ai/blocklist/history_model/conversation_loader.rs:472``initialize_historical_conversations`.
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:624``OrchestrationPillBar::pill_specs`, reads `conversations_by_id`.
- `app/src/ai/blocklist/block/view_impl/orchestration.rs:87``participant_for_agent_id`, reads `conversations_by_id`.
- `app/src/pane_group/mod.rs:927``child_agent_panes` index (`HashMap<AIConversationId, PaneId>`).
- `app/src/pane_group/mod.rs (1078-1151)``PendingRemoteChildHydration` struct, `RemoteChildHydrationAction` enum, `decide_remote_child_hydration_action`.
- `app/src/pane_group/mod.rs:3899``hydrate_task_backed_hidden_child_pane`.
- `app/src/pane_group/mod.rs:4007``attempt_remote_child_hydration`.
- `app/src/pane_group/mod.rs:4114``hydrate_remote_child_transcript_in_place`.
- `app/src/pane_group/mod.rs:4228``attach_ambient_session_and_maybe_tombstone`.
- `app/src/ai/blocklist/history_model.rs:2353``merge_cloud_tasks_into_existing_conversation`.
- `app/src/pane_group/mod.rs``create_shared_session_viewer` (the `is_cloud_mode` parameter routes restored parent panes through the cloud-mode constructor).
- `app/src/pane_group/pane/terminal_pane.rs``TerminalPane::snapshot`, whose shared-session-viewer branch chooses between `LeafContents::AmbientAgent { task_id }` and a fallback empty `LeafContents::Terminal` based on `view.ambient_agent_view_model()`.
- `app/src/terminal/shared_session/viewer/terminal_manager.rs``viewer::TerminalManager::new` (non-deferred constructor; `is_cloud_mode` is plumbed through `new_internal`).
- `app/src/terminal/view.rs``TerminalView::new`, where `ambient_agent_view_model` is only constructed when `is_cloud_mode == true`.
## Proposed changes
The implementation now breaks into four self-contained changes that share the read/write path described in the architecture report. A fifth change in the original scope (a read-time optimistic-stub filter in `crates/persistence/src/model.rs`) has been superseded by upstream PR #11814 "Fix optimistic-root persistence breaking conversation restore (QUALITY-774)" and is no longer carried by this PR; the rationale lives below in change 3.
### 1. Eager orchestration-child hydration in `initialize_historical_conversations`
`initialize_historical_conversations` (`app/src/ai/blocklist/history_model/conversation_loader.rs:472`) previously only indexed orchestration children in `children_by_parent` and `agent_id_to_conversation_id`; insertion into `conversations_by_id` was deferred until the parent's hidden child pane materialised. After the change, the loader detects orchestration children via `resolved_parent_conversation_id_from_persisted_data` and eagerly inserts the fully-deserialised `AIConversation` into `conversations_by_id` (`conversation_loader.rs:558-573`), reusing the existing `convert_persisted_conversation_to_ai_conversation_with_metadata` conversion. No `RestoredConversations` event is emitted, no `live_conversation_ids_for_terminal_view` entry is registered, and no AI blocks are constructed. The later `restore_conversations` call from lazy pane materialisation overwrites the eager entry idempotently.
Tradeoff. The plan considered emitting a `RestoredConversations` event from the eager path so downstream consumers could subscribe symmetrically. Rejected: it would force every `RestoredConversations` subscriber to handle the "no terminal view" case, and the surfaces that actually need the child (pill bar, name resolver) already read from `conversations_by_id` directly. The eager insert is also gated to orchestration children only; non-orchestration historical conversations stay on the lazy path.
### 2. Direct `AmbientAgentTask` inspection for remote-child transcript hydration
The plan originally routed restored remote-child hydration through `AgentConversationsModel::resolve_open_action`. Once `conversations_by_id` carries the placeholder eagerly (change 1), the resolver returns `RestoreOrNavigateToConversation`, a variant that collapses "navigate to the local conversation" and "hydrate the cloud transcript onto the local placeholder" into a single outcome. Widening the resolver would force every navigation site to handle a new variant; the remote-child path is the only site that wants the cloud-transcript outcome.
Instead the hidden-pane hydration inspects the `AmbientAgentTask` directly. The dispatch is extracted into a pure function:
```rust path=null start=null
enum RemoteChildHydrationAction {
LiveAttach,
LoadTranscript { server_token: ServerConversationToken, task_is_terminal: bool },
Fallback { task_is_terminal: bool },
}
fn decide_remote_child_hydration_action(task: &AmbientAgentTask) -> RemoteChildHydrationAction { ... }
```
`RemoteChildHydrationAction` and `decide_remote_child_hydration_action` live in `app/src/pane_group/mod.rs`. The function filters empty/whitespace `conversation_id` tokens via `task.conversation_id().map(str::trim).filter(|t| !t.is_empty())` before treating the value as a usable `LoadTranscript` target, and computes `task_is_terminal = matches!(live_session_state, Inactive)` once so both `LoadTranscript` and `Fallback` carry the same gate.
`hydrate_task_backed_hidden_child_pane` creates the hidden pane up front, registers it under the placeholder's local id in `child_agent_panes`, and either calls `attempt_remote_child_hydration` synchronously when task data is cached, or installs an entry in the named `pending_remote_child_hydrations: HashMap<AmbientAgentTaskId, PendingRemoteChildHydration>` map and retries when `AgentConversationsModelEvent::TasksUpdated` fires. An inner idempotency guard returns whenever a live tracked pane already exists for the placeholder, so a second mid-hydration call from `restore_missing_child_agent_panes_for_parent` — even before the initial transcript merge has populated any exchanges — cannot insert a duplicate hidden pane and orphan the first one. The `LoadTranscript` branch routes through `hydrate_remote_child_transcript_in_place`, which fetches the cloud transcript via `BlocklistAIHistoryModel::load_conversation_by_server_token` and merges it onto the placeholder via `BlocklistAIHistoryModel::merge_cloud_tasks_into_existing_conversation` (`app/src/ai/blocklist/history_model.rs`). The merge preserves the placeholder's local `AIConversationId`, parent linkage, agent name, run id, and `is_remote_child` flag, and returns `anyhow::Error` if the placeholder has been evicted from `conversations_by_id`. The caller already handles that error path by falling back to the live-attach + tombstone branch.
The post-match step is centralised in `attach_ambient_session_and_maybe_tombstone`, which calls `apply_existing_ambient_task_to_pane` and then inserts the conversation-ended tombstone iff `task_is_terminal == true`. All four arms (`LoadTranscript` Ok merge, `LoadTranscript` Err merge, `LoadTranscript` non-Oz / fetch-failure fallback, and `Fallback`) route through this helper so the gate stays uniform — an `ActiveUnattachable` task whose transcript fetch errors out, returns a non-Oz payload, or never had a server token in the first place no longer gets a misleading "conversation ended" tombstone.
The async continuation guard inside `ctx.spawn` requires both `child_agent_panes[child_id] == pane_id` and the pane's terminal view's `active_conversation_id == Some(child_id)` before mutating UI state, so a racing nav or competing hydration cannot clobber a stale target.
Tradeoff. The plan considered widening `resolve_open_action` to expose a `HydrateRemoteChildPlaceholder` variant. Rejected for the reason above. The plan also considered keeping the dispatch inline inside `attempt_remote_child_hydration`; extracted into a free function so the decision (Attachable, ActiveUnattachable + token, Inactive + token, Inactive + no token, ActiveUnattachable + no token, plus the empty-token-filter case) is unit-testable without standing up a `PaneGroup`.
### 3. Optimistic-stub handling (superseded by upstream PR #11814)
Local-no-harness Oz children sometimes persisted two root-shaped tasks: a 38-byte zero-payload optimistic stub created at child-spawn time, and a real upgraded root carrying the actual messages. `AgentConversation::is_restorable` saw two root-shaped tasks and rejected the row, silently dropping the conversation at the entry to `initialize_historical_conversations`.
Upstream PR #11814 ("Fix optimistic-root persistence breaking conversation restore", QUALITY-774) addresses the same symptom on two coordinated layers: `Task::source_for_persistence` returns `None` for `Optimistic(Root)` so no new stub rows are written, and `AIConversation::new_restored` deduplicates multi-root payloads by preferring the parentless task with non-empty `messages`. `AgentConversation::is_restorable` was relaxed to accept the multi-root [stub + real] shape so legacy DB rows still load through the restore path. The local-DB conversion entry point `convert_persisted_conversation_to_ai_conversation_with_metadata` now calls `AIConversation::new_restored_synthesizing_on_empty`, which synthesizes a fresh optimistic root when the persisted task list is empty.
This PR's earlier read-time filter (`task_is_root_shaped`, `optimistic_stub_task_id`, `tasks_for_restore`, `into_tasks_for_restore`) and the seven unit tests pinning it have been removed during the rebase onto upstream. Upstream's restore-side dedupe (with a 50-iteration HashMap-order regression test in `app/src/ai/agent/conversation_tests.rs`) plus its relaxed `is_restorable` cover the same ground at the same layer with less surface area, so a parallel filter is redundant.
### 4. Tree-aware persisted-conversation prune
`select_conversations_to_evict` (`app/src/persistence/agent.rs:143`) replaces the previous per-row FIFO LRU prune in `upsert_agent_conversation` (`agent.rs:60-118`). Each persisted row is grouped into its orchestration tree by walking `parent_conversation_id` to a root (parse failures are treated as their own root; orphan references where the declared parent is missing from the row set are likewise treated as roots). Trees are sorted freshest-first by `max(member.last_modified_at)` with ties broken by `root_id` ascending. The greedy keep loop always retains the freshest tree intact — even if it alone exceeds `MAX_PERSISTED_CONVERSATION_COUNT` — and then keeps each subsequent tree atomically while the cumulative kept count is within the cap. Hard-stop semantics: once any tree exceeds the budget, every older tree is also evicted.
The retention cap moved from 100 to 200 (`MAX_PERSISTED_CONVERSATION_COUNT`, `app/src/persistence/agent.rs:49`). The mirrored read-side cap `MAX_HISTORICAL_CONVERSATIONS` (`app/src/ai/blocklist/history_model.rs:68`) was bumped to the same value with an inline comment noting that the read-side cap is currently moot because the disk-side prune keeps the persisted set inside the same window.
The iteration uses an `iter.next()` pattern to unconditionally keep the freshest tree, then a `for` loop over the remainder, avoiding the `first: bool` flag that would otherwise sit inside the loop body.
Tradeoffs.
- **Freshest-tree exception.** The plan considered a strict cap that evicts even from the freshest tree. Rejected: a strict cap could split an active orchestration session in half on disk, regressing into the same "broken half-tree" failure mode that motivated this change. The unbounded freshest-tree case is documented as a known limitation below.
- **Sharing the constant.** Two `const usize` values in two files is a soft drift hazard, but `crates/persistence` is upstream of `warp` in the workspace graph and cannot import from it. The reverse import would pull persistence-only code into the read-side path. Documented in `MAX_HISTORICAL_CONVERSATIONS`'s comment that the read cap is moot only as long as it stays ≥ the disk cap.
- **Parse failure handling.** Rows whose `conversation_data` fails JSON parsing are treated as their own root rather than being silently linked into another tree. The disk row is untouched and the eviction algorithm just refuses to chain a malformed row into a tree.
### 5. Cloud-mode shared-session viewer snapshot/restore loop
Changes 14 fix restoration for orchestration *children*. A separate latent bug affected the *parent* of an orchestration tree when the parent itself was a cloud agent: its local pane is a shared-session viewer attached to the cloud session, and the restoration → snapshot → restoration loop lost the cloud-mode shape on the second cycle.
`restore_pane_leaf`'s `LeafContents::AmbientAgent → AmbientRestoreKind::SharedSession` arm called `create_shared_session_viewer(session_id, …)`, which used `shared_session::viewer::TerminalManager::new(…)`. The non-deferred constructor hardcoded `is_cloud_mode = false` when delegating to `new_internal`, so the resulting `TerminalView` had no `ambient_agent_view_model` (`TerminalView::new` only constructs the model when `is_cloud_mode == true`). The pill bar and transcript still rendered on restart 1 because the `JoinedSuccessfully` viewer-network handler spins up an `OrchestrationViewerModel` regardless of `is_cloud_mode`. But the snapshot path in `app/src/pane_group/pane/terminal_pane.rs` checks `view.ambient_agent_view_model()` to decide between `LeafContents::AmbientAgent { task_id }` and the fallback empty `LeafContents::Terminal`. With `ambient_agent_view_model = None`, every shutdown after the first restoration emitted the empty `Terminal` shape and the `task_id` was lost. On restart 2 the pane re-materialised as a fresh local terminal pane sharing the original UUID, replayed the cloud session's env-setup blocks from the persisted block list, and surfaced a local shell prompt with no orchestration UI or pill bar.
The fix threads an explicit `is_cloud_mode: bool` parameter through `create_shared_session_viewer``viewer::TerminalManager::new``new_internal`. The two ambient-agent restoration call sites (`restore_pane_leaf`'s `SharedSession` arm and `process_pending_ambient_restorations`'s `OpenOrAttachAmbientAgentConversation` arm) now pass `is_cloud_mode: true`, so the restored view has an `ambient_agent_view_model`, the existing `JoinedSuccessfully` handler's `enter_viewing_existing_session(task_id)` path fires, and the next snapshot correctly emits `LeafContents::AmbientAgent` with the `task_id`. The two other callers stay `false`: `ensure_shared_session_viewer_child_pane` (per-child viewer; hidden child panes are excluded from the snapshot tree by design) and `new_for_shared_session_viewer` (navigation-to-shared-session entry point — has the same latent issue but is outside this PR's scope; see Follow-ups).
Tradeoff. The plan considered a snapshot-only fallback that would read the `task_id` from the live `OrchestrationViewerModel` when `ambient_agent_view_model` is absent. Rejected: it would leave the runtime shape mismatch in place. A freshly created cloud-mode pane has an `ambient_agent_view_model`; a restored one would still not, and any future feature reading from `ambient_agent_view_model` on a viewer pane would silently misbehave only after a restart. Routing the restoration through the cloud-mode constructor makes the restored pane's shape match the freshly created shape.
## End-to-end flow
Tracing one boot through the four changes (with upstream PR #11814's stub handling sitting upstream of them) makes the causal chain visible. The diagram below covers changes 14; change 5 is orthogonal (it concerns the parent pane's snapshot shape rather than the child-restore data flow).
```mermaid
flowchart TD
A[SQLite agent_conversations + agent_tasks] -->|read_agent_conversations| B[Vec<AgentConversation>]
B -->|new_restored_synthesizing_on_empty<br/>+ multi-root dedupe<br/>(upstream PR #11814)| C[restored AIConversation]
C --> D[RestoredAgentConversations]
C --> E[initialize_historical_conversations]
E -->|orchestration children| F[(conversations_by_id)]
F --> G[OrchestrationPillBar::pill_specs]
F --> H[participant_for_agent_id]
F --> I[hydrate_task_backed_hidden_child_pane]
I -->|decide_remote_child_hydration_action| J{Action}
J --> K[LiveAttach: apply_existing_ambient_task_to_pane]
J --> L[LoadTranscript: merge_cloud_tasks_into_existing_conversation]
J --> M[Fallback: attach in place]
L --> N[attach_ambient_session_and_maybe_tombstone]
K --> N
M --> N
A -->|upsert_agent_conversation| O[select_conversations_to_evict]
O -.tree-aware.- A
```
Upstream PR #11814 sits upstream of every other change: it determines which rows survive restore and therefore which rows enter `RestoredAgentConversations` and `initialize_historical_conversations`. The eager-child hydration is what surfaces children into `conversations_by_id` early enough for the pill bar / name resolver to see them and what creates the resolver-collision case that motivates direct `AmbientAgentTask` inspection in the hidden-pane hydration path. The tree-aware prune feeds back into the next boot: if the prune splits trees, the read path's invariants are violated regardless of how well the in-memory side is wired up.
## Testing and validation
Unit coverage lives alongside each module:
- Tree-aware eviction (`app/src/persistence/agent.rs (348-566)`, eight cases): `prune_is_no_op_when_under_limit`, `keeps_fresh_tree_atomically_and_evicts_older_singletons`, `child_kept_drags_parent_along`, `parent_kept_drags_child_along`, `orphan_with_missing_parent_is_its_own_tree`, `single_tree_larger_than_limit_is_kept_in_full`, `parse_failure_row_is_treated_as_root_and_can_be_referenced_by_others`, `eviction_is_deterministic`.
- Stub / multi-root restore coverage now lives in upstream PR #11814's tests: `is_restorable_*` in `crates/persistence/src/model.rs` (five cases covering single-root, multi-root with one real root, multi-root with multiple real roots, multi-root with no real root, empty/single-task) and the dedupe loop in `test_new_restored_prefers_parentless_task_with_messages_over_empty_stub` (`app/src/ai/agent/conversation_tests.rs`).
- Eager orchestration-child hydration (`app/src/ai/blocklist/history_model_tests.rs`): `test_initialize_historical_conversations_eagerly_hydrates_orchestration_children` (line 332), plus `test_initialize_historical_conversations_resolves_parent_agent_id_children_via_seeded_run_ids` (line 258) covers the parent-resolution side path. The eager-hydration test asserts the child is in `conversations_by_id`, that the parent is NOT loaded eagerly, that child run-ids resolve via `conversation_id_for_agent_id`, and that child metadata is excluded from navigation.
- Remote-child hydration dispatch (`app/src/pane_group/mod_tests.rs`): `decide_remote_child_hydration_action` covered with six cases — `LiveAttach` for `Attachable`; `LoadTranscript` for `Inactive` + token (`task_is_terminal: true`); `LoadTranscript` for `ActiveUnattachable` + token (`task_is_terminal: false`); `Fallback` for `Inactive` + no token (`task_is_terminal: true`); `Fallback` for `ActiveUnattachable` + no token (`task_is_terminal: false`, asserting the in-progress run is not visually marked ended); and `decide_remote_child_hydration_empty_token_falls_back` for the empty/whitespace filter added during review.
- LoadTranscript → merge integration coverage (`app/src/ai/blocklist/history_model_tests.rs`): `merge_cloud_tasks_into_existing_conversation_preserves_placeholder_identity`. Builds a placeholder remote-child conversation with `parent_conversation_id` + `agent_name` + `run_id` + `is_remote_child`, drives `merge_cloud_tasks_into_existing_conversation` with a cloud transcript carrying a non-empty title and one user-query exchange, and asserts the merged conversation retains the placeholder's local id and orchestration linkage while surfacing the cloud transcript content. A second assertion exercises the precondition guard by calling merge against an unknown placeholder and asserting `Err`.
- Cloud-mode viewer snapshot contract (`app/src/pane_group/mod_tests.rs`): `create_shared_session_viewer_with_cloud_mode_populates_ambient_agent_view_model` asserts the restored view's `ambient_agent_view_model().is_some()` so the snapshot path's `LeafContents::AmbientAgent` branch is reachable on the next shutdown. A companion test, `create_shared_session_viewer_without_cloud_mode_does_not_populate_ambient_agent_view_model`, pins the non-cloud-mode behaviour so a future flip of the default would be loud.
Manual validation matrix (covers each change end-to-end on a restart):
1. Local-local restart: pill bar renders with the correct agent names (no "Unknown agent" fallback); the multi-root stub shape is silently healed by upstream restore dedupe; conversation list UI is intact.
2. Tree-aware prune: orchestration trees stay together on disk across the cap.
3. Local-remote restart: the cloud transcript merges onto the local placeholder; the "New agent conversation" placeholder bug is gone; live runs continue to attach.
4. Cloud-parent restart-restart: a remote orchestration parent restores correctly across two consecutive Warp restarts; no fallback to a stray local terminal with replayed env-setup blocks.
5. Baseline single-conversation restore: no regressions.
Validation commands: `cargo fmt -p warp -p persistence`, `cargo clippy -p warp --all-targets --features local_fs -- -D warnings`, `cargo clippy -p persistence --tests --all-features -- -D warnings`, and `cargo nextest run` over `pane_group::`, `persistence::agent::tests`, `ai::blocklist::history_model::tests`, and `persistence::model::tests`.
Deferred coverage:
- A property test that asserts `select_conversations_to_evict` never produces an evict-list that splits an orchestration tree (currently inferred from the case tests).
- An end-to-end PaneGroup restart test that exercises `hydrate_remote_child_transcript_in_place` against a mock cloud fetch (the existing test covers the smaller seam directly).
## Risks and mitigations
- **Unbounded freshest tree.** `select_conversations_to_evict` always retains the freshest tree intact, so a single very large orchestration session can push the on-disk row count above 200. Mitigation: the next session's prune evicts everything older, so the steady-state row count stays within the cap unless every session spawns a hundred-plus children. If this becomes a real problem, the freshest-tree exception can be replaced with an "evict the oldest tree members first" within-tree policy, but that re-introduces the half-tree failure mode and is deliberately deferred.
- **Soft drift between `MAX_PERSISTED_CONVERSATION_COUNT` and `MAX_HISTORICAL_CONVERSATIONS`.** The two constants live in different files and crates. They happen to agree today, and the read-side cap's comment documents the invariant: the read cap is moot only as long as the disk cap is `≤` it. Mitigation: the comment, the symmetry of the test coverage, and a future single-const refactor (called out under Follow-ups).
- **Eager-hydration sort-window asymmetry.** `initialize_historical_conversations` walks rows sorted freshest-first by `last_modified_at`, capped at `MAX_HISTORICAL_CONVERSATIONS`. In principle a fresh child could land inside the window without its (stale) parent. Mitigation: tree-aware disk eviction keeps parents fresh enough that this case hasn't been observed; the parent-resolution path tolerates parents that aren't in `conversations_by_id` by falling back to `children_by_parent`.
- **Shared `AgentConversationsModel` subscription for two pending maps.** `ensure_pending_ambient_restoration_subscription` (`app/src/pane_group/mod.rs:3725`) drives both `pending_ambient_agent_conversation_restorations` and `pending_remote_child_hydrations`. Mitigation: maps are kept distinct so the visible-tree `replace_pane` flow does not accidentally swap a hidden child pane; the cost is small and bounded by the number of restored ambient panes.
## Follow-ups
- Share the retention constant between `crates/persistence` and `warp` once the read cap might plausibly be raised independently (for example, if a future history view wants to surface more than the disk cap can hold). Today the read cap is moot, so a separate constant is acceptable; tomorrow it may need to be a single source of truth.
- Add a property test that asserts `select_conversations_to_evict` never returns an evict-list that splits an orchestration tree across the kept/evicted boundary. The existing case tests cover the substantive shapes; a property test would shore them up under generated inputs.
- Tighten `decide_remote_child_hydration_action` against `AmbientAgentLiveSessionState` variants added in future schema bumps. The function currently matches `Attachable` and `Inactive` explicitly; new variants fall into the `LoadTranscript` / `Fallback` decision based on whether the task has a server token. A `cfg`-gated exhaustiveness assertion would catch a new variant at compile time.
- `new_for_shared_session_viewer` has the same latent snapshot/restore loop as the ambient-agent restoration paths fixed by §5. It's a separate entry point used when the user opens a shared session from the conversation list, not the orchestration restore flow, so it's deferred. The shape of the fix is identical: pass `is_cloud_mode: true` from that call site once we confirm the cloud-mode behaviour is desired for non-restore opens.
+92
View File
@@ -0,0 +1,92 @@
# Client Awareness of `wait_for_events` Yields
## Summary
When an Oz cloud agent calls the server-side `wait_for_events` tool, the agent has not finished its work — it has yielded the turn and is waiting on an inbound message or lifecycle event. The Warp client must observe this yield, keep the run alive, suppress completion-shaped UI (notifications, success badges), and surface a distinct "waiting for events" presentation in the orchestration UI.
## Problem
Today the client cannot tell a `wait_for_events` yield apart from real completion. The model turn ends cleanly, the local conversation status flips to `Success`, and two things go wrong as a result:
1. The Oz CLI driver treats `Success` as run completion and schedules the process exit through its `--idle-on-complete` timer. For cloud agents, this means the worker can exit seconds after yielding, even though child events were expected. Server-side compensations (`shouldPreserveInProgressOnClientSuccess`, `ExtendTaskIdleTimeout`) preserve task state in the database but do not keep the running process alive, so the inbound event has no agent to wake.
2. The notifications mailbox fires `NotificationCategory::Complete` ("Task completed.") on every `Success` transition. Orchestrators that yield between turns produce spammy "Task completed" toasts that users have complained about.
A fix has to come from the client: server-side preservation cannot keep a local process alive and cannot suppress local notifications.
Note on orchestration badges: in current one-level orchestration the pill bar aggregator already returns `InProgress` whenever any child is still active, so a yielded orchestrator with children-in-flight does not display a green check today. The new state still drives a distinct badge in the narrower case where an orchestrator yields with no descendants (or with all descendants terminal) and in any future multi-level orchestration; see invariants 2124.
## Goals
1. The client distinguishes a `wait_for_events` yield from real completion using a first-class state.
2. While yielded, the agent run stays alive, the conversation reads as non-terminal, completion notifications do not fire, and orchestration UI surfaces a distinct "waiting" badge.
3. The yield state clears automatically when the next user input or inbound event resumes the agent.
## Non-goals
1. Changing how `wait_for_events` is invoked by the model, or its tool contract from the agent's perspective.
2. Adding a new mechanism for the user to manually pause / resume a run.
3. Redesigning the orchestration pill bar visual language; the new state reuses existing badge primitives with a new color/icon.
4. Re-architecting `ConversationStatus` consumers beyond what is required to add the new variant exhaustively.
## Figma
Figma: none provided. The new badge should reuse the existing `render_avatar_with_status_overlay` / `status_icon_and_color` plumbing with a distinct color and icon for `WaitingForEvents` so it is visually distinguishable from `Success`, `Blocked`, and `InProgress`.
## Behavior
### Terms
1. A **`wait_for_events` yield** is the act of the agent calling the server-side `wait_for_events` tool. The yield ends the current model turn but does not end the run.
2. A **waiting run** is an agent run whose most recent turn ended via a `wait_for_events` yield and which has not yet received any resume input — user query or other conversation input, inbound message, inbound lifecycle event, cancellation, or watchdog timeout — that ends the waiting state.
3. A **terminal status** is one of `Success`, `Error`, `Cancelled`. These mean the run is finished.
4. A **quiescent status** is a status in which the agent is not actively streaming output. `Success`, `Error`, `Cancelled`, `Blocked`, and the new `WaitingForEvents` are all quiescent. `InProgress` is not quiescent.
### Conversation status
5. The client adds a new `ConversationStatus::WaitingForEvents` value alongside the existing `InProgress`, `Success`, `Blocked`, `Error`, and `Cancelled` values.
6. `WaitingForEvents` is **quiescent but not terminal**: the agent is not actively streaming, but the run is still alive and may resume.
7. When the current model turn ends via a `wait_for_events` yield, the conversation status transitions to `WaitingForEvents`, not `Success`.
8. When the agent resumes, the conversation status transitions back to `InProgress` and the waiting state is cleared. Any of the following triggers a resume: the user submits a new query or other conversation input; the user invokes a slash command or other action that adds a new exchange; the orchestration event stream delivers a message or lifecycle event the agent will consume on its next turn.
9. `WaitingForEvents` may only be reached from `InProgress`. It may transition to `InProgress`, `Cancelled`, `Error`, or `Success` (if a follow-up turn completes the run conventionally). It must not transition directly to another `WaitingForEvents` without re-entering `InProgress` first.
10. The waiting state is **not** durable across restart. Shutting the app down ends the wait; on the next start the conversation restores as whatever its last-exchange status implies (typically `Success`, since the yielding stream finished cleanly). The unresolved `wait_for_events` tool call stays in the transcript as an orphan, and the next outbound request triggers the server's existing supersede mechanism to synthesize the matching `Cancel`. The user can re-engage manually if they want to resume the conversation.
### Run lifecycle
11. A waiting Oz cloud agent run does not trigger CLI driver process exit. The local CLI driver only schedules its `--idle-on-complete` exit timer on a true terminal status, not on `WaitingForEvents`.
12. A waiting run may still bound its lifetime: if **no resume input arrives** within an upper bound — no user query or other input, and no inbound message or lifecycle event — the client emits an empty `WaitForEventsResult` as a new tool-call-result input on the agent's behalf, closing the unresolved `wait_for_events` call. The agent's next turn observes the empty timeout result and decides how to proceed (commonly `finish_task`, but the agent may also re-yield via another `wait_for_events`, ask the user, or take other action). The run does **not** auto-cancel on timeout; the agent owns the post-timeout decision. The upper bound is read from the server-supplied `idle_timeout_seconds` on the `wait_for_events` tool call, falling back to a built-in client default if the server did not supply one.
13. The watchdog used in (12) is distinct from the completion idle timer. A `WaitingForEvents` run does not enter the completion-idle path even when the configured `--idle-on-complete` value is shorter than the waiting watchdog.
14. The user can cancel a waiting run through any existing cancel affordance. Cancellation transitions to `Cancelled` immediately.
15. The local `ai_tasks` row reported via `LocalAgentTaskSyncModel.update_agent_task` reports `IN_PROGRESS` while the conversation is `WaitingForEvents`. The client does not report `SUCCEEDED` for a yielded conversation.
### Notifications
16. A transition into `WaitingForEvents` does not produce any notification (no toast, no badge in the notification mailbox).
17. A pre-existing stale notification for the same conversation origin (e.g. a leftover "task in progress" item) is cleared on entry to `WaitingForEvents`, the same way `InProgress` clears it today.
18. A subsequent transition from `WaitingForEvents` back to `InProgress` (because the agent resumed) does not produce a notification on its own.
19. A subsequent transition from `WaitingForEvents` to a terminal status (`Success`, `Error`, `Cancelled`) produces the same notification that the same transition from `InProgress` would have produced.
20. An orchestrator's notifications on `Success`, `Cancelled`, and `Error` fire as they do today. If the orchestrator itself reaches `Success` (or another terminal status) it is treating itself as done; the mailbox does not second-guess that based on the state of descendants. The known "orchestrator notification spam" case is the one where the orchestrator yielded via `wait_for_events` between turns — that case is already handled by (16), because the orchestrator's status is `WaitingForEvents`, not `Success`, while it is waiting.
### Orchestration pill bar and avatar badges
21. A child pill whose conversation status is `WaitingForEvents` renders with the new "waiting" badge (icon + color distinct from `Success`, `Blocked`, and `InProgress`).
22. The orchestrator pill bar badge is driven by `aggregated_orchestrator_status` over the orchestration tree. The aggregator's precedence is `InProgress > Blocked > WaitingForEvents > Error > Cancelled > Success`, with one carve-out: when the orchestrator itself yielded into `WaitingForEvents`, its own waiting state outranks any descendant `InProgress`. Rationale:
- `InProgress` wins when the orchestrator itself is active or when no node is yielded, because something is actively streaming.
- When the orchestrator's own status is `WaitingForEvents` but a descendant is still running, the parent's waiting state is the more useful signal: the user sees that THIS conversation is paused waiting on inbound input even while work continues in the tree.
- `Blocked` outranks `WaitingForEvents` because a blocked node needs user action and must not be masked by a quiescent parent.
- `WaitingForEvents` outranks terminal statuses because the parent is explicitly still alive and listening; the orchestration is not done.
23. The hover details card and the orchestration breadcrumb avatars use the same badge mapping. There is no per-surface override for `WaitingForEvents`.
24. The pill sort order treats `WaitingForEvents` as part of the "active-ish" section of the bar, not the "done" bucket. A waiting child does not drift to the right of completed siblings.
### Other client surfaces
25. The block status bar and any "is the agent thinking" indicator must not show a streaming spinner for `WaitingForEvents`. Waiting is quiescent: no spinner, no Stop button, no live-streaming affordances.
26. Conversation input is enabled while in `WaitingForEvents`: the user can submit a new query, run a slash command, or any other action that produces a new exchange. Doing so clears the waiting state and starts a new turn (transitioning to `InProgress`). The user does not need to wait for an inbound event before submitting input.
27. The block status bar may show an unobtrusive "waiting for events" affordance when the conversation is in `WaitingForEvents`. The exact copy is up to design; this spec only requires it not be styled as completion.
28. `ConversationStatus::is_done()` keeps its existing semantics — `Success | Error | Cancelled` — and so returns `false` for `WaitingForEvents`. No new helper is introduced.
### Resume and clearing the waiting state
29. The waiting state is cleared (i.e., the conversation leaves `WaitingForEvents`) when any of the following happens. The agent itself cannot self-resume from `WaitingForEvents`; resume always requires input from outside the agent's own decision-making — user input, the orchestration event stream, the client-side watchdog acting on the agent's behalf, or user cancellation.
- The user submits a new query, runs a slash command, or otherwise adds a new exchange to the conversation. A user query is a first-class resume path — it does not require an inbound event to arrive first. The server's existing supersede mechanism emits a generic `Cancel` tool-call result for the unresolved `wait_for_events` call; the agent's next turn sees both the cancel and the new input.
- The orchestration event stream delivers a message or lifecycle event that the agent will consume in its next turn. Same server-side supersede path as the user-input case.
- The user cancels the run; the conversation transitions to `Cancelled`. Pending tool calls are not retroactively cancelled — the unresolved `wait_for_events` tool-call message stays in transcript history as an orphan.
- The watchdog from (12) fires; the client emits an empty `WaitForEventsResult` on the agent's behalf, closing the unresolved call. The conversation transitions back to `InProgress` while the agent's next turn decides how to proceed (per (12)). The pending tool call is *not* orphaned by the watchdog path because the client-emitted result closes it.
30. Clearing the waiting state is observable: a transition out of `WaitingForEvents` must produce a status update event so subscribers (task sync, notifications, pill bar) re-evaluate.
31. The waiting state must not survive across distinct agent runs. Starting a new conversation never inherits `WaitingForEvents` from a prior conversation.
### Backwards compatibility and rollout
This fix requires a coordinated release across the proto contract (`warp-proto-apis`), the server (`warp-server`), and the client (`warp`). The user-visible behavior during the rollout is bounded by the following invariants.
32. The fix is gated by a server-side feature flag. When the flag is **off** for a `wait_for_events` call, the legacy behavior is unchanged: the client treats the conversation as `Success`, the CLI driver exits per `--idle-on-complete`, and the existing server-side preservation (`shouldPreserveInProgressOnClientSuccess`, `ExtendTaskIdleTimeout`) keeps the task `IN_PROGRESS` on the server. This is the pre-fix bug and is acceptable during rollout.
33. When the server flag is **on** and the client build includes the new `WaitingForEvents` support, all behavior in this spec applies. The flag flips per `wait_for_events` call (not per conversation), so an individual call's behavior is determined by the flag state at yield time.
34. The flag may flip mid-conversation. A single conversation can legitimately contain both legacy (pre-flag) and new (post-flag) `wait_for_events` yields. The new yields activate the `WaitingForEvents` flow; legacy yields stay on the existing path. The user-visible expectation is that new yields show the "waiting" badge and suppress completion notifications, while legacy yields look the same as today. Both produce the same final outcome (the run resumes correctly when the next inbound event arrives) because the server-side gates protect task state on both paths.
35. A new client receiving a transcript that contains only legacy `wait_for_events` yields (e.g. restored from a pre-flag persistence record) treats the conversation as it does today — no retroactive reclassification is attempted. The legacy server-handled tool call is opaque to the client and cannot be detected.
36. A new client receiving the new public `WaitForEvents` tool-call variant from any server with the flag on activates the full fix. There is no separate client-side feature flag.
+382
View File
@@ -0,0 +1,382 @@
# Client Awareness of `wait_for_events` Yields — Tech Spec
## Context
See `specs/QUALITY-780/PRODUCT.md` for user-visible behavior. This spec maps the product invariants onto the existing conversation status, driver lifecycle, task sync, notifications, and orchestration pill bar code paths in the Warp client, and identifies the server-side change needed so the client can actually observe a `wait_for_events` yield. The server-side spec lives at `warp-server/specs/QUALITY-780/TECH.md`.
### Today's behavior in the bug
The end-to-end path that produces the bug is:
1. The model calls the server-handled `wait_for_events` tool. `HandleWaitForEvents` in `warp-server/logic/ai/multi_agent/runtime/ambient_agents.go` returns a `ServerToolCallResult::WaitForEventsResult` and side-effects `MarkActiveExecutionYieldedForWaitForEvents` and `ExtendTaskIdleTimeout`.
2. The current model turn ends; the agent's response stream finishes successfully.
3. `Message::ToolCallResult` messages (including the legacy server-handled `WaitForEventsResult`) are applied to the local conversation in the `response_event::Type::ClientActions(...)` arm of `BlocklistAIController::handle_response_stream_event` at `app/src/ai/blocklist/controller.rs:2614-2631`, which calls `history_model.apply_client_actions(...)`. The conversation's `ConversationStatus::Success` transition itself fires later when the `BlocklistAIActionEvent` subscriber at `app/src/ai/blocklist/controller.rs:495-518` observes that no follow-up action is queued and marks the response stream completed successfully. (The `AfterStreamFinished` arm at `controller.rs:2680+` is post-stream cleanup; it does not apply `ClientActions`.)
4. `LocalAgentTaskSyncModel.handle_history_event` (`app/src/ai/blocklist/local_agent_task_sync_model.rs:119-151`) maps `Success``AgentTaskState::Succeeded` and fires `update_agent_task`.
5. The server's `ApplyClientUpdates` path calls `shouldPreserveInProgressOnClientSuccess` from the `AgentTaskStateSucceeded` arm at `warp-server/logic/ai/ambient_agents/dispatcher.go:2013` (the predicate itself lives at `dispatcher.go:2110-2146`). It sees the `wait_for_events` marker and clears `in_progress_since` rather than transitioning the task to `SUCCEEDED`. The server **task state** remains preserved.
6. But on the client, `AgentDriver`'s subscription to `BlocklistAIHistoryEvent::UpdatedConversationStatus` (`app/src/ai/agent_sdk/driver.rs:2600-2683`) sees `Success` and either calls `run_exit.end_run_now(...)` (no `idle_on_complete` configured) or schedules `run_exit.end_run_after(idle_timeout, ...)` (idle timeout configured). When that future resolves, the Oz CLI driver process exits via `ctx.terminate_app(...)`.
7. `AgentNotificationsModel.handle_history_event_for_mailbox` (`app/src/ai/agent_management/agent_management_model.rs:304-389`) fires `NotificationCategory::Complete` ("Task completed.") on the same `Success` transition.
8. `aggregated_orchestrator_status` (`app/src/ai/blocklist/orchestration_topology.rs:64-106`) returns `Success` when no node is `InProgress`/`Blocked`/`Error`/`Cancelled`, so the orchestration pill bar's orchestrator badge renders the green check via `render_avatar_with_status_overlay`.
The combined effect is the bug report: an Oz cloud agent worker exits seconds after yielding for events and fires a misleading "Task completed" toast. The orchestration pill bar badge is also wrong in the narrower case where an orchestrator yields with no active descendants (today's one-level orchestration means active children already drive the aggregator to `InProgress`; the badge fix matters for the no-descendants case and is forward-compatible with any future multi-level orchestration).
### Relevant files
#### Conversation status and persistence
- `app/src/ai/agent/conversation.rs:4067-4168``ConversationStatus` enum, `status_icon_and_color`, `render_icon`, `is_in_progress`, `is_blocked`, `is_cancelled`, `is_done`, `is_error`.
- `app/src/ai/agent/conversation.rs:777-814``status()`, `update_status_with_error_message`.
- `app/src/ai/agent/conversation.rs:195-323``AIConversation` struct definition with all durable fields including `parent_agent_id`, `agent_name`, `last_event_sequence`, `pinned`.
- `app/src/ai/agent/conversation.rs:3038-3128``write_updated_conversation_state` constructs `AgentConversationData` for SQLite persistence.
- `app/src/ai/agent/conversation.rs:700-720``derive_status_from_root_task` reconstructs status from last-exchange output on restore. Today, a successful exchange always derives `Success`. Note: this function takes only `root_task: &Option<&Task>` — it has no access to `AgentConversationData` and is called from the restore path at `conversation.rs:542`.
- `app/src/persistence/model/...``AgentConversationData` struct definition (the SQLite schema for restored conversations).
#### Driver / process lifecycle
- `app/src/ai/agent_sdk/driver.rs:147-202``IdleTimeoutSender` (the generation-based oneshot that drives Oz CLI exit timing).
- `app/src/ai/agent_sdk/driver.rs:720-812``AgentDriver::run`; tx/rx oneshot that signals the CLI to terminate the process. The async block that wraps `run_internal` (defined separately at `driver.rs:1594+`) is spawned here.
- `app/src/ai/agent_sdk/driver.rs:1879-1914``HarnessKind::Oz` branch awaits `status_rx` from `execute_run()`; on resolution sleeps 1s then returns the conversation status.
- `app/src/ai/agent_sdk/driver.rs:2429-2709``execute_run`, which subscribes to `BlocklistAIHistoryEvent::UpdatedConversationStatus` and maps `Success | Blocked | Cancelled` to either immediate or idle-on-complete-delayed run exit.
- `app/src/ai/agent_sdk/driver.rs:2861-2949``subscribe_to_cli_agent_session_events`; the same `Success | Blocked` → exit mapping for third-party harnesses.
- `app/src/ai/agent_sdk/mod.rs:1415``ctx.terminate_app(TerminationMode::ForceTerminate, None)` when `driver.run` returns `Ok(())`.
#### Task sync model
- `app/src/ai/blocklist/local_agent_task_sync_model.rs:119-151``handle_history_event` reacts to `UpdatedConversationStatus`.
- `app/src/ai/blocklist/local_agent_task_sync_model.rs:314-355``map_conversation_status` maps `ConversationStatus` to `AgentTaskState`.
#### Notifications
- `app/src/ai/agent_management/agent_management_model.rs:209-302``handle_history_event` and `handle_history_event_for_mailbox`.
- `app/src/ai/agent_management/agent_management_model.rs:304-389` — Per-status notification branches.
- `app/src/ai/agent_management/agent_management_model.rs:471-482``ConversationStatus::should_trigger_notification`.
#### Orchestration pill bar and topology
- `app/src/ai/blocklist/orchestration_topology.rs:64-106``aggregated_orchestrator_status` with precedence `InProgress > Blocked > Error > Cancelled > Success` (precedence to be updated).
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:119-151``pill_status_sort_key`, `pill_secondary_sort_key`, `DONE_STATUS_KEY`.
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:631-705``pill_specs` constructs pill data; orchestrator gets aggregated status, children use their own status.
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:1390-1397` — Hover card uses aggregated status for orchestrators.
- `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:2112-2156``render_avatar_with_status_overlay`.
#### Server tool-call result handling
- `app/src/ai/blocklist/controller.rs:2614-2631` — the `response_event::Type::ClientActions(actions)` arm of `BlocklistAIController::handle_response_stream_event`. This is where `AddMessagesToTask` actions (which carry the tool-call-result messages, including any new `WaitForEvents` tool-call result) are dispatched into the conversation via `history_model.apply_client_actions(...)`.
- `app/src/ai/blocklist/controller.rs:495-518``BlocklistAIActionEvent` subscriber that drives the conversation's `Success` transition after no follow-up action is queued. Not the same code path as `AfterStreamFinished`.
- `app/src/ai/blocklist/controller.rs:2680+``ResponseStreamEvent::AfterStreamFinished` handler; post-stream cleanup. Does **not** apply `ClientActions`.
- `app/src/ai/blocklist/history_model.rs:1484``apply_client_actions` (the function that adds `AddMessagesToTask` actions to a conversation; the natural hook point for the new `WaitForEvents` tool-call detection).
- Search for `WaitForEventsResult` in the client today: no hits. The legacy server tool-call result is opaque to clients (carried in the `Message::ToolCallResult::ServerResult { serialized_result: <opaque string> }` variant per `warp-proto-apis/apis/multi_agent/v1/task.proto:939-941`).
## Design options
Three were considered. We are recommending Option B (first-class variant) because the existing exhaustive-matching conventions make it the safest change to land cleanly; the others are documented for context.
### Option A — Marker on `AIConversation`, status stays `Success`
Add a boolean `waiting_for_events: bool` on `AIConversation` (and persist it on `AgentConversationData`). Conversation status still flips to `Success` on stream finish, but every status-consuming surface that cares (`LocalAgentTaskSyncModel`, `AgentDriver`, notifications, pill bar aggregator) reads the marker alongside the status.
- Pros: smallest blast radius; no enum-variant churn; `match conversation.status()` sites that don't care about waiting keep working unchanged.
- Cons: invisible to exhaustive matching, which is how the original bug propagated in the first place. Any new consumer of `ConversationStatus::Success` will silently treat a waiting conversation as done. The "is this a real success?" check has to be repeated at every site that needs it; we cannot rely on the compiler to enumerate them.
- Verdict: rejected. The whole reason the bug exists is that `Success` is overloaded.
### Option B — First-class `ConversationStatus::WaitingForEvents` variant (recommended)
Add a new variant alongside `InProgress`, `Success`, `Blocked`, `Error`, `Cancelled`.
- Pros: exhaustive matching enumerates every site that needs to make a deliberate decision. Existing `match conversation.status()` arms (icon, color, telemetry, sort key, mailbox) fail to compile until they decide what to do, which is the exact failure mode we want the compiler to catch. Models the state accurately: quiescent but not terminal, like `Blocked`.
- Cons: touches more files (every `match conversation.status()`).
- Verdict: chosen.
### Option C — Reuse `ConversationStatus::InProgress`
Have the conversation stay `InProgress` while yielded.
- Pros: trivially keeps the driver alive (the existing `is_in_progress()` branch already cancels idle timers) and naturally satisfies orchestration aggregation precedence.
- Cons: `InProgress` carries an implicit "actively streaming" meaning throughout the codebase — block status bar shows a spinner, the Stop button is enabled, the input is disabled in some flows, "thinking" UI animates. A yielded run is none of those things. Every UI site that keys off `InProgress` would either misfire or need a new way to ask "is the agent really doing anything?"
- Verdict: rejected. The overload is even worse than Option A.
## Proposed changes
### 1. `ConversationStatus::WaitingForEvents` variant
In `app/src/ai/agent/conversation.rs:4067-4168`:
```rust path=null start=null
pub enum ConversationStatus {
InProgress,
Success,
Error,
Cancelled,
Blocked { blocked_action: String },
// New:
WaitingForEvents,
}
```
Update `Display`, `render_icon`, and `status_icon_and_color` exhaustively. The new badge needs a color and icon distinct from every existing status. Explicit collisions to avoid:
- `Success` uses `theme.ansi_fg_green()` and `Icon::Check` (`conversation.rs:4121-4127`).
- `InProgress` uses `theme.ansi_fg_magenta()` and `Icon::ClockLoader` (`conversation.rs:4114-4120`).
- `Blocked` uses `theme.ansi_fg_yellow()` and `Icon::StopFilled` (`conversation.rs:4136-4142`).
Recommended palette: `theme.ansi_fg_blue()` with a "listening" or "hourglass" icon. Final choice deferred to design with a `TODO(design)` placeholder; this spec only requires that the visual be unambiguous against the three quiescent-non-terminal-adjacent siblings above.
### 2. `ConversationStatus::is_done()` is unchanged
`is_done()` keeps its existing semantics — `Success | Error | Cancelled` — so it already returns `false` for `WaitingForEvents`. No predicate split is needed; the existing five `is_done()` call sites (search row, conversation-list sections, `/cost`, fork data source) all want "the run is finished and cannot resume", which is exactly what `is_done()` already conveys. `should_trigger_notification` adds `WaitingForEvents => false`.
### 3. Persistence and restore
The `WaitingForEvents` status is **not** durable. The agent execution that the wait keeps alive is in-process state by definition; an app shutdown ends the wait the same way it ends every other running tool call.
Concretely:
- `AgentConversationData` carries no `waiting_for_events` field. There is nothing new to write in `write_updated_conversation_state`.
- `derive_status_from_root_task` is the sole authority on restore status. A conversation that was yielded at shutdown restores as `Success` because the yielding response stream finished cleanly.
- The unresolved `wait_for_events` tool-call message stays in the persisted transcript as an orphan. The next outbound request from the user re-engaging the conversation reaches the server with no result for that tool call, and the server's existing pending-tool-call supersede mechanism synthesizes the matching `Cancel`. From the agent's perspective the yield is just another inbound supersede.
- The `LocalAgentTaskSyncModel` flips back to reporting `Succeeded` on restore. The server's `shouldPreserveInProgressOnClientSuccess` gate (server TECH §1.1) handles this safely: the marker is still on the server's task row, so the dispatcher keeps the task `IN_PROGRESS` for the rollout window during which the gate exists.
Alternative considered (and rejected): persist `waiting_for_events: bool` on `AgentConversationData` and override `derive_status_from_root_task` on restore. Rejected because it added durable state for an in-process concept and introduced a stale-state risk (an offline client missing a resume signal could come back showing a multi-day "waiting" badge for a long-since-reaped server task). The honest model — "the wait ends when the app dies" — has a smaller surface area and degrades gracefully.
### 4. Wait-for-events action and executor
`wait_for_events` is modeled as a first-class `action_model` action so the watchdog, the conversation status transition, and the follow-up request all flow through the executor's lifecycle. This avoids a thicket of guards that would otherwise be needed to keep `WaitingForEvents` from being clobbered by code paths that treat "the response stream finished" as "the conversation succeeded".
#### 4.1 Action variant and result
Add `AIAgentActionType::WaitForEvents { tool_call_id: String, idle_timeout_seconds: i32 }` in the shared `ai` crate and a matching `AIAgentActionResultType::WaitForEvents(WaitForEventsResult)` result variant. `WaitForEventsResult` is an enum with two cases:
- `Completed` — watchdog timed out, or an inbound resume signal cleared the wait. Wire form is the empty proto `WaitForEventsResult{}` carried on `Request::Input::ToolCallResult.result`.
- `Cancelled` — user cancelled the wait. Wire conversion drops it (`Err(ConvertToAPITypeError::Ignore)`) so no result is sent for the unresolved tool call; the server's existing supersede mechanism synthesizes the matching `Cancel` instead, mirroring how `RunAgents::Cancelled` is handled.
`AIAgentActionResultType::WaitForEvents(Completed)` returns `true` from `is_successful()` so the controller's auto-follow-up triggers a follow-up request on completion. `Cancelled` returns `true` from `is_cancelled()` so the controller transitions the conversation to `Cancelled` per the standard cancellation path.
#### 4.2 Inbound conversion
`app/src/ai/agent/api/convert_from.rs`'s `Tool::WaitForEvents` arm produces an `AIAgentAction { action: WaitForEvents { tool_call_id, idle_timeout_seconds } }`. Because this is a real action, the exchange's `output.actions()` contains it, which means `AIConversation::mark_request_completed` sees `has_new_actions = true` and does not transition the conversation to `Success` on the yield stream. No explicit `Success`-guard is needed in `mark_request_completed`.
#### 4.3 Executor
`app/src/ai/blocklist/action_model/execute/wait_for_events.rs` implements `WaitForEventsExecutor`. Responsibilities:
- `try_to_execute_action` bumps a per-conversation generation counter, stores a `PendingWait { tool_call_id, sender, watchdog_handle }`, transitions the conversation to `ConversationStatus::WaitingForEvents` via a direct `BlocklistAIHistoryModel::update_conversation_status(WaitingForEvents)` call, spawns the watchdog future and stores its `SpawnedFutureHandle` on the pending entry, and returns `TryExecuteResult::ExecutedAsync`. The action sits in `running_actions` for the entire wait. The `tool_call_id` is held in the executor's `pending` map, not on the conversation — the only owner of the in-flight wait's identity is the executor.
- The `start_pending_action_by_id` action-model plumbing is updated to skip the default `update_conversation_in_progress_status` call for `WaitForEvents` so the executor's `WaitingForEvents` transition is not immediately clobbered with `InProgress`.
- `cancel_execution(tool_call_id)` is invoked from the executor dispatch's cancel path. It drops the pending entry, aborts the watchdog `SpawnedFutureHandle`, bumps the generation counter, and drops the channel sender. The caller (`BlocklistAIActionExecutor::cancel_running_async_action`) has already removed the action from `async_executing_actions`, so the spawn callback that wraps the channel receiver silently discards the result — no `FinishedAction` is emitted, no tool-call result reaches the wire.
- The watchdog firing path (`fire_watchdog_if_current`) is the only path that emits a `WaitForEventsResult::Completed`. It defensively re-checks that the conversation is still in `WaitingForEvents` before firing, so a watchdog that survives an out-of-band status transition does not inject a stale result.
#### 4.4 Watchdog timing and the client-side safety margin
The watchdog timeout is computed by `watchdog_timeout_for_stamped_seconds(idle_timeout_seconds)`:
- If `idle_timeout_seconds <= 0` (prost's "unset" sentinel), fall back to `DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS = 30 min`.
- Subtract `CLIENT_WATCHDOG_SAFETY_MARGIN = 30 s` to reserve a recovery window before the worker-side idle-shutdown fires (see server TECH §1.1).
- Floor the result at `HARD_FLOOR = 5 s` so small testing values still let the watchdog fire on a sane schedule.
The margin contract is the time budget for the recovery cycle: client watchdog fires → `complete_wait_action``FinishedAction` → controller auto-follow-up → outbound request with `WaitForEventsResult` → server `BeginTaskProgress` → next agent turn starts producing activity, which resets the worker idle counter. The corresponding server-side margin (subtract from the stamped value in `RecordWaitForEventsYield`) is tracked as a follow-up.
#### 4.5 CLI driver lifecycle
`app/src/ai/agent_sdk/driver.rs`'s `execute_run` keeps its `UpdatedConversationStatus` subscriber's two early-return arms intact: the `is_in_progress()` arm still cancels the idle timer when the run resumes, and a `WaitingForEvents` arm returns without resolving `run_exit` (the driver keeps the process alive). The driver does **not** own a separate watchdog; the executor's watchdog and follow-up flow drive recovery regardless of whether the conversation is hosted under an `AgentDriver` or in the GUI's local-local pane.
`subscribe_to_cli_agent_session_events` is unaffected because third-party harnesses don't emit `wait_for_events`; exhaustive match against `CLIAgentSessionStatus` confirms this.
### 5. Task sync model
Update `map_conversation_status` in `app/src/ai/blocklist/local_agent_task_sync_model.rs:314-355`:
```rust path=null start=null
ConversationStatus::WaitingForEvents => (AgentTaskState::InProgress, None),
```
This means the client actively reports `IN_PROGRESS` for yielded runs rather than relying on `shouldPreserveInProgressOnClientSuccess` server-side. The server backstop stays in place for older clients and edge cases (see server TECH §"Server-side gates remain as a backstop").
### 6. Notifications
Two changes in `app/src/ai/agent_management/agent_management_model.rs`, both targeted at the `WaitingForEvents` yield case. The orchestrator-aware suppression that an earlier draft considered (consulting `aggregated_orchestrator_status` on the orchestrator's own `Success`) is **out of scope** per PRODUCT.md (20): if the orchestrator itself reaches a terminal status, that's its own assessment and the notification fires as today. The known orchestrator notification spam is the case where the orchestrator yielded via `wait_for_events` between turns, which the `WaitingForEvents` status (and the suppression below) covers directly.
- `ConversationStatus::should_trigger_notification` (line 471): add `WaitingForEvents => false`. (Note: the function uses `matches!` today, which means a new variant returns `false` by default. Rewrite the function as an exhaustive `match` so future variants force a deliberate decision.)
- `handle_history_event_for_mailbox` (line 304): add an explicit `WaitingForEvents` arm that mirrors the `InProgress` arm at line 330 — it clears any stale notification for this origin via `remove_notification_by_source`.
### 7. Orchestration pill bar and aggregation
`app/src/ai/blocklist/orchestration_topology.rs`:
- `aggregated_orchestrator_status` precedence: `InProgress > Blocked > WaitingForEvents > Error > Cancelled > Success`, with one carve-out: when the orchestrator itself yielded into `WaitingForEvents`, its own waiting state outranks any descendant `InProgress`. This keeps the orchestrator pill honest about "THIS conversation is paused" even while child agents continue working. A descendant in `Blocked` still beats the parent's `WaitingForEvents` because Blocked needs user attention.
- Implementation: scan the tree for `any_in_progress`, `first_blocked`, `any_waiting`, `any_error`, `any_cancelled` as before. When `any_in_progress` is set, check whether the orchestrator's own status is `WaitingForEvents` and return `WaitingForEvents` in that case; otherwise return `InProgress`. The remaining precedence steps are unchanged.
- Update the doc-comment precedence list to match.
`app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:119-151`:
- `pill_status_sort_key`: give `WaitingForEvents` its own slot in the "active-ish" half of the bar; do not lump it into `DONE_STATUS_KEY`. Recommended order: `Blocked = 0`, `Error = 1`, `InProgress = 2`, `WaitingForEvents = 2` (same bucket as `InProgress`, sorts left of the done section), `Cancelled | Success = DONE_STATUS_KEY (3)`.
- Update the existing comment at lines 119-124 ("Cancelled and Success share one 'done' bucket") to also mention that `WaitingForEvents` shares the `InProgress` bucket. Future readers should not have to re-derive this.
- `render_avatar_with_status_overlay` (lines 2112-2156) and the hover card (lines 1390-1397) pick up the new badge automatically because they consume `ConversationStatus::status_icon_and_color`.
### 8. Wiring `wait_for_events` and resume signals through the action model
This section covers how the client discovers a yield and how a resume reaches the executor. The server-side spec adds a first-class `WaitForEvents` variant to the public proto's `Message::ToolCall::tool` oneof and an accompanying `WaitForEventsResult` variant to `Message::ToolCallResult::result`. The client pattern-matches the public variant directly; no payload-sniffing of the opaque `Message::ToolCall::Server` is needed (and would not work, since that payload is opaque per `task.proto:405-407`).
#### 8.1 Yield path: inbound `Tool::WaitForEvents` becomes an action
The yield arrives as a `Tool::WaitForEvents` tool-call message inside the response stream. `convert_from.rs` (§4.2) translates it into an `AIAgentAction::WaitForEvents` that lands in the exchange's `output.actions()`. When the response stream finishes, `BlocklistAIController::handle_response_stream_event` collects new actions from finished exchanges and forwards them to `BlocklistAIActionModel::queue_actions`, which dispatches the `WaitForEvents` action through the executor described in §4.3. The executor's `try_to_execute_action` is the single place that transitions the conversation to `WaitingForEvents` and arms the watchdog — there is no separate detection-point helper on `BlocklistAIHistoryModel`.
#### 8.2 Resume path: silent dismissal via the standard cancellation path
Two inbound signals can close the unresolved `WaitForEvents` tool call and resume the agent:
1. **Generic `Cancel` tool-call result (inbound supersede).** When new user input, an inbound message, or an inbound lifecycle event arrives on the waiting task, the server's pending-tool-call supersede mechanism appends a generic `Cancel` tool-call-result referencing the unresolved `WaitForEvents` id (server TECH §1.1).
2. **`WaitForEventsResult` tool-call result (echoed timeout).** The client's own watchdog emitted this result on a follow-up request and the server echoed it back through the next stream.
In both cases, the inbound message is just transcript data — `apply_client_actions` appends it to the conversation transcript with no special handling. The client-side teardown of the running wait is driven by the **outbound** side, before the server is asked to do anything.
For the **orchestration-event case**, `BlocklistAIController::inject_pending_events_for_request` calls `BlocklistAIActionModel::cancel_wait_for_events_for_conversation(conversation_id)` immediately before `send_request_input`. The cancel goes through the standard `cancel_running_async_action` path: the action is removed from `async_executing_actions`, `WaitForEventsExecutor::cancel_execution` aborts the watchdog handle and drops the channel sender, and the spawn callback's `async_executing_actions.remove` returns `None` so the result is silently discarded. **No `FinishedAction` is emitted and no `WaitForEventsResult` is sent.** The server's `collectCancelledResultsForIncompleteToolCalls` synthesizes the matching `Cancel` for the unresolved tool call so the message log stays consistent. Subsequent paths that cancel pending actions (e.g. `cancel_conversation_progress`, `send_query`) reuse the same machinery and behave identically.
For the **user-typed-query case** the existing `send_query` path already calls `cancel_all_pending_actions` before sending; the wait is cancelled by the same silent-dismissal path described above.
For the **watchdog-timeout case**, no outbound request precedes the firing. `fire_watchdog_if_current` produces a `Completed` result through the channel; the action_model emits `FinishedAction(Completed)`, the controller's auto-follow-up subscriber sends a follow-up request carrying the empty `WaitForEventsResult{}`, and the server's next stream echoes the result back as transcript data.
#### 8.3 Persistence and restart behavior
Nothing about the wait is persisted (§3). On restart, a previously-yielded conversation restores as `Success` per `derive_status_from_root_task`; the unresolved `Tool::WaitForEvents` tool call stays in the transcript as an orphan. When the user re-engages, the next outbound request omits a result for it and the server's existing supersede mechanism synthesizes the matching `Cancel`. There is no in-memory wait to clear and no transcript-scan fallback — the executor's `pending` map is the canonical source of truth, and after restart it is empty.
#### 8.4 Inbound orchestration events while waiting
When an orchestration event for a waiting conversation reaches `OrchestrationEventService::EventsReady`, `BlocklistAIController::handle_pending_events_ready` drains the queued events and sends them as the next outbound request via `inject_pending_events_for_request``send_request_input`. The readiness check `conversation_ready_for_pending_events` treats `WaitingForEvents` the same as `Success` so events can be injected while the wait is in flight. The outbound request's `send_request_input` flips status to `InProgress`, which completes the wait per §8.2; the request contains the new event inputs but no `WaitForEvents` tool-call result, so the server synthesizes a `Cancel` on the next response stream as transcript data.
#### 8.5 Why there is no `detect`/`clear` helper for the resume signal
An earlier version of this design routed the resume through `BlocklistAIHistoryModel::detect_wait_for_events_transitions` + `clear_conversation_waiting_for_events_if_matches` inside `apply_client_actions`. The detect/clear pair scanned inbound `ToolCallResult` messages for `WaitForEvents` / `Cancel` variants and flipped status to `InProgress` directly. Both helpers, the `waiting_for_events_tool_call_id` field on `AIConversation`, the `mark_conversation_waiting_for_events` setter, and the transcript-scan fallback `find_unresolved_wait_for_events_tool_call_id` have been removed: every reachable production resume path is preceded by an outbound `send_request_input` that already flips status, so the detect/clear was a no-op in every observable flow (the `if !matches!(status, WaitingForEvents) { return; }` early return at `clear_conversation_waiting_for_events_if_matches` fired before the detect/clear could do any work). Removing the machinery aligns the implementation with the natural request/response lifecycle: status transitions are driven by outbound requests and action lifecycle, not by inbound message-shape inspection.
**Known limitation, intentionally undocumented as a server contract.** If a future code path arranges for an inbound resume signal to arrive **without** any preceding outbound request that flips status (e.g. a server push synthesized without the client driving it, or a viewer flow that mirrors a sharer's status differently from how viewers currently work — see the shared-session viewer note below), the executor's `UpdatedConversationStatus` subscription would not fire and the wait would only complete via the watchdog timeout. The fix in that case would be to re-introduce a targeted detect/clear at the new entry point or to ensure the new entry point flips status explicitly. Shared-session viewers are not affected today: `try_to_execute_action` short-circuits with `NotExecuted::WaitingOnSharer` (`action_model/execute.rs:557-563`), so a viewer never has a pending wait to complete.
### 9. Coordinated rollout and backwards compatibility
No client-side feature flag is required. The signal that activates the client-side fix is the presence of the new public `Message::ToolCall::WaitForEvents` variant in a received message. Because the legacy `Message::ToolCall::Server` payload is opaque to clients (`task.proto:405-407`), there is no way for the client to detect a legacy `wait_for_events` call, and no sniff fallback exists.
Rollout sequencing (mirrors server TECH §"Backwards compatibility and coordinated rollout"):
1. **`warp-proto-apis` release.** The proto additions ship first as a no-op (no producer or consumer yet). Wire-compatible: older deserializers ignore the new variants.
2. **`warp` rev bump.** Cargo.toml in `warp` is bumped to the new release. The client adds the `WaitForEvents` detection and the `WaitingForEvents` flow. Without a server emitting the variant, the new code stays dormant.
3. **`warp-server` rev bump + flag-on rollout.** The server side ships the new emission path behind a feature flag. Flipping the flag for a tenant/workspace activates the client-side fix for that scope.
4. **Steady state.** Both repos ship the new path; the server-side flag is at 100%. The legacy server-handled `wait_for_events` path stays compiled for one release window and is then removed (server TECH §"Cleanup").
#### Behavior during the rollout window
- **Client old, server old.** Legacy bug: `Success` is reported, the CLI driver exits, the server's `shouldPreserveInProgressOnClientSuccess` keeps the task `IN_PROGRESS`. Unchanged from today.
- **Client old, server new.** Client sees the new `WaitForEvents` variant as an unknown field (proto's forward-compatibility) and ignores it. The conversation still goes to `Success` locally; same as the legacy bug. The server-side gates protect the task.
- **Client new, server old.** Server is still emitting via `Message::ToolCall::Server { payload: <opaque> }`. The client sees only the opaque variant and treats the conversation as `Success` (same as today). The server-side gates protect the task.
- **Client new, server new.** Full fix: `WaitForEvents` variant emitted by server, pattern-matched by client, conversation transitions to `WaitingForEvents`, driver stays alive, no toast, correct pill-bar badge.
#### Mixed-mode within a single conversation
The server-side feature flag is evaluated per `wait_for_events` call, so one conversation can contain both legacy and new yields. The client handles this gracefully: legacy yields produce opaque server tool-call messages that the client ignores; new yields activate the `WaitingForEvents` path. There is no client-side state that needs to track which mode a conversation is in.
## End-to-end flow
After the changes, a `wait_for_events` cycle looks like:
1. Model calls `wait_for_events`.
2. Server emits `Message::ToolCall { tool: WaitForEvents }` in the public proto, fires `recordWaitForEventsYield` to extend `VMIdleTimeoutMinutes`, and finishes the response stream without emitting a tool-call result.
3. Client receives the stream. `convert_from.rs` turns the `Tool::WaitForEvents` message into an `AIAgentAction::WaitForEvents { tool_call_id, idle_timeout_seconds }` in the exchange's `output.actions()` (§8.1). Because `has_new_actions = true`, `mark_request_completed` does not transition the conversation to `Success`.
4. When the response stream finishes, `BlocklistAIController` collects the new actions and calls `BlocklistAIActionModel::queue_actions`. The wait action is dispatched to `WaitForEventsExecutor::try_to_execute_action`.
5. The executor (§4.3) bumps its per-conversation generation counter, stores a `PendingWait { tool_call_id, sender }`, transitions the conversation to `WaitingForEvents` via `BlocklistAIHistoryModel::update_conversation_status(WaitingForEvents)`, spawns the watchdog with `watchdog_timeout_for_stamped_seconds`, and returns `ExecutedAsync`. The action sits in `running_actions`.
6. `LocalAgentTaskSyncModel` maps `WaitingForEvents``AgentTaskState::InProgress` and fires `update_agent_task`.
7. `AgentNotificationsModel` does not fire a toast for the `WaitingForEvents` transition (§6). The orchestrator's own `Success`/`Cancelled`/`Error` notifications continue to fire as today.
8. The orchestration pill bar's orchestrator badge renders the waiting state via the updated aggregator precedence (§7).
9. **Resume by inbound supersede.** Inbound user input, an inbound message, or an inbound lifecycle event arrives. The resume is driven by an outbound request from the client (the user's message submission, an `inject_pending_events_for_request` drain triggered by `EventsReady`, etc.). That code path calls `cancel_wait_for_events_for_conversation` before `send_request_input`; the wait is silently dismissed through the standard `cancel_running_async_action` machinery, no `FinishedAction` fires, and no tool-call result is sent on the wire. The server-synthesized `Cancel` arrives in the response stream that follows and is appended to the transcript as ordinary message data by `apply_client_action(AddMessagesToTask)`.
10. **Resume by watchdog timeout.** If no inbound input arrives before the watchdog fires, the executor's timer callback verifies the generation counter still matches and that the conversation is still in `WaitingForEvents`, then sends `Completed` on the channel. The action_model emits `FinishedAction`; the auto-follow-up subscriber sends a follow-up request whose input includes the empty `WaitForEventsResult` produced by the action's result conversion. The server echoes the result through the next stream; the agent's next turn observes the empty timeout result and decides how to proceed (commonly `finish_task`, but the agent may also re-yield, ask the user, or take other action). The run is **not** auto-cancelled on timeout; the agent owns the decision.
## Diagram
```mermaid
flowchart LR
Streaming([Model emits wait_for_events tool call]) -->|public Tool::WaitForEvents| Convert["convert_from.rs:<br/>build AIAgentAction::WaitForEvents"]
Convert --> Queue["queue_actions on stream finish:<br/>dispatch to WaitForEventsExecutor"]
Queue --> Exec["Executor try_to_execute_action:<br/>update_conversation_status(WaitingForEvents),<br/>spawn watchdog,<br/>action runs async"]
Exec --> Sync["LocalAgentTaskSyncModel:<br/>update_agent_task(IN_PROGRESS)"]
Exec --> Notif["NotificationsModel:<br/>no toast for WaitingForEvents,<br/>clear stale items"]
Exec --> Pill["Orchestration pill bar:<br/>waiting badge via aggregator"]
Exec -->|inbound user/event:<br/>outbound request via send_request_input| StatusFlip["send_request_input:<br/>status → InProgress"]
Exec -->|watchdog fires| Complete["Executor complete_wait_action:<br/>Completed result on channel"]
StatusFlip -->|UpdatedConversationStatus| ExecSub["Executor subscription:<br/>complete_wait_action(Completed)"]
ExecSub --> Complete
Complete --> Finished["FinishedAction event"]
Finished --> FollowUp["Controller auto-follow-up:<br/>has_active_stream ⇒ bail<br/>(else send next outbound request)"]
StatusFlip --> NextTurn([Next agent turn])
FollowUp --> NextTurn
```
## Testing and validation
Map each `PRODUCT.md` invariant to a concrete test or manual verification. Numbers in parentheses reference `specs/QUALITY-780/PRODUCT.md`.
### Unit tests
- `conversation_tests.rs``ConversationStatus::is_done()` returns true exactly for `Success | Error | Cancelled` and `false` for `WaitingForEvents`. Covers (3), (4), (28).
- `conversation_tests.rs``should_trigger_notification` returns `false` for `WaitingForEvents` and `InProgress`, true for `Success | Blocked | Error`. Covers (16), (19).
- `conversation_tests.rs` — Restore: a conversation that was yielded via `wait_for_events` at shutdown restores as `Success` (not `WaitingForEvents`), the orphan tool call stays in the transcript, and no waiting state is rebuilt. Covers (10).
- `conversation_tests.rs` — Transition matrix: assert the only legal transitions into `WaitingForEvents` are from `InProgress`; transitions out of `WaitingForEvents` are to `InProgress`, `Cancelled`, `Error`, or `Success`; a direct `WaitingForEvents``WaitingForEvents` is not reachable (must re-enter `InProgress` first). Covers PRODUCT.md (9).
- `conversation_tests.rs` — Cancellation from `WaitingForEvents`: invoking the existing cancel path on a `WaitingForEvents` conversation transitions to `Cancelled` immediately and emits a status update. Covers PRODUCT.md (14).
- `local_agent_task_sync_model_tests.rs``map_conversation_status(WaitingForEvents)` returns `(AgentTaskState::InProgress, None)`. Covers (15).
- `agent_management_model_tests.rs``handle_history_event_for_mailbox` for `WaitingForEvents` does not call `add_notification` and removes any existing notification for the origin. Covers (16), (17).
- `agent_management_model_tests.rs` — No notification fires on the `WaitingForEvents``InProgress` resume transition. Covers PRODUCT.md (18).
- `agent_management_model_tests.rs` — Orchestrator's own terminal status fires the existing notification: an orchestrator with non-terminal descendants reaching `Success` (or `Cancelled` / `Error`) still produces the `Complete` (or matching) toast — the mailbox does not inspect descendant state. Covers PRODUCT.md (20).
- `orchestration_topology_tests.rs``aggregated_orchestrator_status` precedence including the parent-waits carve-out: orchestrator `WaitingForEvents` + all children `Success``WaitingForEvents`; orchestrator `WaitingForEvents` + one child `InProgress``WaitingForEvents` (carve-out); orchestrator `InProgress` + one child `InProgress``InProgress`; orchestrator `WaitingForEvents` + one child `Blocked``Blocked`; orchestrator `WaitingForEvents` + one child `Error``WaitingForEvents`. Covers (22).
- `orchestration_pill_bar_tests.rs``pill_status_sort_key(WaitingForEvents)` returns a value strictly less than `DONE_STATUS_KEY`. Covers (24).
- `wait_for_events_tests.rs``watchdog_timeout_for_stamped_seconds` math: stamped 0 → default minus margin; stamped 60 → 30 s; stamped 10 → `HARD_FLOOR`; stamped negative → default minus margin. Plus named-constant checks for `DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS`, `CLIENT_WATCHDOG_SAFETY_MARGIN`, and `HARD_FLOOR`. Covers (11), (12).
- `input_tests.rs` or `agent_message_bar_tests.rs` — With the conversation in `WaitingForEvents`, the input is enabled and submitting a follow-up clears the waiting state and transitions to `InProgress`. Covers PRODUCT.md (26).
- `history_model_tests.rs` — Starting a new conversation in a terminal view that previously held a `WaitingForEvents` conversation does not inherit the wait state. Covers PRODUCT.md (31).
### Integration tests
- Add an integration test in `crates/integration/` that drives an Oz CLI agent with `--idle-on-complete=5s` against a fake server emitting the new public `WaitForEvents` variant; assert the process does not exit within 30 seconds. Covers PRODUCT.md (11).
- Timeout-path integration test: drive an Oz CLI agent against a fake server, let the client watchdog fire, assert the client emits `Message::ToolCallResult { result: WaitForEvents(WaitForEventsResult{}) }` against the unresolved `WaitForEvents` tool-call id and the run does **not** transition to `Cancelled`. The fake server echoes the result back; assert the conversation transitions to `InProgress` and the simulated agent's next turn fires. Covers PRODUCT.md (12), (29).
- Coordinated-rollout matrix: a flag-off fake server emits the legacy server tool call; the client treats the conversation as `Success` (legacy bug) and the server's `shouldPreserveInProgressOnClientSuccess` keeps the task `IN_PROGRESS`. A flag-on fake server emits the new variant; the client transitions to `WaitingForEvents`. Covers PRODUCT.md (32), (33).
- Extend `agent_conversations_model_tests.rs` to assert that a conversation entering `WaitingForEvents` does not propagate `Success` semantics to consumers that check `is_done()`. Covers (28).
### Manual validation
- Run a local Oz orchestrator that spawns one child agent and yields via `wait_for_events`. Verify:
1. The orchestration pill bar's orchestrator badge shows the "waiting" icon/color (not green check). (21), (22)
2. No "Task completed" toast appears. (16), (20)
3. The CLI worker process stays alive until the child message arrives. (11)
4. After the inbound message resumes the agent, the badge transitions back to active and the conversation eventually completes. (8), (30)
- Repeat with the orchestrator in the foreground and minimized to confirm notification behavior matches.
- Restart Warp while a conversation is `WaitingForEvents`. Confirm the conversation restores as `Success` (the yield does not survive restart, per §3), the orphan `wait_for_events` tool call is visible in the transcript, and re-engaging the conversation cleanly synthesizes the supersede. (10)
- Submit a follow-up while in `WaitingForEvents`. Confirm the input accepts the message, the conversation transitions to `InProgress`, and no notification fires for the transition. (26), (18)
### Regression coverage
- Audit every `match conversation.status()` site for an explicit `WaitingForEvents` arm. The exhaustive-matching rule from `WARP.md` should already enforce this; the test suite confirms.
- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` and `./script/presubmit` pass.
## Orchestration
This section is the canonical cross-spec orchestration plan for QUALITY-780. The same text appears in both `warp/specs/QUALITY-780/TECH.md` and `warp-server/specs/QUALITY-780/TECH.md` so each spec is self-contained for the agent implementing it.
### Decision
Implementation is fanned out across multiple AI agents working in parallel git worktrees. The work spans three repositories (`warp-proto-apis`, `warp-server`, `warp`), and the proto change is a hard prerequisite for everything else because both the server and client implementations consume the new generated bindings. After the proto release, the server-side and client-side core work can run in parallel; once the client core lands, the remaining client work fans out further. AI agents complete each subtask in minutes, not days — the bottleneck is wave ordering, not per-agent effort.
### Worktree layout
Per the `~/src/QUALITY-780/` task-directory convention:
- `~/src/QUALITY-780/warp-proto-apis` — proto agent.
- `~/src/QUALITY-780/warp-server` — server-impl agent.
- `~/src/QUALITY-780/warp` — client-core agent and final integrator.
- `~/src/QUALITY-780/warp-driver`, `~/src/QUALITY-780/warp-sync-notif`, `~/src/QUALITY-780/warp-pill-bar`, `~/src/QUALITY-780/warp-detection` — additional `warp` worktrees for the four Wave 2 client fan-out agents.
All branches use the `matthew/` prefix.
### Dependencies and ordering (three waves)
- **Wave 0 — Proto release (single agent, sequential).** `proto` adds the new variants to `warp-proto-apis/apis/multi_agent/v1/task.proto` and publishes a release tag. All downstream waves block on this completing.
- **Wave 1 — Core scaffold + server (two agents in parallel, after Wave 0).** `server-impl` and `client-core` run concurrently because they live in different repositories and share no compilation dependency. The client core is sized to be the minimum scaffold that downstream client agents need to compile against (status variant, exhaustive match arms in shared files, predicate split, persistence, restore-site).
- **Wave 2 — Client fan-out (four agents in parallel, after Wave 1's client-core branch is pushed).** `client-driver`, `client-sync-notif`, `client-pill-bar`, `client-detection` branch from `client-core`'s branch and modify disjoint client subsystems. They do not touch the files client-core owns.
- **Wave 3 — Integration (orchestrator).** Orchestrator merges all four Wave 2 branches into the client-core branch, runs `cargo fmt` / `cargo clippy` / `./script/presubmit`, and opens a single draft PR for `warp`. `server-impl` independently opens a draft PR for `warp-server`. The proto release tag from Wave 0 is referenced from both implementation PR descriptions.
### Launch config
Run-wide settings (execution mode, model, harness) are documented in the orchestration config attached to this plan. Defaults:
- Execution mode: **local** for every agent. The agents touch code paths exercised by `./script/presubmit` and other local toolchains, and each works in a user-visible git worktree.
- Model: inherits from the orchestrator (not pinned in the config).
- Harness: default Oz.
Each wave launches as its own `run_agents` batch. Do not pre-launch downstream waves — wait for each wave's lifecycle events before fanning out the next.
### Child agents
- **proto — `warp-proto-apis` proto additions (Wave 0).**
- Worktree: `~/src/QUALITY-780/warp-proto-apis`. Branch: `matthew/QUALITY-780-proto-additions`.
- Owns: the proto additions in `apis/multi_agent/v1/task.proto` per server TECH §0.
- Output: pushes branch, opens draft PR, publishes a release tag/version. Reports the released version string + git ref to the orchestrator.
- **server-impl — `warp-server` emission path + flag (Wave 1).**
- Worktree: `~/src/QUALITY-780/warp-server`. Branch: `matthew/QUALITY-780-server`.
- Owns: server TECH §0–§1 implementation: `WaitForEventsToolCall::ProduceActions`, `isWaitForEventsAction`, refactor of `HandleWaitForEvents``recordWaitForEventsYield`, finalizer hook in `RunPrimaryAgent`, gating of the `ExecuteServerHandledToolCall` arm, the new `WaitForEventsClientToolEnabled` feature flag, and the unit/integration tests in server TECH §"Testing and validation".
- Validation: `go fmt ./...`, `go vet ./...`, `./script/presubmit` before opening the PR.
- PR: draft, using `.github/pull_request_template.md`.
- **client-core — `warp` status variant + predicates + persistence (Wave 1).**
- Worktree: `~/src/QUALITY-780/warp`. Branch: `matthew/QUALITY-780-client-core`.
- Owns: §1 (`ConversationStatus::WaitingForEvents` variant + all match arms in `conversation.rs` `Display` / `render_icon` / `status_icon_and_color`); §2 (`is_done` stays as-is, with the new variant correctly returning `false`); §3 (persistence field on `AgentConversationData` + restore-site check in `new_restored` at `conversation.rs:542`). For files that Wave 2 agents own (driver, sync/notif, pill bar, detection), client-core leaves their match arms with conservative `WaitingForEvents` placeholders (e.g. treat like `InProgress` for the clear-stale notification path, like `Blocked` for the not-currently-streaming question) so the tree compiles and existing tests pass. Wave 2 agents replace the placeholders with their final implementations.
- Validation: `cargo fmt`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit`.
- Hand-off: pushes the branch and reports the branch name so Wave 2 agents can rebase from a known-good commit.
- **client-driver — `warp` driver lifecycle (Wave 2).**
- Worktree: `~/src/QUALITY-780/warp-driver`. Branch: `matthew/QUALITY-780-client-driver` (off `matthew/QUALITY-780-client-core`).
- Owns: §4 (`app/src/ai/agent_sdk/driver.rs``IdleTimeoutSender` reuse pattern, `execute_run`'s `UpdatedConversationStatus` handler, `subscribe_to_cli_agent_session_events` no-op verification, watchdog emission of `WaitForEventsResult`).
- **client-sync-notif — `warp` task sync + notifications (Wave 2).**
- Worktree: `~/src/QUALITY-780/warp-sync-notif`. Branch: `matthew/QUALITY-780-client-sync-notif` (off `matthew/QUALITY-780-client-core`).
- Owns: §5 (`local_agent_task_sync_model.rs``map_conversation_status`) and §6 (`agent_management_model.rs``should_trigger_notification` exhaustive rewrite, `handle_history_event_for_mailbox` `WaitingForEvents` arm).
- **client-pill-bar — `warp` orchestration aggregation + pill bar (Wave 2).**
- Worktree: `~/src/QUALITY-780/warp-pill-bar`. Branch: `matthew/QUALITY-780-client-pill-bar` (off `matthew/QUALITY-780-client-core`).
- Owns: §7 (`orchestration_topology.rs``aggregated_orchestrator_status` precedence + `any_waiting` accumulator + doc-comment, `orchestration_pill_bar.rs``pill_status_sort_key` + sort-bucket comment).
- **client-detection — `warp` tool-call detection + ordering rule (Wave 2).**
- Worktree: `~/src/QUALITY-780/warp-detection`. Branch: `matthew/QUALITY-780-client-detection` (off `matthew/QUALITY-780-client-core`).
- Originally owned the inbound-resume detect/clear path in `history_model.rs`. After the simplification in §8.5, no detect/clear helpers exist; the resume is driven entirely by the natural status flip in `send_request_input`. This agent's remaining responsibility is the client-side rollout notes in §9 and any `controller.rs` ordering guards required to keep the response-stream `Success` transition from clobbering an active wait.
### Merge strategy
- Each Wave 2 client agent reports its branch name and a brief summary of changed files. Each agent runs its own `cargo fmt` / `cargo clippy` / `./script/presubmit` before reporting.
- Orchestrator integrates Wave 2 into client-core in `~/src/QUALITY-780/warp`:
1. Check out `matthew/QUALITY-780-client-core`.
2. Merge each Wave 2 branch in sequence (driver → sync-notif → pill-bar → detection). Conflicts should be limited to client-core's placeholder arms in fan-out-owned files; each Wave 2 agent replaces only its own placeholders, so per-file conflicts are localized.
3. Re-run `cargo fmt`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `./script/presubmit` on the integrated branch.
4. Push `matthew/QUALITY-780-client-core` and open a single draft PR for `warp`.
- `server-impl` opens its own draft PR for `warp-server` directly from its branch.
- Final state: three branches across three repos, three draft PRs (`warp-proto-apis`, `warp-server`, `warp`). The two implementation PRs link to the proto release.
### Diagram
```mermaid
flowchart LR
Plan([Plan + orchestration config approved]) --> Proto["Wave 0:<br/>proto — warp-proto-apis<br/>add variants + cut release"]
Proto --> Server["Wave 1:<br/>server-impl — warp-server<br/>emission + flag + tests"]
Proto --> ClientCore["Wave 1:<br/>client-core — warp<br/>status variant + predicates + persistence"]
ClientCore --> Driver["Wave 2:<br/>client-driver"]
ClientCore --> SyncNotif["Wave 2:<br/>client-sync-notif"]
ClientCore --> PillBar["Wave 2:<br/>client-pill-bar"]
ClientCore --> Detection["Wave 2:<br/>client-detection"]
Server --> ServerPR([warp-server draft PR])
Driver --> Integrate["Wave 3:<br/>Orchestrator integrates<br/>+ presubmit"]
SyncNotif --> Integrate
PillBar --> Integrate
Detection --> Integrate
Integrate --> ClientPR([warp draft PR])
Proto --> ProtoPR([warp-proto-apis draft PR])
```
## Risks and mitigations
- **Risk: A previously-yielded conversation restores as `Success` and the user thinks it is done.** Mitigation: this is by design (§3). The orphan tool call sits in the transcript so the conversation can be re-engaged at any time, at which point the server-side supersede mechanism naturally drives the resume. Cosmetically the badge is `Success` instead of `Waiting` for the offline-and-restarted case; the user can resume manually.
- **Risk: The waiting watchdog races the resume signal.** An inbound event arrives at almost the same time as the timeout. Mitigation: the executor's per-conversation generation counter (§4.3) makes cancellation atomic with respect to the timer fire — a still-pending watchdog whose generation no longer matches no-ops.
- **Risk: Orphaned `WaitForEvents` tool-call message in transcript history.** When the conversation transitions to `Cancelled` via user cancel, the unresolved tool call stays in transcript history — pending tool calls are not retroactively cancelled (per PRODUCT.md (29)). The client-side watchdog path does *not* orphan the call: the watchdog emits a `WaitForEventsResult` that closes it before the agent decides what to do next. The only remaining orphan case is the worker-side safety-net idle-shutdown (server TECH §1.1) firing because the client is offline; that path transitions the task to `CANCELLED` without a result message. Mitigation: this is intentional and harmless. Terminal conversations are read-only, so an orphan tool call is just historical metadata and does not affect any live behavior.
- **Risk: Visual badge for `WaitingForEvents` collides with `Blocked`, `InProgress`, or `Success`.** Mitigation: §1 enumerates the existing color/icon assignments. Until design lands a dedicated visual the placeholder reuses the `InProgress` icon and color so the badge never collides with `Success` / `Blocked`.
- **Risk: Client watchdog races the worker idle-shutdown.** If the stamped `idle_timeout_seconds` matches the worker's `VMIdleTimeoutMinutes` exactly, the worker can shut down before the client watchdog has time to fire and send the follow-up. Mitigation: the client subtracts `CLIENT_WATCHDOG_SAFETY_MARGIN` (and floors at `HARD_FLOOR`) before scheduling (§4.4). A corresponding server-side margin is tracked as a follow-up so the stamped value the client observes is already below the worker ceiling.
- **Risk: Coordinated rollout regression.** A misordered release sequence (e.g. client builds without the new proto bindings) could break deserialization. Mitigation: ship `warp-proto-apis` first as a no-op; bump revs in `warp` and `warp-server` only after. Server TECH §"Coordinated rollout" tracks the sequence end-to-end.
## Follow-ups
- Once the server-side feature flag is at 100% and the legacy `ExecuteServerHandledToolCall` arm is removed (server TECH §"Cleanup"), audit the client for any remaining references to the legacy server-handled `wait_for_events` shape and drop them.
- Audit the block status bar for any remaining "spinner shows for `WaitingForEvents`" cases — most likely the change in §1 makes this fall out for free, but worth confirming.
- Evaluate whether the third-party harness path (`subscribe_to_cli_agent_session_events`) ever needs to model a yield analogously. Today no third-party harness emits `wait_for_events`, but if one starts to we want a clear extension point.
- Once the new client surface is stable, look at adding a richer transcript affordance ("waiting for events from agent X") that distinguishes inbound-resume (generic `Cancel` arriving with new inputs) from watchdog-timeout (`WaitForEventsResult` arriving alone) for display purposes. Out of scope for QUALITY-780 itself.
+58
View File
@@ -0,0 +1,58 @@
# Auto-queue prompts during agent-requested long-running commands
Linear: [QUALITY-839](https://linear.app/warpdotdev/issue/QUALITY-839/auto-enable-prompt-queueing-during-lrc)
## Summary
While an agent is in control of a long-running command (LRC) that the agent requested as part of a conversation, submitting a prompt auto-queues it instead of immediately sending it to the agent driving the command when regular queue mode is otherwise off. LRC-auto-queued prompts are delivered to the agent when the command finishes only when doing so preserves existing queue order; prompts queued by regular queue mode keep normal end-of-response semantics. The user can also press Enter on an empty input to fire the next queued prompt earlier. A new cloud-synced dropdown setting, "Default long-running command submission mode", controls whether prompts are queued or sent immediately during eligible LRCs. It only applies — and is only shown — when the default prompt submission mode is Interrupt.
Figma: none provided.
## Problem
Today, a prompt submitted while an agent controls an agent-requested LRC is delivered to that agent immediately, steering it mid-command. Users often type thoughts ahead of time and don't want them injected into the running command the instant they hit Enter; they want them held until they deliberately release them or until the exchange finishes.
## Behavior
### Trigger and scope
1. Auto-queue activates for a conversation exactly when the agent holds control of an active long-running command that the agent requested in that conversation and the settings call for it: the default prompt submission mode is Interrupt and the LRC submission mode is "Queue until command finishes" (see 18). This includes the state where the agent is blocked on user approval to interact with the command.
2. Auto-queue does not activate for user-started LRCs where the user explicitly tagged in the agent, or when the user is in control of the LRC — e.g. before the agent has taken control, or after a manual takeover, stop, or agent-initiated transfer of control back to the user.
3. Auto-queue activation is per-conversation: it affects only the conversation whose agent controls the LRC. Other conversations' queue toggle states are untouched.
4. The behavior is gated on the same feature availability as the existing prompt-queue feature (the queue chip / `/queue` surface). Where the queue feature is unavailable, behavior is unchanged from today.
5. When the default prompt submission mode is Queue, the LRC machinery is entirely inert: prompts queue until the end of the full response per existing queue-mode behavior, the chip toggle behaves persistently, and the LRC setting is hidden (see 19).
### Queuing while the LRC runs
6. While auto-queue is active and regular queue mode is otherwise off, submitting a non-empty prompt appends it to the conversation's queued prompts (the same queue used by the auto-queue chip and `/queue` today) instead of sending it to the agent, and the input clears. If the current queue head is absent or is itself queued until command finish, the queued prompts panel shows the new row with an italic, secondary-colored "(queued until the command finishes)" suffix after its preview text — the same treatment as the model picker's "(selected)" label. If the current queue head is not queued until command finish, the prompt appends as a regular queued row with no command-finish suffix.
7. If regular queue mode is already enabled for the conversation (via the queue chip/keybinding or the default prompt submission mode), submissions during the LRC use regular queue semantics: they append as normal queued rows with no command-finish suffix and drain at the end of the response unless the user sends them manually.
8. Pressing Enter on an empty input sends the top queued row immediately — delivered to the same target an immediate submission would have used (the agent controlling the LRC) — per the existing empty-input-Enter send-now behavior. Each press sends exactly one row.
9. All existing queue interactions (panel rows, edit, delete, reorder, send-now buttons, pause on error/cancel) behave exactly as they do for manually-enabled queue mode.
10. When the command finishes, leading prompts that were auto-queued during it (the suffixed rows at the head of the queue) are sent to the agent immediately, in queue order — including when the user manually took over the command before it finished. Rows queued by other means (`/queue`, an explicit queue-mode toggle, queue default mode) are untouched and drain per the existing end-of-response rules; command-finish delivery never skips over them.
11. Shell-command rows queued while the agent controls the LRC are regular queued commands (no suffix): they cannot be delivered to the agent, do not fire at command end, and keep the existing queued-command drain semantics.
### Status chip and ghost text
12. While auto-queue is active, the prompt-queue chip in the warping indicator renders in its active (accent-colored) state, identical to when the user enables queue mode manually.
13. While auto-queue is active and the input is in AI mode with an empty buffer, the ghost text shows the existing queue hint copy ("Queue a follow up for the running agent", with the classic-input "or backspace to exit" variant), replacing the steer hint shown today during an LRC.
### Reverting and manual override
14. Auto-queue is a derived state, not a sticky toggle: when the LRC ends (command finishes, or control transfers to the user for any reason), the conversation's queue mode reverts to whatever it was before the LRC — the user's per-conversation toggle state, or the default from the queue-vs-interrupt setting. Rows that did not fire per (10) remain queued.
15. If the user manually toggles queue mode off (chip click or its keybinding) while the agent still controls the LRC, the override is respected for the remainder of that LRC: prompts submit immediately to the agent, as today. The override is scoped to that LRC only — it does not change the conversation's persistent toggle state, and the next eligible agent-requested LRC in the conversation auto-enables again.
16. Toggling queue mode back on after such an override re-enables regular queue mode for the conversation; prompts submitted after that toggle use normal queued-row semantics rather than command-finish LRC semantics. Reverting at LRC end still applies per (14).
17. If the conversation was already in queue mode before the LRC (via a per-conversation toggle), entering and exiting the LRC produces no visible change: queue mode stays on throughout and after, and its rows drain at end of response per (10).
### Setting
18. A new setting, "Default long-running command submission mode", controls invariants (1)(17). It is a dropdown with two options — "Send immediately" and "Queue until command finishes" (the default) — cloud-synced, and visible on the AI settings page directly below the "Default prompt submission mode" (queue vs. interrupt) dropdown. Its description reads: "What happens when you submit a prompt while an agent is driving a long-running command. LRC-queued prompts are sent to the agent when the command finishes."
19. The dropdown is only rendered while "Default prompt submission mode" is Interrupt. With Queue selected it is hidden (and ignored), since prompts already queue until the end of the full response.
20. When set to "Send immediately", behavior during eligible agent-requested LRCs is unchanged from today: prompts submit immediately to the agent, and the chip/ghost text reflect only the user's own queue toggle state.
21. The setting is also settable from the Command Palette via "Set long-running command submission: …" entries, shown only while the default prompt submission mode is Interrupt.
22. Changing the setting takes effect immediately, including mid-LRC: switching to "Send immediately" while auto-queue is active reverts the conversation to its non-LRC queue state; switching to "Queue until command finishes" while an agent controls an eligible agent-requested LRC activates auto-queue (subject to any manual override per (15)).
### Edge cases
23. If multiple exchanges occur within one conversation, each eligible agent-requested LRC independently triggers auto-queue on entry and reverts on exit; manual overrides per (15) never outlive the LRC they were made in.
24. Read-only shared-session viewers and other states where prompt sending is unavailable keep their existing restrictions; auto-queue does not create new send affordances there.
25. Auto-queue never queues an empty submission; Enter on an empty input follows (8) when rows are queued, and otherwise keeps its existing behavior.
+86
View File
@@ -0,0 +1,86 @@
# QUALITY-839 — Auto-queue prompts during agent-requested long-running commands
See `specs/QUALITY-839/PRODUCT.md` for behavior. Researched at commit `8e984f0d784f38684472054978db10f39ff7ea5c` (branch `harry/quality-839-auto-enable-prompt-queueing-during-lrc`, stacked on the APP-4717 empty-input-Enter send-now work).
## Context
All read sites for "is queue mode on?" already funnel through one method, so the core of this feature is making that method LRC-aware:
- [`app/src/ai/blocklist/queued_query.rs:366 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/ai/blocklist/queued_query.rs#L366) — `QueuedQueryModel::is_queue_next_prompt_enabled`: per-conversation override falling back to the cached `AISettings::default_prompt_submission_mode`. `ConversationQueueState` (L152-164) holds the per-conversation override; `toggle_queue_next_prompt` (L377) flips it.
- [`app/src/terminal/input.rs:13778 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/terminal/input.rs#L13778) — `maybe_queue_input_for_in_progress_conversation`: the submission intercept; consults `is_queue_next_prompt_enabled` and conversation in-progress/blocked status. During an eligible agent-requested LRC the conversation status is `InProgress` (or `Blocked`), so no change is needed to its status gating.
- [`app/src/terminal/input.rs:6141 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/terminal/input.rs#L6141) — `agent_mode_hint_text`: ghost text switches to the queue hint (`AGENT_MODE_AI_ENABLED_QUEUE_HINT_TEXT_*`, L453-455) when `is_queue_next_prompt_enabled` is true and the conversation is in progress. PRODUCT §13 falls out automatically.
- [`app/src/ai/blocklist/block/status_bar.rs:838 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/ai/blocklist/block/status_bar.rs#L838) — the queue chip (`queue_next_prompt_button`) renders accent-colored when `is_queue_next_prompt_enabled` is true. PRODUCT §12 falls out automatically.
- [`app/src/terminal/model/block/interaction_mode.rs:102 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/terminal/model/block/interaction_mode.rs#L102) — `Block::is_agent_in_control` plus `Block::is_agent_requested_command()` form the trigger condition (PRODUCT §1-2). This covers the blocked-on-approval state (`LongRunningCommandControlState::Agent { is_blocked, .. }`) for agent-requested commands, while excluding user-in-control, tagged-in-only, and user-started LRCs where the user explicitly tagged in the agent.
- [`app/src/terminal/view.rs:27035 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/terminal/view.rs#L27035) — `ToggleQueueNextPrompt` handler (chip click + `Cmd-Shift-J`): resolves the active conversation and calls `QueuedQueryModel::toggle_queue_next_prompt`. `TerminalView` holds `self.model`, so it can check LRC control state when routing the toggle.
- Re-render on LRC transitions is already wired: the status bar notifies on `CLISubagentEvent::UpdatedControl` and `ModelEvent::BlockCompleted` ([`status_bar.rs:231-248, 312-330`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/ai/blocklist/block/status_bar.rs#L231-L248)), and its warping-indicator render already locks the terminal model and reads `is_agent_in_control` (L752-770). The input likewise already locks `self.model` on hot paths (e.g. `is_input_mode_toggle_disabled`, [`input.rs:14409`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/terminal/input.rs#L14409)).
- [`app/src/settings/ai.rs:496-533 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/settings/ai.rs#L496-L533) — `PromptSubmissionMode` setting; the new enum setting is defined next to it and follows the same `implement_setting_for_enum!` pattern.
- [`app/src/settings_view/ai_page.rs:5771-5790 @ 8e984f0d`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/settings_view/ai_page.rs#L5771-L5790) — `AIInputWidget::render` places the "Default prompt submission mode" dropdown under `FeatureFlag::QueueSlashCommand`; the new dropdown goes directly below it. Palette wiring pattern for the sibling setting: `init_actions_from_parent_view` (L367-388) + context flags in [`settings_view/mod.rs:521-522`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/settings_view/mod.rs#L521-L522) + flag computation in [`workspace/view.rs:22371`](https://github.com/warpdotdev/warp/blob/8e984f0d784f38684472054978db10f39ff7ea5c/app/src/workspace/view.rs#L22371).
No new feature flag (per decision): everything ships under the existing `QueueSlashCommand` gate that already wraps the chip, the queue panel, and the submission intercept.
## Proposed changes
### 1. Setting (`app/src/settings/ai.rs`)
New enum setting in `AISettings`, defined next to `PromptSubmissionMode`:
```
pub enum LongRunningCommandSubmissionMode {
SendImmediately,
#[default]
QueueUntilCommandCompletes,
}
```
registered via `implement_setting_for_enum!` (cloud-synced, `toml_path: "agents.warp_agent.other.long_running_command_submission_mode"`, `feature_flag: FeatureFlag::QueueSlashCommand`) and stored as the `long_running_command_submission_mode` field. `display_name()` returns "Send immediately" / "Queue until command finishes"; `command_palette_description()` the matching "Set long-running command submission: …" strings. The settings macro generates the matching `AISettingsChangedEvent::LongRunningCommandSubmissionMode` variant used below.
### 2. LRC-aware enablement, computed at the call sites (`queued_query.rs`, `input.rs`, `status_bar.rs`)
No LRC state is pushed into `QueuedQueryModel`; each call site determines the LRC context itself from the terminal model it already holds, and the model only answers the enablement question given that context.
- `QueuedQueryModel::is_queue_next_prompt_enabled` gains a `lrc_auto_queue_active: bool` parameter, computed via the shared `is_lrc_auto_queue_active` helper (`queued_query.rs`): true exactly when the queue feature flag is on, `default_prompt_submission_mode == Interrupt`, `long_running_command_submission_mode == QueueUntilCommandCompletes`, and the active block's agent controls an agent-requested command for this conversation (`is_agent_requested_command()`). The Interrupt requirement keeps the whole LRC machinery inert in Queue mode (PRODUCT §5). When true, return the LRC-scoped override if set, else `true` (auto-enabled); when false, existing logic (persistent override → cached default). The persistent override is never consulted or written while the LRC branch is in effect, which yields the revert-on-LRC-end semantics for PRODUCT §14, §17.
- One new field on `ConversationQueueState`: `queue_next_lrc_prompt_override: Option<bool>` — a manual toggle made during an eligible agent-requested LRC. It is explicitly cleared when the command ends: `TerminalView` calls `clear_queue_next_lrc_prompt_override` on `CLISubagentEvent::FinishedSubagent` (PRODUCT §15-16, §23). Also dropped with the conversation's queue state.
- Call sites that compute `lrc_auto_queue_active` (each already holds the terminal model):
- `maybe_queue_input_for_in_progress_conversation` (`input.rs`) — the routing decision stays in the input, as today.
- `agent_mode_hint_text` (`input.rs`) — ghost text (PRODUCT §13).
- `render_warping_indicator_for_latest_exchange` (`status_bar.rs`) — the chip's `is_active` (PRODUCT §12); this render already reads `is_agent_in_control` from the locked terminal model.
- The settings are read directly from `AISettings` at each call site (no cache), so mid-LRC setting flips take effect on the next render/submission (PRODUCT §22). For chip/hint re-render on the setting change, `QueuedQueryModel`'s `AISettingsChangedEvent` subscription also re-emits `DefaultModeChanged` for the `LongRunningCommandSubmissionMode` variant.
### 3. Toggle routing (`app/src/terminal/view.rs`)
In the `ToggleQueueNextPrompt` handler, check `is_lrc_auto_queue_active`: when true, call `QueuedQueryModel::toggle_queue_next_prompt_during_lrc(conversation_id, ctx)`, which writes `queue_next_lrc_prompt_override = Some(!current_effective)`; otherwise the existing `toggle_queue_next_prompt`. Both emit `QueueNextPromptToggled`, which the status bar and input already subscribe to. Re-render on control transitions themselves (agent takes/loses control) is covered by the status bar's existing `UpdatedControl`/`BlockCompleted` notifies; the input additionally subscribes to `CLISubagentEvent` (`SpawnedSubagent`/`UpdatedControl`/`FinishedSubagent`/`ControlHandedBackAfterTransfer`) to refresh the ghost text, since its hint subscriptions did not previously cover control transitions.
### 4. Queued-row origin (`queued_query.rs`, `input.rs`, `server/telemetry/events.rs`)
New `QueuedQueryOrigin::LrcAutoQueue` variant (and matching `TelemetryQueuedQueryOrigin` value). `maybe_queue_input_for_in_progress_conversation` uses it instead of `AutoQueueToggle` when the LRC branch is the effective enabler (`lrc_auto_queue_active` is true and the persistent non-LRC queue toggle/default would be off), the submission is a prompt, and the current queue head is absent or already has `LrcAutoQueue` origin. If regular queue mode is already enabled, or if the current queue head has any other origin, the new prompt keeps `AutoQueueToggle` so command-finish delivery cannot jump it over older queued rows. Command rows always keep `AutoQueueToggle` since they cannot be delivered to the agent (PRODUCT §11). The origin drives both the send-on-command-finish behavior (§5 below) and the panel row suffix (§6 below), and distinguishes the rows in `QueuedPrompt*` telemetry. Exhaustive matches on the enum get the new arm.
### 5. Send queued prompts when the command finishes (`app/src/terminal/view.rs`)
New `TerminalView::send_lrc_queued_prompts(conversation_id, ctx)`: collects the conversation's leading queued rows with `LrcAutoQueue` origin, and for each (in queue order) dispatches it via `Input::submit_queued_prompt_for_active_pane` + `QueuedQueryModel::remove_fired_row` — the same path the panel's send-now button uses. Called from the `CLISubagentEvent::FinishedSubagent` handler, right after `clear_queue_next_lrc_prompt_override` (PRODUCT §10). `FinishedSubagent` fires when the command block completes regardless of who held control at that moment, which gives the fire-after-manual-takeover behavior of §10. Rows with other origins stop the command-finish drain and keep the existing end-of-response drain (`drain_queued_prompts`).
### 6. Queued row suffix (`app/src/terminal/view/queued_prompts_panel.rs`)
In `render_row`, non-command rows with `LrcAutoQueue` origin render an italic `sub_text_color` suffix — `"(queued until the command finishes)"` (`LRC_AUTO_QUEUE_ROW_SUFFIX`) — after the preview text, mirroring the model picker's "(selected)" treatment. The preview is wrapped in `Shrinkable::new(1., …)` so it shrinks to its text (clipping with an ellipsis when long) and the suffix hugs it.
### 7. Settings UI (`app/src/settings_view/ai_page.rs`)
Inside the existing `FeatureFlag::QueueSlashCommand.is_enabled()` block in `AIInputWidget::render`, after the "Default prompt submission mode" dropdown: a second `render_dropdown_item` labeled "Default long-running command submission mode", rendered only when `default_prompt_submission_mode == Interrupt` (PRODUCT §19). The dropdown handle (`lrc_submission_mode_dropdown`) lives on `AISettingsPageView`, is built by `OtherAIWidget::create_lrc_submission_mode_dropdown` (the `create_default_prompt_submission_mode_dropdown` pattern), and re-syncs its selection on `AISettingsChangedEvent::LongRunningCommandSubmissionMode`. A new `AISettingsPageAction::SetLongRunningCommandSubmissionMode(mode)` persists via `set_value` (the `SetPromptSubmissionMode` pattern). LRC terms live in `AIInputWidget::search_terms`.
### 8. Command palette (`settings_view/mod.rs`, `workspace/view.rs`, `ai_page.rs`)
- New context flags `LRC_SUBMISSION_SEND_IMMEDIATELY` / `LRC_SUBMISSION_QUEUE_UNTIL_COMMAND_COMPLETES` in `settings_view/mod.rs` flags, set from `ai_settings.long_running_command_submission_mode` in the workspace context computation (the `PROMPT_SUBMISSION_*` pattern).
- Per-mode `FixedBinding`s registered in `ai_page::init_actions_from_parent_view` next to the `PromptSubmissionMode` bindings, additionally gated on `PROMPT_SUBMISSION_INTERRUPT` so the entries hide when the setting is hidden (PRODUCT §21).
## Testing and validation
- `app/src/ai/blocklist/queued_query_tests.rs` (model-level, maps to PRODUCT invariants):
- `is_queue_next_prompt_enabled` with `lrc_auto_queue_active` → enabled by default; without → existing behavior unchanged (§1, §14, §20).
- `toggle_queue_next_prompt_during_lrc` writes the LRC-scoped override, leaves the persistent override untouched, and re-toggling re-enables (§15, §16); `clear_queue_next_lrc_prompt_override` (command end) restores auto-enable for the next LRC (§23) and the pre-LRC state is what the non-LRC path returns afterward (§14, §17).
- `app/src/terminal/input_tests.rs` (host-level, next to the existing queue host tests): with the active block's agent in control for an agent-requested command and the default settings, a non-empty AI submission queues instead of submitting, with `LrcAutoQueue` origin when regular queue mode is off and the queue is empty or its head is already `LrcAutoQueue` (§6); if regular queue mode is on or the current queue head is not `LrcAutoQueue`, the submission keeps the generic origin and does not fire at command finish (§7, §10); user-tagged LRCs do not auto-queue (§2); ghost text returns the queue hint (§13); "Send immediately" → submission routes as today (§20, §22); Queue default mode → row queues with the generic origin (§5); `send_lrc_queued_prompts` fires leading `LrcAutoQueue` rows in order and leaves other rows queued (§10).
- Chip state (§12) is a pure read of `is_queue_next_prompt_enabled` — covered by the model tests; verify visually in the manual smoke.
- Manual smoke: run a dev-server-style command via the agent, let the agent take control, submit prompts into an empty queue while regular queue mode is off (they queue with the row suffix and fire together at command finish), repeat with regular queue mode on or a non-LRC queued row at the head (the new row has no suffix and does not fire at command finish), toggle the chip off mid-LRC (submission steers immediately), and flip the dropdown in Settings → AI mid-LRC (including hiding it by switching the default mode to Queue).
- `cargo check` + `./script/format`; full presubmit before PR per repo workflow.
## Parallelization
Not beneficial: the change is a single coupled chain (setting → model API → call sites → settings UI) where each step consumes the previous one's types. A single agent implements it on this branch (`harry/quality-839-auto-enable-prompt-queueing-during-lrc`).
+151
View File
@@ -0,0 +1,151 @@
# TECH: wait_for_events parent registration for owner-side orchestration events
Linear: [QUALITY-919 — Auto-register orchestrators for child events on wait_for_events](https://linear.app/warpdotdev/issue/QUALITY-919/auto-register-orchestrators-for-child-events-on-wait-for-events)
## Context
We deliver child lifecycle and inbox-message events to an orchestrator (parent) through an owner-side SSE stream managed by `OrchestrationEventStreamer` (`app/src/ai/blocklist/orchestration_event_streamer.rs`). A conversation is treated as a parent only when its `watched_run_ids` contains a non-self run id (`is_parent_agent_conversation`, `:1463`). That set is populated when children are launched via `run_agents` (`register_watched_run_id`, `:534`) or rehydrated on restore from the server task's `children` (`:1386`). When a parent is eligible and `OwnerOrchestrationAncestorStreamer` is on (now in `default`, so enabled on all channels), `desired_sse_filter` (`:1574`) selects an `AncestorRunId { include_self: true }` stream that delivers the parent's own inbox plus all direct children's events on one ordered stream, discovering children dynamically via a server-side `parent_run_id` JOIN.
Gap: children can also be created out-of-band — via the Oz CLI or web API — by passing `parent_run_id` directly. That path never calls `register_watched_run_id`, so the parent's client never learns it is a parent, `desired_sse_filter` stays on `RunIds(self)` (or no stream), and the parent misses its children's events.
This change uses the `wait_for_events` client tool — the moment an orchestrator blocks on its descendants — as the trigger to confirm parent status against the server and register for the ancestor stream.
Scope: this is a parent-side fix only. An out-of-band child already subscribes to its own inbox in its own driver-hosted process via the existing `has_parent_agent` eligibility — the parent run id is stamped as `parent_agent_id` (`app/src/ai/agent/conversation.rs:1101`), which makes the child eligible (`is_eligible`) and opens a `RunIds(self)` stream (requires an active consumer, which a running child has). The gap is solely that the *parent* never learns it has such a child.
**Invariant (load-bearing):** orchestration trees are one level deep — a run is either a root orchestrator or a leaf child, never both. The server's ancestor query is already single-level (`parent_run_id = $1`), so this assumption is consistent end-to-end. The design relies on it in exactly one place (the child short-circuit below) and must be revisited alongside the server query if multi-level trees are introduced.
No user-visible behavior changes; this is event-delivery correctness, so no `PRODUCT.md` accompanies this spec. Behavioral contract: after an orchestrator with at least one server-recorded child calls `wait_for_events`, it receives that child's lifecycle and message events (and its own inbox) for the remainder of the conversation, regardless of how the child was created.
Relevant code:
- `app/src/ai/blocklist/action_model/execute/wait_for_events.rs` — executor; `execute()` already has `conversation_id` and currently only schedules a watchdog and flips status to `WaitingForEvents`.
- `orchestration_event_streamer.rs`: `is_parent_agent_conversation` (`:1463`), `desired_sse_filter` (`:1574`), `reevaluate_eligibility` (`:1602`), `register_watched_run_id` (`:534`), restore application of `task.children` (`:1386`), `is_eligible` + `has_parent_agent` usage (`:1541-1543`), `teardown_sse` stickiness comment (`:2032-2044`).
- `app/src/ai/ambient_agents/task.rs:180-185``AmbientAgentTask.children`, the server-recorded direct children (`parent_run_id`-based; includes CLI/API children).
- `start_agent.rs:182` — existing pattern for an action executor to drive `OrchestrationEventStreamer`.
## Proposed changes
1. **New dogfood-gated flag `WaitForEventsParentRegistration`** (follow the `add-feature-flag` skill: enum variant in `crates/warp_features/src/lib.rs`, `DOGFOOD_FLAGS` entry, Cargo feature + `enabled_features()` bridge in `app/Cargo.toml` and `app/src/features.rs`). It gates the entire new behavior so rollout is independent of the already-shipped `OwnerOrchestrationAncestorStreamer`.
2. **New method on `OrchestrationEventStreamer`**`register_parent_on_wait(conversation_id, ctx)`:
- Flag disabled → return.
- `conversation.has_parent_agent()` is true → return. One-level-tree invariant: a child cannot be a parent, so skip the server fetch entirely. The child still receives its own inbox via the existing `is_eligible``RunIds(self)` stream, so there is no regression.
- `is_remote_run_view(conversation_id)` is true → return. A shared-session viewer or remote-child placeholder is a passive view of a run executing elsewhere; that process owns the inbox. Mirrors the `is_eligible` exclusion and avoids a wasted fetch.
- `is_parent_agent_conversation(conversation_id)` already true → return. No re-fetch is needed to discover children added later: once the parent is on the `include_self` ancestor stream, the server `parent_run_id` JOIN and `AncestorKey` fan-out already deliver events for any new child (including out-of-band ones), so new-child discovery is the stream's job, not the fetch's. The fetch exists only to make the initial not-parent → parent transition, and the role is permanent thereafter.
- Otherwise resolve `self_run_id`; if absent, return (rare — the next wait re-checks). Spawn `ai_client.get_ambient_agent_task(self_run_id)`.
- On result, if `task.children` is non-empty: insert the ids into `watched_run_ids`, advance `event_cursor = max(local, task.last_event_sequence)`, then call `reevaluate_eligibility`. Note `last_event_sequence` is the client's confirmed-processing **delivery** cursor for the run, not the max recorded sequence; it is `NULL` until the client acknowledges events (advanced only via the advance-only `PATCH /agent/runs/:runId/event-sequence`). So a first-time-registering root has `NULL`, the cursor stays at 0, and the ancestor stream replays from the beginning and delivers the child's already-pending events — which is also why reusing the restore path's `max(local, confirmed)` merge is correct here (it resumes past acknowledged events, not unseen ones). With `OwnerOrchestrationAncestorStreamer` on, this opens the `AncestorRunId { include_self: true }` stream, which thereafter tracks all children dynamically (the out-of-band ones and any created later). Mirror the restore path's task-application logic (`:1386`); factor a shared helper if convenient. If `children` is empty → no-op (not a parent).
3. **Invoke from `wait_for_events.rs::execute()`** via `OrchestrationEventStreamer::handle(ctx).update(ctx, |s, ctx| s.register_parent_on_wait(conversation_id, ctx))`, using the same access pattern as `start_agent.rs:182`.
### Decision flow
```mermaid
flowchart TD
W["wait_for_events.execute()"] --> M["register_parent_on_wait()"]
M --> F{"flag enabled?"}
F -- no --> X1["return (no-op)"]
F -- yes --> C{"has_parent_agent()?"}
C -- "yes / child" --> X2["return: child already gets its inbox via RunIds(self)"]
C -- "no / root" --> RV{"is_remote_run_view?"}
RV -- yes --> X5["return: passive view; owner process holds the inbox"]
RV -- no --> P{"already is_parent?"}
P -- yes --> X3["return: ancestor stream already tracks new children"]
P -- no --> G["get_ambient_agent_task(self)"]
G --> H{"task.children non-empty?"}
H -- no --> X4["return: not a parent (re-checked next wait)"]
H -- yes --> R["insert children into watched_run_ids; advance cursor; reevaluate_eligibility"]
R --> S["opens AncestorRunId include_self stream; tracks all children dynamically"]
```
### Sketch (illustrative)
```rust
// orchestration_event_streamer.rs
pub fn register_parent_on_wait(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
if !FeatureFlag::WaitForEventsParentRegistration.is_enabled() {
return;
}
// One-level-tree invariant: a child can never be a parent.
let is_child = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.is_some_and(|c| c.has_parent_agent());
if is_child {
return;
}
// Passive view (shared-session viewer / remote child): owner process holds the inbox.
if self.is_remote_run_view(conversation_id, ctx) {
return;
}
// Already a known parent: the ancestor stream already tracks new children.
if self.is_parent_agent_conversation(conversation_id, ctx) {
return;
}
let Some(self_run_id) = self.self_run_id(conversation_id, ctx) else {
return;
};
let Ok(task_id) = self_run_id.parse::<AmbientAgentTaskId>() else {
return;
};
let ai_client = self.ai_client.clone();
ctx.spawn(
async move { ai_client.get_ambient_agent_task(&task_id).await },
move |me, result, ctx| {
let Ok(task) = result else { return; };
if task.children.is_empty() {
return; // not a parent
}
// Mirror the restore path (:1386): populate watched_run_ids + cursor.
me.apply_task_children(conversation_id, &task, ctx);
me.reevaluate_eligibility(conversation_id, ctx);
},
);
}
```
```rust
// wait_for_events.rs::execute(), after `conversation_id` is bound
OrchestrationEventStreamer::handle(ctx).update(ctx, |s, ctx| {
s.register_parent_on_wait(conversation_id, ctx);
});
```
**Representation:** reuse `watched_run_ids`; no new "is parent" state is introduced. Permanence falls out for free — `watched_run_ids` is sticky and `teardown_sse` preserves it (`:2032-2044`), so the parent role persists for the conversation's life and across wait cycles; subsequent waits short-circuit on the already-parent check. After the initial transition `watched_run_ids` is intentionally **not** refreshed; it is only the boolean for `is_parent_agent_conversation` and filter selection. Children added later are delivered by the live ancestor stream, not by this set — the server fans out by `AncestorKey(parent_run_id)` and the owner drain (`handle_event_batch`, `:1954`) processes every streamed event except `killed_run_ids` tombstones, so it must never gain a `watched_run_ids` filter (see Risks).
**Flag-off / no-children behavior** is exactly today's behavior (parents discovered only via `run_agents`/restore), which keeps rollout safe.
## Testing and validation
Unit tests (`orchestration_event_streamer_tests.rs`, following the existing `*_ancestor_include_self_stream` tests):
- Root with server-recorded children → `register_parent_on_wait` opens one `AncestorRunId { include_self: true }` stream (assert the connected filter).
- Child (`has_parent_agent`) → no task fetch, no parent role, no stream change.
- Root with no children → no registration.
- Idempotent: a second call when already a parent does not re-fetch or churn the stream.
- Flag off → no-op.
- `self_run_id` absent → no fetch, no-op.
- `get_ambient_agent_task` returns an error → no registration (graceful).
Executor test (`wait_for_events_tests.rs`): `execute()` invokes the streamer method behind the flag and honors the child short-circuit.
Manual (dogfood build, flag on): a parent creates a child via the Oz CLI/web API passing `parent_run_id`, then calls `wait_for_events`; verify the parent surfaces the child's lifecycle + messages (inbox notification) and that the watchdog does not fire first.
Execution: run the affected tests via `cargo nextest run -p warp` (per AGENTS.md). The implementer must be green on these plus `./script/format` and `cargo clippy` (the `./script/presubmit` versions) before requesting review. No `crates/integration` test is added — disproportionate for this client-internal change; the manual dogfood repro covers end-to-end.
Dynamic-discovery coverage: that a child added *after* registration is delivered without a re-fetch is covered indirectly — the unit test asserts the connected filter is `AncestorRunId { include_self: true }`, and existing ancestor-stream tests already cover delivery for children resolved by that filter (server fan-out is not re-tested here).
Contract mapping: the "root with children" unit test plus the manual repro cover the behavioral contract in Context; the child, flag-off, error, and missing-run-id tests guard against over-registration and regressions.
## Parallelization
The implementation itself is a single coherent change (executor + streamer + flag plumbing, tightly coupled to its tests) and is **not** split across parallel implementation agents. Per the implementation plan it is executed by one implementation agent plus a separate code-review agent in an iterative review loop — a quality gate rather than a wall-clock speedup. See the plan's Orchestration section for worktree, branch, and coordination details.
## Environment
- Repo/worktree: `~/src/event-registrations/warp`, branch `matthew/event-registrations`. Client-only — no `warp-server` or `warp-proto-apis` changes. The server ancestor stream, `include_self`, `get_ambient_agent_task.children`, and `orchestration_viewer_streamer` already shipped and are enabled in prod.
- New flag touches `crates/warp_features/src/lib.rs`, `app/Cargo.toml`, and `app/src/features.rs`.
## Risks and mitigations
- **One-level-tree assumption:** the `has_parent_agent` short-circuit (and the single-level ancestor stream) would miss mid-tree nodes if trees become multi-level. Mitigation: invariant documented here and consistent with the server's single-level JOIN; revisit both together.
- **Timing race (accepted):** a child created during an already-blocked empty wait is not seen until the next wait. Mitigation: orchestrators create children before waiting; each subsequent wait re-checks and self-heals.
- **Extra GET per wait for childless roots:** a childless root re-fetches `get_ambient_agent_task` on every `wait_for_events` (intentional — this is the self-heal path by which a root that gains a child later discovers it). Roots that already have children skip after the first wait (they become known parents). The fetch is a single lightweight GET; add negative caching only if it proves costly (follow-up).
- **`watched_run_ids` goes stale after the transition (children added or removed out-of-band):** by design. A child added later is never inserted into the set, yet its events are still delivered because the open ancestor stream is keyed on `AncestorKey(self)` and the owner drain (`drain_sse_events`/`handle_event_batch`, `:1914`/`:1954`) does not filter by `watched_run_ids` (it drops only `killed_run_ids`). Deleted children are harmless for the same reason. **Guard:** do not add `watched_run_ids` filtering to the ancestor drain path, and do not assume the set enumerates current children — out-of-band children added after the initial transition live only on the stream. This holds only while the active filter is the ancestor stream (the feature's premise); static `RunIds` mode would not pick them up.
## Follow-ups
- **Always-on child discovery (lazy listening at first wait):** when a non-child agent first calls `wait_for_events`, open a lightweight **self** stream (`RunIds([self])`) and keep it open. That delivers the parent's own inbox and serves as the landing channel. Have the server emit a `child_agent_started` event on the parent's own run (`run_id = P`; parent resolved at creation via `resolveTaskOwnerFromParentRun`, `../warp-server/router/handlers/public_api/agent_webhooks.go:1132`) whenever a task is created with `parent_run_id = P`. The parent receives it on the self stream, registers the child / flips `is_parent`, and upgrades to `AncestorRunId { include_self: true }` (a superset of the self stream, so no coverage gap), thereafter receiving that child's events and all future children's via `AncestorKey(self)` fan-out. Children are thus discovered the instant they start (mid-wait or while actively working), the per-wait `get_ambient_agent_task` poll is removed, and childless waiters hold only the cheap self stream — the ancestor stream opens only once a child actually exists. Here `child_agent_started` is load-bearing (the upgrade trigger). New wiring: `desired_sse_filter` selects the self stream for a waiting root and the ancestor filter once a child is known. Complementary to the existing restore fetch (cold start via `task.children`); spans warp-server (emit `child_agent_started`), warp (open-on-wait + upgrade handler), and possibly warp-proto-apis (event type).
- **Alternative to the above:** open `AncestorRunId { include_self: true }` directly at first wait (no new server event — a child's first lifecycle event surfaces on it via the connect-time JOIN + `AncestorKey` fan-out), at the cost of holding the ancestor stream for every waiting root even when childless.
- Promote `WaitForEventsParentRegistration` via the `promote-feature` skill after dogfood, then remove it via `remove-feature-flag` once stable.
- Open a draft PR per the repo workflow (template at `.github/pull_request_template.md`), likely `CHANGELOG-NONE`.

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