14 KiB
Local-to-Cloud Handoff: UI Polish — Tech Spec
Product spec: specs/REMOTE-1519/PRODUCT.md
Linear: REMOTE-1519
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:
- 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.
- 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_sessionjoins a fresh shared session for a follow-up cloud execution, it usesSharedSessionInitialLoadMode::AppendFollowupScrollback, which (a) deduplicates blocks by ID viaBlockList::append_followup_shared_session_scrollbackand (b) flipsshould_suppress_existing_agent_conversation_replay = true. That flag drivesBlocklistAIController::should_skip_replayed_response_for_existing_conversationto skip replayed response streams. We reuse this mechanism for the local→cloud first-session connect. - Fork-into-new-pane restoration.
BlocklistAIHistoryModel::fork_conversationmaterializes a forkedAIConversationlocally;restore_conversation_after_view_creationfeeds 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.
ForkConversationForHandoffin../warp-server-2/logic/ai_conversation_fork.goalready implements the server fork end-to-end (auth on source, GCS data copy, metadata insert). The viewer-sideBlocklistAIController::find_existing_conversation_by_server_tokenmaps aStreamInit.conversation_idto a localAIConversationby token; binding the local fork'sserver_conversation_tokento the server fork's id at chip-click time wires them up automatically when the cloud session arrives.
Diagram
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:
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_forkto theAIClienttrait, implemented asPOST agent/handoff/prepare-fork. Mirror the request/response shape pattern ofPrepareHandoffSnapshotRequest. - On
SpawnAgentRequest, replacefork_from_conversation_id: Option<String>withconversation_id: Option<String>(resume semantics). The client now always pre-mints the fork via the new endpoint and sends the resulting id underconversation_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:
- Resolve eligibility synchronously from the active session view's
BlocklistAIHistoryModel::active_conversation. If the conversation is missing, empty, or has noserver_conversation_token, surface the shared error toast and return without opening any pane. ctx.spawna future that callsAIClient::prepare_handoff_fork. The new pane is not split until this returns. On error, surface the same error toast; do not open a pane.- On success, on the main thread:
- Call
BlocklistAIHistoryModel::fork_conversation(&source_conversation, FORK_PREFIX, /* preserve_task_ids */ true, ctx)to materialize the local forkL'.preserve_task_ids: truekeeps the source's task ids so the cloud agent'sClientActions (which reference those task ids) resolve inL'. 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_conversationsoverwritesconversations_by_idwith 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 }.
- Call
- Kick off async background prep on the new pane:
derive_touched_workspace→upload_snapshot_for_handoff. When derivation completes, callset_pending_handoff_workspace. When the upload completes, callset_pending_handoff_snapshot_prepwithUploaded(token)/SkippedEmptyWorkspace/Failed(err)as appropriate. The pane is fully interactive throughout. The send button's gate (is_handoff_ready_to_submit) requirestouched_workspace.is_some(),snapshot_prepsettled (Uploaded or SkippedEmptyWorkspace), andsubmission_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_ids 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_idmatches an existing exchange'sserver_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.
ForkConversationForHandoffreturnsInvalidRequestErrorwhenBatchDoesConversationDataExistis 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_idfilter 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_submitblocks 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 forPrepareHandoffForkRequest/PrepareHandoffForkResponse.app/src/ai/blocklist/history_model_test.rs: test thatset_server_conversation_token_for_conversationafterfork_conversationupdates the token-to-conversation reverse index sofind_conversation_id_by_server_token(T_C)finds the fork. Plus a test exercisingpreserve_task_ids: trueto 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 aTestPrepareLocalHandoffForkHandler_*suite covering: feature-flag-off; missingsource_conversation_id; happy path; auth failure on the source.- Update existing
agent_webhooks_test.go::TestHandoff_*cases that exerciseForkFromConversationIDto instead drive the newprepare-forkendpoint and then sendConversationIDon 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.