119 lines
14 KiB
Markdown
119 lines
14 KiB
Markdown
# 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.
|