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
+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).