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
+43
View File
@@ -0,0 +1,43 @@
# Local-to-Cloud Handoff: UI Polish — Product Spec
Linear: [REMOTE-1519](https://linear.app/warpdotdev/issue/REMOTE-1519/make-ui-better-for-local-cloud-handoff)
## Summary
Polish the local-to-cloud handoff (REMOTE-1486) so that the cloud-mode pane that opens next to the local pane already shows the source conversation, and looks identical to a regular fresh cloud-mode run while the cloud agent is starting up. Today the user clicks the chip and is dropped into a blank pane that only fills in once the cloud agent's first turn streams in.
## Problem
Two related rough edges in the V0 handoff flow:
1. The new cloud-mode pane is empty between chip click and the cloud agent's first response. The user has lost their context — they have to remember what they handed off, or look at the local pane next to it.
2. The cloud-mode setup-v2 affordances (the "Running setup commands…" collapsible row that wraps the environment startup PTY output, the cloud-mode loading screen / queued-prompt indicator) work for fresh cloud-mode runs but render incorrectly during handoff. The handoff pane shows raw startup output instead of the polished setup-v2 surface.
## Goals
- The handoff pane is hydrated with the source conversation's AI exchanges immediately on chip click. The user sees the same conversation history they were just looking at, in the new pane, before they finish typing the follow-up.
- The cloud agent's shared-session replay (which rebroadcasts every exchange in the forked conversation) does not double-render content already on screen. Only genuinely new exchanges from the cloud agent appear after replay.
- The handoff pane uses the cloud-mode setup-v2 affordances during the loading phase, the same way a fresh cloud-mode run does: queued-prompt indicator, "Setting up environment" loading screen, "Running setup commands…" collapsible block wrapping the startup PTY output.
## Non-goals
- Bidirectional sync after handoff. The forked conversation diverges at chip-click; later edits in the local pane do not propagate to the cloud, and vice versa. Same posture as REMOTE-1486 V0.
- Restoring shell command blocks from the local pane into the new cloud pane. Only the conversation's AI exchanges are hydrated; terminal output that lived on the local terminal (e.g. unrelated commands run between agent turns) stays on the local pane.
- Cloud→cloud setup-v2 fixes. The cloud-cloud follow-up path (REMOTE-1290) may have similar gaps but is out of scope here; we'll only address local→cloud.
- A local "this conversation was handed off to <link>" breadcrumb on the source pane.
## Behavior
### Fork timing and hydration on chip click
1. Clicking the "Hand off to cloud" chip (or invoking `/oz-cloud-handoff`) immediately mints a server-side fork of the source conversation. The new conversation token is returned synchronously to the client.
2. The new cloud-mode pane opens next to the local pane and is pre-populated with the source conversation's AI exchanges, rendered with live (non-restored) appearance — visually indistinguishable from staying in the local pane.
3. The forked conversation appears in the user's history under their account, owned by them.
4. Subsequent edits in the local pane after chip click do **not** appear in the handoff pane. The cloud agent will work against the conversation as it was at chip-click time. Users who want a more recent snapshot must close the handoff pane and click the chip again.
### Eligibility and fallback
5. Per-conversation eligibility requires an active, non-empty conversation with a synced server token. When the active conversation isn't eligible, the chip surfaces an error toast in the local window and **does not open** any pane. The local conversation is unaffected and the user can retry once the source has synced.
6. If the server fork call fails for any reason (network, auth, source not synced to GCS), the new pane is **not** opened. The failure surfaces as the same error toast in the local window. The local conversation is unaffected and the user can retry by clicking the chip again.
### Cloud session replay and dedup
7. When the cloud agent's shared session connects to the handoff pane, the agent's conversation replay rebroadcasts every exchange in the forked conversation. Because we already pre-populated the same exchanges, the replay events are suppressed at the response-stream level, identical to how cloud→cloud follow-up sessions handle stale replay (REMOTE-1290).
8. After the replay completes, genuinely new exchanges (the cloud agent's first response to the user's follow-up prompt) are appended normally. The user sees a smooth transition from "frozen pre-handoff state" to "cloud agent answering my follow-up prompt".
### Setup-v2 affordances during loading
9. After the user submits, the handoff pane shows the same cloud-mode setup-v2 affordances a fresh cloud-mode run shows:
- The submitted prompt as a queued user-query indicator (REMOTE-1454 visual treatment, no Send-now / dismiss buttons).
- The "Setting up environment" loading screen during the pre-session phase.
- The "Running setup commands…" collapsible row that wraps environment startup PTY output once the shared session connects.
10. When the cloud agent's first turn arrives, the queued-prompt indicator and the setup-v2 affordances tear down on the same transitions a fresh cloud-mode run uses (`AppendedExchange` for Oz, `HarnessCommandStarted` for non-Oz).
### Edge cases
11. If the user closes the handoff pane between chip click and submit, the server-side fork is orphaned (visible in the user's conversation history but never run against). V0 does not clean these up.
12. If the user clicks the chip twice on the same source conversation, two independent forks are minted — same as today's REMOTE-1486 chip behavior; nothing changes here.
13. The local pane is unaffected throughout: its conversation is not duplicated, archived, or annotated. The user can keep typing in the local pane.
## Success criteria
- Clicking the chip on a long conversation produces a fully populated handoff pane within ~300ms (network-dependent on the fork RPC), without flicker.
- The user never sees duplicate exchange blocks during the cloud agent's session connect / replay phase.
- The handoff pane's loading-phase UI is byte-for-byte identical to a fresh cloud-mode run's (modulo the pre-populated exchanges above the queued-prompt indicator).
+118
View File
@@ -0,0 +1,118 @@
# Local-to-Cloud Handoff: UI Polish — Tech Spec
Product spec: `specs/REMOTE-1519/PRODUCT.md`
Linear: [REMOTE-1519](https://linear.app/warpdotdev/issue/REMOTE-1519/make-ui-better-for-local-cloud-handoff)
## Context
REMOTE-1486 shipped V0 of the local-to-cloud handoff: a chip in the agent input footer (or `/oz-cloud-handoff`) opens a fresh cloud-mode pane next to the local pane; on submit the client snapshots the workspace and spawns a cloud agent forked from the local conversation.
That V0 has two rough edges this spec addresses:
1. **No hydration of the source conversation in the new pane.** The fork is materialized server-side at submit time only. Until the cloud agent's shared session connects and replays the conversation transcript, the new pane is blank. The cloud session's replay then re-broadcasts every exchange the user already saw in the local pane.
2. **Setup-v2 affordances render incorrectly.** A fresh cloud-mode pane shows a "Running setup commands…" collapsible row, a queued-prompt indicator, and a loading screen during the pre-session window (gated by `FeatureFlag::CloudModeSetupV2`). Handoff panes today don't surface those affordances; the environment startup PTY output renders raw instead.
The pieces this spec builds on:
- **Cloud-cloud handoff replay suppression.** When `attach_followup_session` joins a fresh shared session for a follow-up cloud execution, it uses `SharedSessionInitialLoadMode::AppendFollowupScrollback`, which (a) deduplicates blocks by ID via `BlockList::append_followup_shared_session_scrollback` and (b) flips `should_suppress_existing_agent_conversation_replay = true`. That flag drives `BlocklistAIController::should_skip_replayed_response_for_existing_conversation` to skip replayed response streams. We reuse this mechanism for the local→cloud first-session connect.
- **Fork-into-new-pane restoration.** `BlocklistAIHistoryModel::fork_conversation` materializes a forked `AIConversation` locally; `restore_conversation_after_view_creation` feeds it into a freshly-created pane and restores AI blocks for every exchange with live (non-restored) appearance.
- **Server-side fork and conversation-token binding.** `ForkConversationForHandoff` in `../warp-server-2/logic/ai_conversation_fork.go` already implements the server fork end-to-end (auth on source, GCS data copy, metadata insert). The viewer-side `BlocklistAIController::find_existing_conversation_by_server_token` maps a `StreamInit.conversation_id` to a local `AIConversation` by token; binding the local fork's `server_conversation_token` to the server fork's id at chip-click time wires them up automatically when the cloud session arrives.
## Diagram
```mermaid
sequenceDiagram
participant U as User
participant C as Local Warp Client
participant LP as Local Pane
participant HP as Handoff Pane (new)
participant API as warp-server (public API)
participant Sand as Cloud Sandbox
U->>C: Click "Hand off to cloud" chip on local pane
C->>API: POST /agent/handoff/prepare-fork {source_conversation_id}
API->>API: ForkConversationForHandoff (auth, copy GCS, insert metadata)
API-->>C: {forked_conversation_id: T_C}
Note over C: On error here: error toast, no pane opens
C->>C: BlocklistAIHistoryModel::fork_conversation (local fork L', bind T_C)
C->>HP: split fresh cloud-mode pane next to LP
C->>HP: restore_conversation_after_view_creation(L')
Note over HP: Pre-populated with source's AI exchanges
par Background prep (kicked off after pane opens)
C->>C: derive_touched_workspace (walks conversation, git remotes)
C->>API: POST /agent/handoff/prepare-snapshot
API-->>C: {prep_token, upload_urls}
C->>API: PUT snapshot files (parallel)
end
U->>HP: Type follow-up prompt, submit
Note over HP: Send blocked until snapshot upload settles
C->>API: POST /agent/runs {conversation_id: T_C, handoff_snapshot_token, prompt, config}
API-->>C: {task_id, run_id}
Note over HP: Setup-v2 affordances render: queued prompt, loading screen
Sand->>Sand: bootstrap, run setup commands (PTY → active block, hidden)
Sand-->>HP: shared session ready
HP->>HP: connect_to_session with AppendFollowupScrollback
Note over HP: should_suppress_existing_agent_conversation_replay = true
Sand-->>HP: replay forked conversation transcript
Note over HP: Replay events skipped (request_id matches existing exchange)
Sand-->>HP: cloud agent's first turn (rehydration prompt + user follow-up + response)
HP->>HP: AppendedExchange clears setup-v2 flag, queued-prompt block
Note over LP: Local pane unchanged throughout
```
## Proposed changes
### 1. Server-side: split fork from spawn (`../warp-server-2`)
Forking on chip click (vs at submit time) freezes the cloud's view at the moment the user opted into the handoff and lets the two conversations evolve independently.
**New endpoint** `POST /api/v1/agent/handoff/prepare-fork`:
```go path=null start=null
type PrepareLocalHandoffForkRequest struct {
SourceConversationID string `json:"source_conversation_id" binding:"required"`
}
type PrepareLocalHandoffForkResponse struct {
ForkedConversationID string `json:"forked_conversation_id"`
}
```
Add the handler alongside `PrepareLocalHandoffSnapshotHandler` in `router/handlers/public_api/agent_handoff.go`. It gates on `features.LocalToCloudHandoffEnabled()`, resolves the principal, and calls `logic.ForkConversationForHandoff`. Wire the route under the same `aiCheckedGroup` as the existing snapshot prep endpoint.
**Remove `ForkFromConversationID` from `RunAgentRequest`.** The field, validation, and inline fork call all go. The existing `ConversationID *string` field continues to drive `task.AgentConversationID` (resume semantics) — the client now points it at the pre-minted fork id.
**`HandoffSnapshotToken` stays.** Snapshot prep + upload still flow through `prepare-snapshot` and `attachHandoffSnapshotToTask` post-task-creation; only the timing of when the client triggers them moves (now async on chip click instead of submit time — see §3).
### 2. Client-side API surface (`app/src/server/server_api/ai.rs`)
- Add `prepare_handoff_fork` to the `AIClient` trait, implemented as `POST agent/handoff/prepare-fork`. Mirror the request/response shape pattern of `PrepareHandoffSnapshotRequest`.
- On `SpawnAgentRequest`, replace `fork_from_conversation_id: Option<String>` with `conversation_id: Option<String>` (resume semantics). The client now always pre-mints the fork via the new endpoint and sends the resulting id under `conversation_id`.
### 3. Client-side fork-on-chip-click (`app/src/workspace/view.rs`)
`Workspace::start_local_to_cloud_handoff` becomes a strict-ordering open path:
1. Resolve eligibility synchronously from the active session view's `BlocklistAIHistoryModel::active_conversation`. If the conversation is missing, empty, or has no `server_conversation_token`, surface the shared error toast and return without opening any pane.
2. `ctx.spawn` a future that calls `AIClient::prepare_handoff_fork`. The new pane is **not** split until this returns. On error, surface the same error toast; do not open a pane.
3. On success, on the main thread:
- Call `BlocklistAIHistoryModel::fork_conversation(&source_conversation, FORK_PREFIX, /* preserve_task_ids */ true, ctx)` to materialize the local fork `L'`. `preserve_task_ids: true` keeps the source's task ids so the cloud agent's `ClientAction`s (which reference those task ids) resolve in `L'`.
- `pane_group.add_ambient_agent_pane(ctx)` to split the new pane.
- Pre-fill the prompt input if the slash command supplied one.
- `terminal_view.restore_conversation_after_view_creation(RestoredAIConversation::new(L'.clone()), /* use_live_appearance */ true, ctx)` so the AI exchanges render immediately.
- `BlocklistAIHistoryModel::set_server_conversation_token_for_conversation(local_fork_id, T_C)`. Must run **after** restore: `restore_conversations` overwrites `conversations_by_id` with the token-less clone we passed in, so binding earlier would be lost.
- Seed `PendingHandoff { forked_conversation_id: T_C, touched_workspace: None, snapshot_prep: Pending, submission_state: Idle }`.
4. Kick off async background prep on the new pane: `derive_touched_workspace` → `upload_snapshot_for_handoff`. When derivation completes, call `set_pending_handoff_workspace`. When the upload completes, call `set_pending_handoff_snapshot_prep` with `Uploaded(token)` / `SkippedEmptyWorkspace` / `Failed(err)` as appropriate. The pane is fully interactive throughout.
The send button's gate (`is_handoff_ready_to_submit`) requires `touched_workspace.is_some()`, `snapshot_prep` settled (Uploaded or SkippedEmptyWorkspace), and `submission_state == Idle`. If submit fires while any precondition is unmet, the input layer surfaces a "Preparing handoff" toast and leaves the prompt + attachments intact.
### 4. Submit path uses resume semantics (`app/src/terminal/view/ambient_agent/model.rs`)
With the fork and the snapshot upload both completed during the chip-click open path, `AmbientAgentViewModel::submit_handoff` is a thin shim over `spawn_agent_with_request` that reads cached `forked_conversation_id` and `snapshot_prep` directly off `pending_handoff`. The orchestrator that REMOTE-1486 used is deleted.
### 5. Replay-suppressing initial connect (`app/src/terminal/shared_session/viewer/terminal_manager.rs`)
`TerminalManager::connect_to_session` gains an `append_followup_scrollback: bool` flag. The cloud-mode subscription in `app/src/terminal/view/ambient_agent/mod.rs` passes `view_model.is_local_to_cloud_handoff()` so handoff panes use `AppendFollowupScrollback` instead of the default `ReplaceFromSessionScrollback`.
The append mode handles both pieces of dedup: `BlockList::append_followup_shared_session_scrollback` skips block IDs we already have, and `should_suppress_existing_agent_conversation_replay = true` drives the response-stream filter described in §6.
### 6. Replay-stream filter keys on `request_id` (`app/src/ai/blocklist/controller/shared_session.rs`)
The cloud agent's replay rebroadcasts every exchange in the forked conversation, including ones we've already pre-populated. We need to skip the replayed response streams without skipping the cloud agent's genuinely new turns (which arrive on the same connection after replay finishes).
`should_skip_replayed_response_for_existing_conversation` (called from `on_shared_init`) compares `init_event.request_id` against the `server_output_id`s of the local fork's existing exchanges. The stream is skipped iff:
- the model is in replay mode (`is_receiving_agent_conversation_replay && should_suppress_existing_agent_conversation_replay`), AND
- the incoming `request_id` matches an existing exchange's `server_output_id`.
Replay events for already-known exchanges are dropped; new turns the cloud agent appends after the local fork (e.g. the user's first submitted prompt) carry request_ids we have never seen and flow through normally.
### 7. Feature-flag posture
No new feature flags. All changes are gated on the existing `FeatureFlag::OzHandoff && FeatureFlag::LocalToCloudHandoff` (client) and `features.LocalToCloudHandoffEnabled()` (server) used by REMOTE-1486.
## Risks and mitigations
- **Chip-click latency is now gated on the prepare-fork RPC.** Previously the pane opened instantly; now the user sees nothing until the fork resolves. The fork is a synchronous metadata + GCS-copy round-trip already used at submit time today; expected latency is similar to other authenticated public-API RPCs (<300ms p50). On error we surface a toast immediately.
- **Source conversation not synced to GCS.** `ForkConversationForHandoff` returns `InvalidRequestError` when `BatchDoesConversationDataExist` is false. The client surfaces this as the toast above; the user can wait a moment and click again.
- **Replay suppression skips a genuinely new exchange.** The `request_id` filter scopes skipping to specific known exchanges, so new turns flow through even during the replay window.
- **Snapshot upload still in flight at submit time.** `is_handoff_ready_to_submit` blocks submit until upload settles. The user sees a "Preparing handoff" toast and their prompt + attachments are preserved.
## Testing and validation
### Unit tests
- `app/src/server/server_api/ai_test.rs`: serialization/deserialization tests for `PrepareHandoffForkRequest` / `PrepareHandoffForkResponse`.
- `app/src/ai/blocklist/history_model_test.rs`: test that `set_server_conversation_token_for_conversation` after `fork_conversation` updates the token-to-conversation reverse index so `find_conversation_id_by_server_token(T_C)` finds the fork. Plus a test exercising `preserve_task_ids: true` to confirm task ids are preserved across the fork.
- `app/src/terminal/shared_session/viewer/event_loop_test.rs`: extend the existing append-mode tests to cover the local→cloud connect path.
### Server tests (`../warp-server-2`)
- `router/handlers/public_api/agent_handoff_test.go`: add a `TestPrepareLocalHandoffForkHandler_*` suite covering: feature-flag-off; missing `source_conversation_id`; happy path; auth failure on the source.
- Update existing `agent_webhooks_test.go::TestHandoff_*` cases that exercise `ForkFromConversationID` to instead drive the new `prepare-fork` endpoint and then send `ConversationID` on the run request.
### Integration / manual
- Click the chip on a long Oz conversation; verify the new pane is visibly populated with the AI exchanges before the cloud session connects, with no flicker or duplicate blocks during the connect/replay window.
- Submit a follow-up; verify the queued-prompt indicator + "Setting up environment" loading screen + "Running setup commands…" collapsible block all render the same way they do for a fresh cloud-mode run.
- After the cloud agent's first turn arrives, verify the pre-populated blocks remain in place, the queued-prompt indicator clears, and the new exchange appends below them.
- Click the chip on a non-eligible conversation (no synced server token); verify **no pane opens** and an error toast surfaces in the local window.
- Manually break a network connection during chip click so the prepare-fork RPC fails; verify **no pane opens** and an error toast surfaces in the local window.
## Parallelization
The two-side change is small enough that one engineer/agent can implement it sequentially in two PRs — a server PR for the prepare-fork endpoint + `ForkFromConversationID` removal, then a client PR for the hydration + load mode + replay-stream filter. No sub-agents needed.
## Follow-ups
- Cloud→cloud setup-v2 polish (REMOTE-1290) — out of scope here.