Files
galaxy/specs/QUALITY-702/TECH.md
T

151 lines
14 KiB
Markdown

# 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.