first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# REMOTE-1458: Unified agent icon-with-status across Warp surfaces
|
||||
## Summary
|
||||
Warp shows an "agent icon" on several surfaces — vertical tabs, the pane header, the inline conversation list menu (the slide-out list of ACTIVE + PAST runs in `conversation_list/item.rs`), and the notifications mailbox. This spec defines the canonical agent-icon treatment and the cross-surface consistency contract so the same logical run always renders as the same icon no matter which surface it appears on. The Agent Management View (card list) is explicitly out of scope for this pass — its richer layout owns its own status treatment and we're not disrupting it yet. Reference issue: https://linear.app/warpdotdev/issue/REMOTE-1458.
|
||||
## Problem
|
||||
Across Warp's agent-run surfaces today, users frequently can't tell two fundamental things:
|
||||
- **Whether a cloud run is Oz or a third-party harness** (Claude Code, Gemini CLI). Cloud runs with a non-oz harness render with the Oz cloud glyph during the entire pre-setup / setup phase because the client-side `CLIAgentSessionsModel` — the thing every surface consults for harness identity — is only populated once the harness CLI is detected in the shared session. Until then, even a Claude run that the user explicitly selected looks like an Oz run.
|
||||
- **Whether a third-party agent run is local or in the cloud.** A local `claude` CLI session and a cloud-mode Claude run render with identical brand colors and glyphs; nothing distinguishes the two, so users can't tell whether "Claude" in their tab bar is running on their machine or in a remote sandbox.
|
||||
Concretely, each surface contributes to the ambiguity in its own way:
|
||||
- `app/src/workspace/view/vertical_tabs.rs` resolves the tab icon from `CLIAgentSessionsModel::session(terminal_view.id())`. Before the harness CLI starts, ambient runs fall through to the plain `OzAgent` variant regardless of which harness the user selected.
|
||||
- `app/src/terminal/view/pane_impl.rs` renders a single plain `OzCloud` glyph for every ambient run via `render_ambient_agent_indicator` and an unbadged Oz/OzCloud glyph for conversation-bound terminals via `render_agent_indicator`. No brand circle, no status badge, no harness distinction, no local-vs-cloud distinction.
|
||||
- `app/src/workspace/view/conversation_list/item.rs` (`render_item`) renders a plain `Icon::Cloud` glyph for every ambient row and a plain `render_status_element` for every local row — no brand color, no harness distinction. Rows representing Oz cloud, Claude cloud, and Gemini cloud runs all render as the same cloud outline.
|
||||
- `app/src/ai/agent_management/notifications/item_rendering.rs` renders the correct brand color and glyph per CLI agent but hardcodes `is_ambient: false`, so a notification triggered by a cloud-mode Claude run looks identical to one triggered by a local Claude session.
|
||||
Beyond the visual problem, the derivation logic itself is duplicated across surfaces and drifts: vertical tabs alone has two separate waterfalls (`resolve_icon_with_status_variant` and `TypedPane::summary_pane_kind`) that recompute the same fields, and neither routes through `TerminalView::selected_conversation_status_for_display`, which is where "surface InProgress during cloud setup" logic wants to live. Adding a fifth surface today means copy-pasting the waterfall a third time and re-reasoning about every edge case.
|
||||
## Goals
|
||||
- Render the same agent icon shape everywhere Warp represents an agent run: a brand-color circle containing the agent glyph, a status badge at the bottom-right (local runs) or a white cloud lobe with the status icon inside (ambient/cloud runs).
|
||||
- Make the icon reflect the user's selected harness the moment a cloud run is committed — i.e. as soon as `AmbientAgentViewModel` transitions out of `NotAmbientAgent`, not only once the CLI command has started.
|
||||
- Make the status component reflect the correct run state on every surface, including the cloud-setup pre-first-exchange phase (surface as InProgress).
|
||||
- When a single logical run is represented on multiple surfaces at the same time (e.g. a cloud task that's both a visible pane and a card in the conversation list), every surface renders a pixel-identical icon.
|
||||
- Keep non-agent surfaces (plain terminal pane, shell indicators, error indicators) visually unchanged.
|
||||
## Non-goals
|
||||
- Redesigning the agent-icon visual itself. The circle + badge + cloud-lobe shape is defined in the Figma reference at https://www.figma.com/design/chk9pwt35jTJhf9KnHmZyE/Components?node-id=6535-4010 and is taken as given here.
|
||||
- Changing how CLIAgentSessionsModel is populated, how shared sessions are joined, or how the Oz / harness detection pipeline works.
|
||||
- Showing agent icons on surfaces that don't currently render an agent identity at all beyond the ones listed here.
|
||||
- Migrating status rendering away from `ConversationStatus`; we still use that type as the canonical "what state is this in" value.
|
||||
- Changing per-harness brand colors, icon glyphs, or sizing constants beyond what's needed to reuse the vertical-tab sizing on other surfaces.
|
||||
## User experience
|
||||
The canonical agent icon is a brand-color circle containing the agent glyph, with either a bottom-right status badge (local runs) or a white cloud lobe containing the status icon (ambient/cloud runs). It is rendered uniformly on every surface enumerated below, at surface-appropriate sizes.
|
||||
### Vertical tabs
|
||||
- When the user has selected a third-party harness (Claude, Gemini) and the run is in any ambient state (Setup, Composing, WaitingForSession, AgentRunning, Failed, Cancelled, NeedsGithubAuth), the vertical tab icon shows that harness's brand-color circle with the cloud lobe — not the Oz cloud circle — even before the harness CLI has started running.
|
||||
- When the `CLIAgentSessionsModel` later registers the actual CLI session, the tab continues to render the same agent brand (matching the selected harness), so there's no visible "flip" at harness-start.
|
||||
- When the run's harness is Oz, the tab shows the existing Oz ambient circle (brand purple background, Oz glyph, cloud lobe, status).
|
||||
- When the selected harness changes while composing (user opens the dropdown and picks a different harness), the icon updates immediately.
|
||||
### Pane header
|
||||
- The existing "agent indicator" slot on the pane-header title row (rendered via `render_ambient_agent_indicator` for cloud sessions and `render_agent_indicator` for conversation-bound terminals) switches to the same brand-color circle + status badge + cloud lobe treatment used in vertical tabs, at the same visual dimensions (16px icon / ~24px overall circle).
|
||||
- For an ambient Claude run in setup phase, the pane header shows a Claude-orange circle with a white cloud lobe containing the InProgress spinner — matching what the vertical tab shows for the same run.
|
||||
- For a local CLI agent session (e.g. `claude` running in a plain terminal), the pane header shows the CLI agent's brand circle with the status badge, no cloud lobe.
|
||||
- For plain-terminal / shell / error cases (no agent), the existing indicators (`render_terminal_mode_indicator` and its shell / error variants) remain unchanged. The new circle treatment only replaces the agent-specific indicators.
|
||||
### Inline conversation list menu
|
||||
- In the rows rendered by `conversation_list/item.rs::render_item`, the current leading-slot icon (a plain `Icon::Cloud` for ambient rows, or `render_status_element` for local rows) is replaced by the same agent icon-with-status treatment — brand circle, optional cloud lobe, status badge inside the lobe (ambient) or at the bottom-right (local).
|
||||
- For cloud Task rows (i.e. rows whose underlying `ConversationOrTask::Task` is an `AmbientAgentTask`), the harness is resolved from `agent_config_snapshot.harness` and the cloud lobe is rendered.
|
||||
- For local Conversation rows (`ConversationOrTask::Conversation`), the icon renders as a local Oz circle (no cloud lobe). Local conversations don't carry a harness field; they're treated as Oz for this surface.
|
||||
- The status inside the badge/lobe comes from `ConversationOrTask::status(app)` — which already maps task states and conversation states to `ConversationStatus`.
|
||||
- Row sizing is tuned to match the existing `status_element_size` footprint (`font_size + STATUS_ELEMENT_PADDING * 2.`) so adopting the unified icon doesn't shift row heights. Visual proportions follow the vertical tabs sizing.
|
||||
- **Out of scope**: the Agent Management View (`AgentManagementView::render_card` / `render_header_row` in `app/src/ai/agent_management/view.rs`) retains its current leading status icon. That surface has its own richer card layout with action buttons, session status labels, etc., and is intentionally not migrated in this pass.
|
||||
### Notifications mailbox
|
||||
- Notifications triggered by a cloud-mode agent run surface the cloud lobe in their avatar; notifications from local runs continue to render without the lobe.
|
||||
- Concretely, when `AgentNotificationsModel::add_notification` fires, the emit path resolves whether the originating terminal view was in an ambient session (via `TerminalView::ambient_agent_view_model`) and threads that flag into the notification, which in turn reaches `render_agent_avatar` instead of today's hardcoded `false`.
|
||||
- The avatar's brand color, glyph, and status icon keep matching the existing notification categories (`Complete`, `Request`, `Error`); only the cloud lobe is newly honored.
|
||||
### Cross-surface consistency guarantee
|
||||
- Given any single logical agent run (a cloud Task, a local conversation, a local CLI session), when it is visible simultaneously on two or more of the surfaces above, the resulting icon is pixel-identical (same brand color, same glyph, same status, same cloud-lobe presence). Achieving this is the centralization objective of this spec, and is validated by a shared test suite.
|
||||
## Edge cases
|
||||
1. **Harness selected but run not yet dispatched (Composing state)**: the vertical tab and pane header immediately reflect the selected third-party harness. The card surface is not applicable (no task yet).
|
||||
2. **Harness changed mid-composing**: icons update immediately on every surface that reflects the composing view-model.
|
||||
3. **Viewer joins an existing shared session whose harness is not yet fetched**: briefly renders as Oz until `enter_viewing_existing_session` resolves the task; once resolved, the icon updates to the correct harness. Acceptable — not new behavior, just inherited from REMOTE-1454.
|
||||
4. **Harness CLI detected by `CLIAgentSessionsModel` does not match the view-model's selected harness (rare)**: the `CLIAgentSessionsModel` session wins on surfaces that consult it directly (vertical tabs and pane header), because it represents observed reality. The conversation-list card still derives from `agent_config_snapshot.harness` since that's its only input.
|
||||
5. **Plain terminal, no agent at all**: all four surfaces render whatever they render today — the new circle treatment does not replace plain-terminal indicators.
|
||||
6. **Cloud notification vs. local notification for the same agent kind (e.g. Claude)**: they now visually differ — the cloud one has the white lobe, the local one has the bottom-right status badge. This is intentional and consistent with vertical tabs.
|
||||
7. **Completed ambient run viewed as a historical conversation (no live `AmbientAgentViewModel`)**: the card renders based on the `AmbientAgentTask` fields. If the task's `agent_config_snapshot.harness` is present, we show that harness with the cloud lobe; otherwise we fall back to Oz. The pane header for a read-only replay uses whatever `ambient_agent_view_model.selected_harness()` resolved to when the viewer joined.
|
||||
## Success criteria
|
||||
- Selecting Claude (or Gemini) in the harness dropdown and submitting a cloud run immediately updates the tab icon to the Claude (or Gemini) brand circle with a cloud lobe containing the InProgress status — no Oz-cloud intermediate state.
|
||||
- The pane header for the same run shows the same Claude circle + cloud lobe + status as the tab.
|
||||
- The conversation list card for the same run shows the same Claude circle + cloud lobe + status.
|
||||
- When the run transitions to Success / Error / Blocked / Cancelled, the status icon inside the cloud lobe updates on every surface at once.
|
||||
- Starting a local `claude` CLI session (no cloud) shows a Claude brand circle with a bottom-right status badge on the vertical tab, pane header, and notifications (when a notification fires). No cloud lobe on any surface.
|
||||
- The legacy non-agent indicators (plain terminal, shell, error) are visually unchanged.
|
||||
- All the existing validation cases from REMOTE-1454 (non-oz cloud setup UX) continue to pass unchanged.
|
||||
## Validation
|
||||
- Spawn a Claude cloud run and observe the tab icon, pane header indicator, and conversation list card; confirm all three render as a Claude-orange circle with a cloud lobe containing the spinner during setup, and that they all transition together to the harness-running state when the `claude` CLI begins executing.
|
||||
- Spawn a Gemini cloud run and confirm the same using the Gemini brand color.
|
||||
- Spawn an Oz cloud run and confirm the three surfaces show Oz purple + cloud lobe + status, matching each other.
|
||||
- Start a local `claude` CLI session in a plain terminal and confirm the tab, pane header, and any notification emitted (e.g. on a blocked permission request) show a Claude circle + bottom-right status badge with no cloud lobe.
|
||||
- Cancel / fail / block a cloud Claude run; confirm the status icon inside the cloud lobe updates on every surface.
|
||||
- Open the inline conversation list menu after running several agents (Oz cloud, Claude cloud, Gemini cloud, local Claude, local Oz) and confirm each row's leading icon correctly identifies the agent and ambient-ness.
|
||||
- Let a cloud-mode Claude run complete while the tab is not focused; open the notifications mailbox and confirm the notification's avatar is a Claude circle with a cloud lobe.
|
||||
- Toggle `CloudMode` off; confirm no regression to existing non-cloud agent icons (local Oz, local CLI agents).
|
||||
- Toggle `AgentHarness` off; confirm all cloud runs fall back to the Oz treatment and no Claude/Gemini icons appear on any surface.
|
||||
@@ -0,0 +1,199 @@
|
||||
# REMOTE-1458: Tech Spec — Unified agent icon-with-status
|
||||
## Problem
|
||||
Agent-icon rendering is duplicated across four surfaces and each surface re-derives the underlying `{agent kind, status, is_ambient}` tuple from different primitives. Today's logic is also incomplete: the vertical tab icon doesn't flip to the selected third-party harness until the harness CLI is detected in `CLIAgentSessionsModel`, leaving a stale "Oz cloud" icon during the entire setup phase for Claude / Gemini cloud runs. See `specs/REMOTE-1458/PRODUCT.md` for desired behavior.
|
||||
## Relevant code
|
||||
- `app/src/ui_components/agent_icon.rs` — source-facing helpers: `terminal_view_agent_icon_variant`, `conversation_or_task_agent_icon_variant`, plus the pure inner functions exercised by tests.
|
||||
- `app/src/ui_components/agent_icon_tests.rs` — cross-surface equivalence test suite.
|
||||
- `app/src/ui_components/icon_with_status.rs` — `IconWithStatusVariant`, `render_icon_with_status`, `render_with_cloud_status_badge`, `OZ_AMBIENT_BACKGROUND_COLOR`, sub-component size ratios.
|
||||
- `app/src/workspace/view/vertical_tabs.rs` — `resolve_icon_with_status_variant`, `TypedPane::summary_pane_kind`, `SummaryPaneKind`, `ambient_agent_variant`, `VERTICAL_TABS_ICON_SIZE`.
|
||||
- `app/src/terminal/view/pane_impl.rs` — `render_header_title`, `is_in_cloud_agent_setup_phase`, `selected_conversation_status`, `selected_conversation_status_for_display`, `PANE_HEADER_AGENT_SIZE`.
|
||||
- `app/src/terminal/view/ambient_agent/model.rs` — `selected_harness`, `is_third_party_harness`, `selected_third_party_cli_agent`, `mark_harness_command_started`, `harness_command_started`.
|
||||
- `app/src/terminal/view/ambient_agent/view_impl.rs::handle_ambient_agent_event` — re-emits `TerminalViewEvent::TerminalViewStateChanged` on icon-affecting state transitions.
|
||||
- `app/src/terminal/cli_agent_sessions/mod.rs` — `CLIAgentSessionsModel::session`.
|
||||
- `app/src/terminal/cli_agent.rs` — `CLIAgent` enum + `CLIAgent::from_harness`, brand colors, icons.
|
||||
- `crates/warp_cli/src/agent.rs` — `Harness` enum, `Harness::config_name` / `Harness::from_config_name`.
|
||||
- `app/src/workspace/view/conversation_list/item.rs` — `render_item` (inline conversation list row leading-slot icon), `LIST_ITEM_AGENT_SIZE`, `LIST_ITEM_OVERLAY_EXTRA_OVERHANG`.
|
||||
- `app/src/ai/agent_conversations_model.rs` — `ConversationOrTask::title`, `status`, `display_status`, `harness`.
|
||||
- `app/src/ai/ambient_agents/task.rs` — `AmbientAgentTask`, `AgentConfigSnapshot.harness: Option<HarnessConfig>`, `serialize_harness` / `deserialize_harness` (now route through `Harness::config_name` / `Harness::from_config_name`).
|
||||
- `app/src/ai/agent_management/notifications/item.rs` — `NotificationSourceAgent::{Oz, CLI}` (both carry `is_ambient`), `NotificationItem`.
|
||||
- `app/src/ai/agent_management/notifications/item_rendering.rs` — `render_agent_avatar`, `NOTIFICATION_AVATAR_SIZE`.
|
||||
- `app/src/ai/agent_management/agent_management_model.rs` — `handle_cli_agent_session_event`, `handle_history_event_for_mailbox`, `add_notification`, `find_terminal_view_by_id`, `TerminalViewMetadata` (consolidates the `is_ambient`/branch lookup).
|
||||
## Current state (pre-implementation)
|
||||
Vertical tabs contains the only surface that uses the full `IconWithStatusVariant` shape; `resolve_icon_with_status_variant` walks a CLI-session-then-conversation waterfall, and `TypedPane::summary_pane_kind` reimplements the same logic without status. The two waterfalls agree on the happy path but diverge on the ambient-but-no-session case (both return OzAgent regardless of the user's selected harness, which is the bug driving this spec).
|
||||
The pane header's indicator slot uses plain glyphs. `render_ambient_agent_indicator` renders `Icon::OzCloud` in a constrained box with no brand circle, no status, and no harness specialization. `render_agent_indicator` renders either `WarpIcon::Oz` / `WarpIcon::OzCloud` OR a raw status element — never a combined circle+status.
|
||||
The inline conversation list row's leading slot (in `conversation_list/item.rs::render_item`) switches between two plain icons based on whether the row represents a `ConversationOrTask::Task` (ambient) or `ConversationOrTask::Conversation` (local): a raw `Icon::Cloud` glyph for ambient rows, or `render_status_element(&conversation.status(app), font_size, appearance)` for local rows. There is no brand color, harness identity, or cloud-lobe treatment in that slot at all.
|
||||
The Agent Management View's card leading slot (`AgentManagementView::render_header_row` in `app/src/ai/agent_management/view.rs`) also calls `render_status_element` with the display status. That surface is out of scope for this pass — its richer card layout (action buttons, session status labels, creator avatars) owns its own status treatment and we leave it untouched.
|
||||
Notifications call `render_agent_avatar(NotificationSourceAgent, NotificationCategory, theme)` which maps to `IconWithStatusVariant::{OzAgent, CLIAgent}` with `is_ambient: false` hardcoded and a category-derived status.
|
||||
## Architecture: helpers, not a singleton
|
||||
Before diving into the per-surface changes, one architectural decision: centralization happens via a small set of source-facing *helpers* that return `Option<IconWithStatusVariant>`, not via a new singleton model that caches computed descriptors.
|
||||
A singleton was considered. It would offer "guaranteed centralization" and "one call site per surface" in exchange for keeping a cached `AgentIconDescriptor` keyed by some notion of run identity. The tradeoffs that kept us away from it:
|
||||
- **Keying.** A logical run has multiple identities (`terminal_view_id` when open, `AmbientAgentTaskId` remotely, `AIConversationId` in history, plus the notification's own `terminal_view_id`). A singleton would need overlapping indices and a merge policy for the same run viewed via different keys. On-demand helpers sidestep this entirely because each surface already holds exactly one identity.
|
||||
- **Update plumbing.** Every emit path that mutates the underlying state would have to push into the singleton: `CLIAgentSessionsModel` (6 event variants), `AmbientAgentViewModel` (11 event variants), `BlocklistAIHistoryModel` (conversation status updates), `AgentConversationsModel` (task state updates). ~25 new write sites, each a potential source of stale state.
|
||||
- **Derivation cost.** The computation is a small `match` over a few field reads. It is cheaper to recompute at render time than to maintain a cache. Cached derived state is also not an idiomatic pattern in WarpUI.
|
||||
- **Testability.** Source helpers with a source argument are trivially testable by constructing the source. Singleton state requires mocking.
|
||||
The Option-3 trait path (see *Follow-ups*) is the natural escalation if we ever grow past two source types and want a shared descriptor contract without a cache. Do that later, not now.
|
||||
## Proposed changes
|
||||
### 1. Extend `IconWithStatusVariant` with `is_ambient` + cloud-lobe rendering
|
||||
`app/src/ui_components/icon_with_status.rs`
|
||||
Extend `IconWithStatusVariant::OzAgent` and `IconWithStatusVariant::CLIAgent` to carry `is_ambient: bool`. Replace the previous `IconWithStatusSizing` struct with a single `total_size: f32` plus an `overlay_extra_overhang_ratio: f32` parameter on `render_icon_with_status`; every sub-component (brand circle, status badge, cloud lobe, status icon inside the lobe) is derived proportionally from `total_size` via module-private ratio constants (`CIRCLE_RATIO`, `BADGE_RATIO`, `CLOUD_RATIO`, etc.). Surfaces just pick the size they want and pass `0.0` for the overhang in the common case; the conversation list passes a small positive overhang to push the badge to the bounding-box corner.
|
||||
Introduce `render_with_cloud_status_badge` as a private helper that overlays a white `WarpIcon::CloudFilled` at the bottom-right of the brand circle with the status icon (if any) centered inside; invoke it from the agent variants when `is_ambient` is true in place of the normal status ring. For ambient Oz runs, swap the circle background to the brand purple `OZ_AMBIENT_BACKGROUND_COLOR = ColorU { r: 203, g: 176, b: 247, a: 255 }` and render `WarpIcon::OzCloud` instead of `WarpIcon::Oz`. Each surface defines a single `*_AGENT_SIZE: f32` (or `NOTIFICATION_AVATAR_SIZE`) constant; threading `is_ambient: true` is what actually enables lobe rendering.
|
||||
### 2. Centralize derivation via per-source helpers (no new types)
|
||||
Keep `IconWithStatusVariant` as the canonical render-time shape. Introduce one pure helper per data source, each returning `Option<IconWithStatusVariant>` covering only the agent variants (`OzAgent`, `CLIAgent`). Non-agent variants (`Neutral`, `NeutralElement`) stay at the call site.
|
||||
The helpers share three primitive mappers — each mapper lives in exactly one place and is invoked by every helper that needs it:
|
||||
- `CLIAgent::from_harness(Harness) -> Option<CLIAgent>`, added on `CLIAgent` in `app/src/terminal/cli_agent.rs`. Maps every `Harness` variant exhaustively (Oz → None; Claude/Gemini/OpenCode → their corresponding CLIAgent; Unknown → `Some(CLIAgent::Unknown)`, which the icon waterfall filters out). `AmbientAgentViewModel::selected_third_party_cli_agent` is a thin wrapper that just delegates to `CLIAgent::from_harness(self.harness)` — it does NOT gate on `FeatureFlag::AgentHarness`, so the icon change ships independently of that flag.
|
||||
- `Harness::config_name` / `Harness::from_config_name` on `crates/warp_cli/src/agent.rs`. The exhaustive `config_name` match forces every new `Harness` variant to declare a canonical name, and a round-trip test in `warp_cli::agent::tests` locks the inverse pair. `task.rs::serialize_harness` / `deserialize_harness` route through these helpers, so no separate string parser exists.
|
||||
- `ConversationOrTask::status(app) -> ConversationStatus` (already exists in `agent_conversations_model.rs`).
|
||||
- `ConversationOrTask::harness() -> Option<Harness>` (already exists in `agent_conversations_model.rs`); the task-card helper consumes this directly without round-tripping through a string.
|
||||
- `TerminalView::selected_conversation_status_for_display(ctx) -> Option<ConversationStatus>` (already exists in `pane_impl.rs`; surfaces InProgress whenever the run is busy, including the cloud-setup phase).
|
||||
### 3. `AmbientAgentViewModelEvent` re-emission through `TerminalViewStateChanged`
|
||||
`app/src/terminal/view/ambient_agent/view_impl.rs::handle_ambient_agent_event`
|
||||
The vertical tabs (and every other subscriber that renders from `terminal_view_agent_icon_variant`) re-render on `TerminalViewEvent::TerminalViewStateChanged`. Emit that event from the ambient-model event handlers for every state transition that can change the icon output: `EnteredSetupState`, `EnteredComposingState`, `DispatchedAgent`, `SessionReady`, `ProgressUpdated`, `Failed`, `Cancelled`, `NeedsGithubAuth`, `HarnessSelected`, `HarnessCommandStarted`. The event propagates pane-group → workspace → `ctx.notify()` via existing wiring in `app/src/pane_group/pane/terminal_pane.rs:720-722` and `app/src/workspace/view.rs:12920`.
|
||||
### 4. `TerminalView::is_in_cloud_agent_setup_phase` + status override
|
||||
`app/src/terminal/view/pane_impl.rs`
|
||||
Add `is_in_cloud_agent_setup_phase(ctx) -> bool` that returns true while `ambient_agent_view_model.is_waiting_for_session()` OR `is_cloud_agent_pre_first_exchange` is true. Update `selected_conversation_status` (line 996) and `selected_conversation_status_for_display` (line 1031) to treat the setup phase as `ConversationStatus::InProgress`, matching how active long-running shell commands surface today. This is what makes the spinner show up inside the cloud lobe on every surface that consults the terminal view for status (vertical tabs, pane header). Remote-task surfaces (conversation list cards) already return InProgress during `AmbientAgentTaskState::{Queued, Pending, Claimed, InProgress}` via `ConversationOrTask::status`, so they inherit the same semantic for free.
|
||||
### 5. `terminal_view_agent_icon_variant` helper
|
||||
New free function in a new module `app/src/ui_components/agent_icon.rs`, re-exported via `app/src/ui_components/mod.rs`. Signature:
|
||||
```rust
|
||||
pub(crate) fn terminal_view_agent_icon_variant(
|
||||
terminal_view: &TerminalView,
|
||||
app: &AppContext,
|
||||
) -> Option<IconWithStatusVariant>;
|
||||
```
|
||||
The public function gathers primitives from the live `TerminalView` into a `TerminalIconInputs` struct, then delegates to a pure inner function (`agent_icon_variant_from_terminal_inputs`) that's exercised directly by the cross-surface tests without an `AppContext`. Resolution order:
|
||||
1. If `CLIAgentSessionsModel::session(terminal_view.id())` returns a session with a known (non-`Unknown`) agent, return `CLIAgent { agent, status, is_ambient }` where `status` is `Some(session.status.to_conversation_status())` only when the session is plugin-backed AND the handler exposes rich status (Codex's OSC 9 handler does not). Plugin-backed and command-detected sessions share a single match arm here.
|
||||
2. Else if the terminal is ambient and `ambient_agent_view_model.selected_third_party_cli_agent()` returns a non-`Unknown` agent, return `CLIAgent { agent, status: selected_conversation_status_for_display, is_ambient: true }`. **This is the fix for the pre-setup bug.** `Unknown` is filtered to avoid rendering an unbranded gray circle for a future-server harness this client doesn't recognize.
|
||||
3. Else if the terminal has a selected conversation OR `is_ambient`, return `OzAgent { status: selected_conversation_status_for_display, is_ambient }`.
|
||||
4. Else `None` (caller falls through to `Neutral { Terminal }` / `Neutral { Shell }` / error indicator).
|
||||
### 6. `conversation_or_task_agent_icon_variant` helper
|
||||
Same module as above. Signature:
|
||||
```rust
|
||||
pub(crate) fn conversation_or_task_agent_icon_variant(
|
||||
src: &ConversationOrTask<'_>,
|
||||
app: &AppContext,
|
||||
) -> Option<IconWithStatusVariant>;
|
||||
```
|
||||
Rules:
|
||||
- `ConversationOrTask::Task(_)`: ambient run. Pull the `Harness` directly from `ConversationOrTask::harness()` (already returns `Option<Harness>` from the deserialized `agent_config_snapshot.harness.harness_type`, no string parsing) and pass it to a private `agent_icon_variant_for_task(harness, status)` that maps via `CLIAgent::from_harness`. Filter out `CLIAgent::Unknown` so future-server harnesses fall back to the Oz treatment instead of an unbranded gray circle.
|
||||
- `ConversationOrTask::Conversation(_)`: local Oz conversation. Emit `OzAgent { status: Some(src.status(app)), is_ambient: false }`. Local conversations have no `CLIAgent` signal on this surface (per product spec).
|
||||
### 7. Wire vertical tabs through the helper
|
||||
Replace the two inline derivations:
|
||||
- `resolve_icon_with_status_variant` in `vertical_tabs.rs:2254-2299` for the Terminal branch becomes:
|
||||
```rust
|
||||
TypedPane::Terminal(terminal_pane) => {
|
||||
let terminal_view = terminal_pane.terminal_view(app);
|
||||
let terminal_view = terminal_view.as_ref(app);
|
||||
if let Some(variant) = terminal_view_agent_icon_variant(terminal_view, app) {
|
||||
return variant;
|
||||
}
|
||||
IconWithStatusVariant::Neutral {
|
||||
icon: WarpIcon::Terminal,
|
||||
icon_color: main_text,
|
||||
}
|
||||
}
|
||||
```
|
||||
- `TypedPane::summary_pane_kind` in `vertical_tabs.rs:2488-2509` for the Terminal branch becomes a thin wrapper that maps the helper's variant into `SummaryPaneKind::OzAgent`/`CLIAgent` with matching `is_ambient`:
|
||||
```rust
|
||||
if let Some(variant) = terminal_view_agent_icon_variant(terminal_view, app) {
|
||||
return match variant {
|
||||
IconWithStatusVariant::OzAgent { is_ambient, .. } => SummaryPaneKind::OzAgent { is_ambient },
|
||||
IconWithStatusVariant::CLIAgent { agent, is_ambient, .. } => SummaryPaneKind::CLIAgent { agent, is_ambient },
|
||||
_ => unreachable!("helper only returns agent variants"),
|
||||
};
|
||||
}
|
||||
// else: existing conversation/title fallback / Terminal
|
||||
```
|
||||
No other logic in these two functions changes — the centralization removes the duplicated waterfall without moving the call sites.
|
||||
### 8. Wire pane header through the helper
|
||||
Replace the agent-only indicator branches in `render_header_title` (`pane_impl.rs`):
|
||||
- Today the branch chain is: shared ambient agent indicator → shared-session avatar / Sharing icon → conversation-selected `render_agent_indicator` → terminal-mode indicator.
|
||||
- New: when the shared-session branch fires for a viewed/transcript ambient session, OR when the conversation-bound branch fires, call `terminal_view_agent_icon_variant(self, app).map(render_agent_circle)` where `render_agent_circle` is a small closure that calls `render_icon_with_status(variant, PANE_HEADER_AGENT_SIZE, 0., theme, theme.background())`. This replaces both `render_ambient_agent_indicator` and `render_agent_indicator`.
|
||||
- Plain-terminal / shell / error indicators (`render_terminal_mode_indicator`) stay untouched.
|
||||
Define `PANE_HEADER_AGENT_SIZE: f32 = 26.` in `pane_impl.rs` (slightly larger than `VERTICAL_TABS_ICON_SIZE` to fit the pane header row). Delete the now-unused `render_agent_indicator` and `render_ambient_agent_indicator` after the migration, plus the `mouse_states.ambient_agent_indicator_mouse_handle` field on `TerminalView`.
|
||||
### 9. Wire the inline conversation list menu through the helper
|
||||
In `conversation_list/item.rs::render_item`, replace the two-branch icon derivation (`Icon::Cloud` for ambient rows, `render_status_element` for local rows) with:
|
||||
```rust
|
||||
let icon_element: Box<dyn Element> =
|
||||
match conversation_or_task_agent_icon_variant(conversation, app) {
|
||||
Some(variant) => render_icon_with_status(
|
||||
variant,
|
||||
LIST_ITEM_AGENT_SIZE,
|
||||
LIST_ITEM_OVERLAY_EXTRA_OVERHANG,
|
||||
theme,
|
||||
theme.background(),
|
||||
),
|
||||
None => render_status_element(&conversation.status(app), font_size, appearance),
|
||||
};
|
||||
```
|
||||
`LIST_ITEM_AGENT_SIZE: f32 = 22.` is sized so the icon footprint stays close to the existing `status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.` and row heights don't shift. `LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05` pushes the badge fully to the bounding-box corner, which reads better in this denser layout than the default centered overhang. The `None` branch keeps the surface future-proof; every current `ConversationOrTask` produces an agent variant today.
|
||||
The Agent Management View (`AgentManagementView::render_header_row`) is explicitly out of scope in this pass — keep its existing `render_status_element` call untouched. See `PRODUCT.md` § "Inline conversation list menu" for rationale.
|
||||
### 10. Thread `is_ambient` through `NotificationSourceAgent`
|
||||
Expand the enum to carry the flag so downstream rendering can honor it:
|
||||
```rust
|
||||
// app/src/ai/agent_management/notifications/item.rs
|
||||
pub enum NotificationSourceAgent {
|
||||
Oz { is_ambient: bool },
|
||||
CLI { agent: CLIAgent, is_ambient: bool },
|
||||
}
|
||||
```
|
||||
Both notification emit paths (`handle_cli_agent_session_event` and `handle_history_event_for_mailbox`) need `is_ambient` on the source. They also already need the git branch for the rich-layout header row, and that lookup walks the same workspace tree. Consolidate both into a single `TerminalViewMetadata { is_ambient, branch }` struct and a `TerminalViewMetadata::lookup(terminal_view_id, app)` helper that calls `find_terminal_view_by_id` once per notification, then reads `view.is_ambient_agent_session(app)` (the existing `TerminalView` helper, which gates on `FeatureFlag::CloudMode`) and `view.current_git_branch(app)`. `add_notification` takes the `branch: Option<String>` as a parameter so it doesn't perform a second lookup; callers thread the metadata through.
|
||||
Update `render_agent_avatar` in `item_rendering.rs` to read `is_ambient` from the enum variant rather than hardcoding `false`. The existing `IconWithStatusVariant::{OzAgent, CLIAgent}` code path already produces the cloud-lobe rendering when `is_ambient` is true; no changes to `render_icon_with_status` itself.
|
||||
The telemetry event `TelemetryEvent::AgentNotificationShown { agent_variant: agent.into() }` keeps its existing schema by dropping the ambient flag in the `From<NotificationSourceAgent>` impl.
|
||||
### 11. Telemetry / logs
|
||||
No new telemetry events. Existing `AgentNotificationShown` keeps firing with the agent variant. If schema allows, extend it with `is_ambient: bool` to enable future cloud-vs-local notification analytics; otherwise defer.
|
||||
### 12. Cross-surface equivalence tests
|
||||
The suite's job is to lock the invariant *"same logical run → identical icon on every surface"* and catch drift when a new surface or state combo is added. Three essential pieces, all in a new `app/src/ui_components/agent_icon_tests.rs`.
|
||||
#### 12.1 Canonical state fixture
|
||||
A test-only enum `CanonicalRunState` enumerates every conceptually distinct run (plain terminal; local Oz conversation; local CLI agent (plugin-backed in-progress / plugin-backed blocked / command-detected); cloud Oz in-progress; cloud third-party pre-dispatch and in-progress). It exposes:
|
||||
```rust
|
||||
impl CanonicalRunState {
|
||||
fn all() -> &'static [Self];
|
||||
fn terminal_inputs(&self) -> TerminalIconInputs;
|
||||
fn task_inputs(&self) -> Option<(Harness, ConversationStatus)>;
|
||||
fn expected(&self) -> Option<AgentIconFields>; // single canonical answer
|
||||
}
|
||||
```
|
||||
`expected` is spec-in-code — editing it requires editing `PRODUCT.md`. `AgentIconFields` is a `PartialEq` projection of `IconWithStatusVariant`'s agent-variant fields (`IconWithStatusVariant` itself can't derive `PartialEq` because `NeutralElement` carries a `Box<dyn Element>`).
|
||||
#### 12.2 Canonical-state equivalence table
|
||||
One parameterized test (`every_canonical_state_produces_consistent_icon_across_surfaces`) drives every canonical state through both the terminal-side helper (`agent_icon_variant_from_terminal_inputs`) and the task-side helper (`agent_icon_variant_for_task`) and asserts they project to the same `AgentIconFields`. Adding a surface = one more comparison; adding a state = one more enum variant + `expected` arm. A second test (`terminal_is_ambient_matches_inputs_for_every_state`) locks the structural invariant that `is_ambient` on the rendered variant always matches the input flag.
|
||||
#### 12.3 Spot tests
|
||||
- `cli_agent_from_harness_maps_known_harnesses` covers Oz/Claude/Gemini/OpenCode.
|
||||
- `local_claude_vs_cloud_claude_differ_only_by_is_ambient` asserts a locally-registered Claude CLI session and an ambient Claude run produce the same `CLIAgent { Claude, .. }` variant differing only by `is_ambient`.
|
||||
- `task_with_oz_or_unknown_harness_renders_as_oz` asserts both Oz and Unknown harnesses fall back to the Oz variant on task cards.
|
||||
- `summary_pane_kind_icons_distinguish_ambient_claude_from_local_claude` in `vertical_tabs_tests.rs` stays as-is. Existing `notifications/item_tests.rs` tests are extended with `is_ambient: true` and `false` cases for each variant.
|
||||
## End-to-end flow (Claude cloud run)
|
||||
1. User selects Claude in harness selector. `AmbientAgentViewModel::set_harness(Harness::Claude, ctx)` fires `HarnessSelected`.
|
||||
2. The `HarnessSelected` handler in `ambient_agent/view_impl.rs:262` emits `TerminalViewStateChanged` → pane group → workspace → `ctx.notify()`.
|
||||
3. Vertical tabs re-render. `terminal_view_agent_icon_variant` walks its waterfall: no CLI session yet (step 1/2 skipped), `is_third_party_harness()` → true with `selected_third_party_cli_agent() → Some(CLIAgent::Claude)`, `selected_conversation_status_for_display` → `None` (nothing in progress yet since Composing), so variant is `CLIAgent { Claude, None, is_ambient: true }`. Tab shows Claude-orange circle, cloud lobe (no status icon inside).
|
||||
4. User submits prompt. `DispatchedAgent` fires; view-model transitions to `WaitingForSession`; `is_in_cloud_agent_setup_phase` → true; `selected_conversation_status_for_display` → `Some(InProgress)`. Helper returns `CLIAgent { Claude, Some(InProgress), is_ambient: true }`. Tab shows Claude circle, cloud lobe with spinner inside.
|
||||
5. Pane header renders the same helper output, same visual.
|
||||
6. Conversation list card appears as soon as the `AmbientAgentTask` is registered. `conversation_or_task_agent_icon_variant` reads `agent_config_snapshot.harness` → "claude" → `Harness::Claude` → `CLIAgent::Claude`; `ConversationOrTask::status` → `InProgress`. Card shows same visual.
|
||||
7. Session ready; harness block starts; `CLIAgentSessionsModel::set_session(CLIAgent::Claude)` fires. Helper's step 1 takes over (plugin-backed session). Same brand + status, so no visual flip.
|
||||
8. Notification fires (e.g. blocked permission request). Emit path resolves `terminal_view.ambient_agent_view_model().is_ambient_agent() → true`; `NotificationSourceAgent::CLI { Claude, is_ambient: true }` goes into the notification. `render_agent_avatar` → `CLIAgent { Claude, Some(Blocked), is_ambient: true }`. Notification shows Claude cloud avatar.
|
||||
9. Run completes. Status propagates from `AmbientAgentTask.state` → `ConversationOrTask::status` → Success; card icon updates. `AmbientAgentViewModel` transitions to terminal state; `HarnessCommandStarted` already fired earlier, so `is_cloud_agent_pre_first_exchange` is false and the tab/pane-header helpers pick up the real conversation status.
|
||||
## Risks and mitigations
|
||||
**`NotificationSourceAgent` schema change is a breaking API for existing call sites.** Every construction site is in `agent_management_model.rs` (8 call sites between CLI events and history events). We'll update them all in the same patch. The helper function `find_terminal_view_by_id` keeps the lookup cost low (already paid once per notification for branch resolution).
|
||||
**Look-up races for is_ambient at notification emit time.** If the terminal view has been torn down before the notification fires (unlikely — notifications fire off in-flight events), the lookup returns `None` and we default `is_ambient: false`. This is safe; the notification still renders, just without the cloud lobe.
|
||||
**Pane header sizing regression.** The current indicator uses `appearance.ui_font_size()` (~12-14px) as the icon size; the new circle is 24-26px overall. The header row height is fixed by other elements (title text at 13-14px), so a larger circle is taller. We may need to adjust vertical alignment or cross-axis sizing. Validate during implementation; if the header grows, either reduce `PANE_HEADER_AGENT_SIZE` to match the font size or re-lay the header row to center the circle against the title.
|
||||
**Removing `render_ambient_agent_indicator` drops its tooltip.** The indicator currently shows a "Cloud agent run" tooltip on hover (pane_impl.rs:881-892). If retaining the tooltip is important, wrap the new circle in the same `Hoverable` and tooltip. Otherwise drop; the cloud lobe makes the ambient nature visually obvious.
|
||||
**Card icon replaces the plain status-only visual.** Cards that don't have an agent (none today, but defensively) fall through to the existing `render_status_element`. No visual regression expected.
|
||||
**Helper takes a full `TerminalView` reference.** This creates a transitive dependency from `ui_components/agent_icon.rs` back to `terminal::view::TerminalView`. Acceptable because `ui_components` already has terminal-aware code (`icon_with_status.rs` imports `CLIAgent` from `terminal`). Alternative: invert the dependency by making the helper a method on `TerminalView` in `pane_impl.rs`. Pick based on where the compile dependencies look cleanest; prefer the free function in `ui_components/agent_icon.rs` for testability.
|
||||
## Testing and validation
|
||||
Unit coverage:
|
||||
- Add `CLIAgent::from_harness` tests for Oz/Claude/Gemini inputs.
|
||||
- Extend `vertical_tabs_tests.rs` (or add `ui_components/agent_icon_tests.rs`) with the cross-surface equivalence suite described in §12.
|
||||
- Test `conversation_or_task_agent_icon_variant` with:
|
||||
- A `ConversationOrTask::Task` whose `agent_config_snapshot.harness` is `"claude"` → `CLIAgent::Claude` + `is_ambient: true`.
|
||||
- A `ConversationOrTask::Task` whose harness is `"oz"` or missing → `OzAgent` + `is_ambient: true`.
|
||||
- A `ConversationOrTask::Conversation` → `OzAgent` + `is_ambient: false`.
|
||||
- Test that `terminal_view_agent_icon_variant` returns `CLIAgent { Claude, ... is_ambient: true }` when the view-model's selected harness is Claude and no CLIAgent session exists yet.
|
||||
- Test idempotency of `NotificationSourceAgent` schema: old tests in `notifications/item_tests.rs` need updates for the new `is_ambient` field; add one new test case asserting the cloud-lobe path is taken when `is_ambient: true`.
|
||||
Integration / manual validation:
|
||||
- Full flows from the product spec's Validation section. Concretely: spawn a Claude cloud run; observe tab + pane header + card all render the same Claude circle + cloud lobe + status through Composing → Waiting → Running → Success.
|
||||
- Start a local `claude` CLI session and confirm tab + pane header render the Claude circle with a bottom-right badge (no cloud lobe).
|
||||
- Trigger a blocking permission request on a Claude cloud run; observe the notification in the mailbox shows the Claude cloud avatar.
|
||||
- Re-run the REMOTE-1454 validation cases to confirm no regression in the cloud setup UX.
|
||||
Invoke `verify-ui-change-in-cloud` after the implementation lands to spot-check the visual result across surfaces.
|
||||
## Follow-ups
|
||||
- Consider collapsing `NotificationSourceAgent` + `is_ambient` + status into a single `AgentIconDescriptor` struct shared by all four surfaces once a third or fourth call site appears (Option 3 in the design discussion). Not worth it at two call sites.
|
||||
- If the helper function pattern proves brittle at the trait-boundary for test fakes, consider introducing an `AgentIconSource` trait and formalizing the descriptor in a follow-up PR.
|
||||
- Telemetry: extend `AgentNotificationShown` with `is_ambient` once the schema is updated, to track cloud-notification volume.
|
||||
Reference in New Issue
Block a user