27 KiB
Local-to-Cloud Handoff: & Entrypoint — Tech Spec
Product spec: specs/REMOTE-1558/PRODUCT.md
Linear: REMOTE-1558
Context
REMOTE-1558 adds a keyboard-first local-to-cloud launch path: local AgentView users type & query, optionally choose a cloud environment from the local footer, and press Enter once. The same auto-run path is also exposed through /handoff query; no-query /handoff and chip flows activate & handoff-compose mode.
Current code paths this builds on:
app/src/terminal/input.rs:495defines the existing!shell-mode prefix. The typed-only prefix stripping and lock behavior live inapp/src/terminal/input.rs (9087-9200), empty-state escape/backspace handling lives inapp/src/terminal/input.rs (9686-9824), and the visible!indicator is rendered bymaybe_render_ai_input_indicatorsinapp/src/terminal/input.rs (14701-14758).InputTypeis the semantic/classification mode for a buffer:ShellorAI. It is imported frominput_classifierand maps directly into the session-sharing protocol'sShell/AIinput mode inapp/src/ai/blocklist/input_model.rs (114-121). Do not add aCloudHandoffvariant toInputType;&handoff compose is still an AI prompt semantically, with different destination and submit behavior.!is not stored as a standalone prefix enum today; it is represented byBlocklistAIInputModelbeing locked toInputType::Shell.&must therefore add explicit guards around the existing shell-lock transitions rather than assuming the two modes are naturally exclusive.app/src/terminal/input.rs (11926-12211)is the cloud-mode submit path. It collects pending image/file attachments intoAttachmentInput, clears the editor and pending attachments, then calls eitherAmbientAgentViewModel::spawn_agentorsubmit_handoff. It does not serialize selected text/block/document context;spawn_agentandsubmit_handoffboth setreferenced_attachments: vec![]inapp/src/terminal/view/ambient_agent/model.rs (707-786)andapp/src/terminal/view/ambient_agent/model.rs (1203-1258)./handoffis registered with an optional argument inapp/src/search/slash_command_menu/static_commands/commands.rs:174. Slash parsing preserves the text after the first space inapp/src/terminal/input/slash_command_model.rs:416, and the handler dispatchesWorkspaceAction::OpenLocalToCloudHandoffPanefor/handoff queryand activates&handoff-compose mode for/handoffwith no query.WorkspaceAction::OpenLocalToCloudHandoffPaneis defined inapp/src/workspace/action.rs:489and handled inapp/src/workspace/view.rs:20345.Workspace::start_local_to_cloud_handoffinapp/src/workspace/view.rs (12894-12965)currently requires an active non-empty conversation with aserver_conversation_token; otherwise it toasts and opens no pane.complete_local_to_cloud_handoff_openinapp/src/workspace/view.rs (12967-13155)materializes a local fork, pushes the cloud-mode pane, pre-fills an optional prompt, restores the forked conversation, binds the fork token, exits the source agent view, seedsPendingHandoff, and starts touched-workspace derivation plus snapshot upload.PendingHandoffand handoff readiness live onAmbientAgentViewModelinapp/src/terminal/view/ambient_agent/model.rs (78-138)andapp/src/terminal/view/ambient_agent/model.rs (397-509).submit_handoffbuilds a normalSpawnAgentRequestwithconversation_idset to the forked server conversation id andinitial_snapshot_tokenset from the prepared upload.EnvironmentSelectorinapp/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs (129-480)is currently hard-bound toModelHandle<AmbientAgentViewModel>. It persists explicit selections toCloudAgentSettings::last_selected_environment_idand only enables while the ambient model is composing. The footer renders it only for ambient cloud panes inapp/src/ai/blocklist/agent_view/agent_input_footer/mod.rs (2014-2039).AgentMessageBarinapp/src/ai/blocklist/agent_view/agent_message_bar.rsalready owns the shell-mode "backspace to exit shell mode" affordance throughExitBashModeMessageProducer; the cloud-prefix affordance should be added there rather than as unrelated input chrome.- Agent/AI magenta is available through existing AI color helpers such as
ai_brand_colorinapp/src/ai/blocklist/view_util.rs, and should be used for the&indicator and cloud-mode exit affordance instead of shell-mode blue. Input::is_cloud_mode_input_v2_composingalready excludes local-to-cloud handoff panes inapp/src/terminal/input/agent.rs:65, so the new handoff compose path should stay on the existing AgentView input UI.
Proposed changes
1. Launch payload types
Two types in app/src/ai/blocklist/handoff/mod.rs carry the handoff payload from the source input through the workspace action into the cloud pane:
HandoffLaunchAttachments— the pre-readVec<AttachmentInput>forSpawnAgentRequest, plus aVec<PendingAttachment>display/restoration snapshot so failure paths can repopulate the source input.PendingCloudLaunch—prompt: Stringandattachments: HandoffLaunchAttachments. Reused by the action variant and byPendingHandoff.auto_submit. The workspace action carries these directly — no wrapper struct or request-id coordination. The source input eagerly clears its compose state on dispatch and workspace restores it on failure (see §4):
OpenLocalToCloudHandoffPane {
launch: Option<PendingCloudLaunch>,
explicit_environment_id: Option<SyncId>,
}
Do not add selected text/block/document serialization in this spec. & and /handoff query should match the current cloud-mode submit behavior: prompt plus pending image/file attachments.
2. Add input-local prefix state for & and !
Do not model & by adding a third InputType. InputType should remain a two-way semantic classification (Shell or AI) used by autodetection, slash-command gating, UDI controls, and session-sharing serialization. A cloud-handoff draft should continue to report as AI input anywhere that asks whether the buffer is an AI prompt.
Add an Input-local prefix-mode layer instead:
enum InputPrefixMode {
None,
Shell,
CloudHandoff,
}
InputPrefixMode::Shell should be derived from the existing locked-shell state, not separately stored. InputPrefixMode::CloudHandoff should be backed by an Input-owned ModelHandle<HandoffComposeState> rather than extending BlocklistAIInputModel. This keeps normal AI-vs-shell classification independent from the handoff launch draft while still giving prefix rendering and keyboard handling one mutually exclusive parent enum.
HandoffComposeState should track:
- whether handoff compose mode is active;
- the selected environment id, if any;
- whether the environment was explicitly selected by the user;
Activation should mirror the
!path while defending mutual exclusion:
- In
handle_editor_event, recognize&only onEditOrigin::UserTyped, only when it is the first character of an otherwise empty buffer, and only when the current input is the fullscreen local AgentView input. Do not activate in terminal mode, cloud-mode panes, CLI-agent rich input, pasted input, buffers with leading whitespace, or locked shell mode. - If the visible
!shell-mode indicator is active, typed&is normal shell text; the user must exit shell mode before entering handoff compose mode. - Strip the literal
&from the editor buffer, setHandoffComposeState.active = true, set the input config directly toInputConfig { input_type: InputType::AI, is_locked: true }, keep focus in the editor, and notify the footer. Do not callunlocked_if_autodetection_enabledfor this transition; cloud-prefix mode is intentionally locked AI until exit. - While
HandoffComposeState.activeis true, skip the existingTERMINAL_INPUT_PREFIXactivation branch and any autodetection unlock path. Typed!is prompt text in the cloud-launch draft; it must not lock the input to shell mode until the user exits&mode. - Any other path that locks the input to shell mode while
&is active, such as explicit terminal-mode actions orDeleteAllLeft, should callexit_handoff_compose_modebefore applying the shell lock. Conversely,&activation should no-op if the shell lock is already active. This makesHandoffComposeState.activeand locked-shell input a defended invariant rather than a rendering convention. - Add a helper like
current_prefix_mode(ctx) -> InputPrefixModeand route activation, rendering, hint text, Escape/Backspace, Enter behavior, and tests through it. This helper should deriveShellfrom lockedInputType::Shell, deriveCloudHandofffromHandoffComposeState.active, and never let both be true from a caller's perspective. - Render
&throughmaybe_render_ai_input_indicatorsas a sibling of the existing!indicator path. Defensively prefer&only ifHandoffComposeState.active; otherwise render!from locked-shell state. The&indicator should use Agent/AI magenta (ai_brand_coloror the equivalent theme magenta), notansi_fg_blue. Tests should assert both indicators cannot render together. - In
set_zero_state_hint_text, use the handoff hint only whenHandoffComposeState.activeand the editor buffer is empty. Keep this hint focused on what the prompt will do; the Backspace exit copy belongs to the message bar. - Thread the
HandoffComposeStatehandle into the Agent View message bar path by passing it throughBlocklistAIStatusBar::newandAgentMessageBar::new, or by passing a small derived prefix-mode provider if that keeps constructor churn lower. Add anExitCloudHandoffModeMessageProducerparallel toExitBashModeMessageProducerthat renders Enter + "to hand off to cloud" and Backspace + "to dismiss". Both labels use Agent/AI magenta when the editor is empty; when prompt text is present, the Backspace label follows the shell-mode muted/disabled behavior while the Enter label stays active-colored. - On
BackspaceOnEmptyBuffer/BackspaceAtBeginningOfBufferor Escape, call a singleexit_handoff_compose_modehelper that clears the handoff state, restores normal unlocked AI/autodetection behavior for future prompts, but preserves the prompt for Escape. Normal editing that empties the buffer does not exit the mode — the user must backspace on an already-empty buffer to exit, matching the!shell-mode two-step exit. Programmatic clears (e.g.clear_buffer_and_reset_undo_stack) also call the exit helper. On Enter with a non-empty handoff prompt, build aPendingCloudLaunchfrom the current buffer, pre-read cloud-mode-supported image/file attachments using the same logic asapp/src/terminal/input.rs (11926-12131), read the explicit environment id fromHandoffComposeState, then eagerly exit compose mode and clear the source buffer and pending attachments. DispatchOpenLocalToCloudHandoffPane { launch, explicit_environment_id }. If workspace fails to open the target pane, it callsInput::restore_cloud_handoff_draftto re-activate compose mode, repopulate the buffer with the prompt, and restore the display attachments and environment selection.
3. Generalize EnvironmentSelector
Refactor EnvironmentSelector so it binds to a small environment-selection target interface instead of directly storing ModelHandle<AmbientAgentViewModel>.
The target interface needs:
selected_environment_id(ctx) -> Option<SyncId>;set_environment_id(environment_id, is_explicit, ctx)— theis_explicitflag tracks whether the user made a deliberate selection (preventing touched-repo overlap from overwriting it later);ensure_default_environment_id(environment_id, ctx)— sets a default only when no selection exists yet, and on theCloudPanetarget also respects explicit environment locks from a pending handoff;is_configuring(ctx) -> bool;- a change event or subscription hook for refreshing menu/button state.
Implement the target for
AmbientAgentViewModeland forHandoffComposeState. Preserve existing selector behavior: selecting an environment from either target persists toCloudAgentSettings::last_selected_environment_id, opens the environment-management pane through the existing footer event, and uses the same label/menu/disabled states. UpdateAgentInputFooterso it can receive the handoff state handle in addition to the optional ambient model. Render the selector in the left footer area when either: - an ambient cloud pane is composing, preserving current behavior; or
HandoffComposeState.activeis true, producing the transient&selector. The transient selector should not be part of the configurable toolbar item list; it is mode chrome like the&input indicator.
4. Refactor workspace entrypoints with eager-clear and restore-on-failure
The source input eagerly exits compose mode and clears its buffer/attachments before dispatching. The footer chip and /handoff with no query activate & handoff-compose mode directly. /handoff query builds a PendingCloudLaunch, clears the buffer normally with other slash commands, and dispatches. & query builds a PendingCloudLaunch, eagerly exits compose mode, and dispatches.
Workspace classifies the active source:
- No active conversation or an empty active conversation: fresh cloud run/compose.
- Non-empty conversation whose status is running or blocked: restore source input, toast.
- Non-empty idle conversation without
server_conversation_token: restore source input, toast. - Non-empty idle conversation with
server_conversation_token: local-to-cloud handoff. On any failure before the target pane is successfully opened (validation, fork RPC, pane creation), workspace callsInput::restore_cloud_handoff_draft(launch, explicit_environment_id)on the source view to re-activate compose mode, repopulate the buffer with the prompt, and restore display attachments and environment selection. For the async fork path (start_local_to_cloud_handoff), the fork RPC callback's error branch callsrestore_cloud_handoff_draftbefore showing the toast. The success branch proceeds tocomplete_local_to_cloud_handoff_openas today.
5. Fresh cloud run/compose path for empty conversations
Add a helper on Workspace or TerminalView that starts normal cloud mode from an OpenLocalToCloudHandoffPane dispatch.
When launch is None, reuse the existing cloud-mode open path. When launch is Some, create the normal cloud-mode pane, apply explicit_environment_id to its AmbientAgentViewModel before building the spawn config, then call spawn_agent with the collected prompt and attachments. This avoids simulating editor input and keeps fresh-cloud auto-run aligned with the model-level submit API.
If no environment was explicitly selected, rely on the existing selector/defaulting behavior. If no environment exists, let build_default_spawn_config send environment_id: None, matching current cloud-mode behavior.
6. Local-to-cloud auto-submit
Extend PendingHandoff with:
auto_submit: Option<PendingCloudLaunch>containing prompt and attachments;explicit_environment_id: Option<SyncId>or an equivalent environment-source marker.- a submission phase that can distinguish idle manual compose, optimistically queued auto-submit, and active server dispatch. This can be a field on
PendingHandoff, a small enum onAmbientAgentViewModel, or an equivalent representation, but callers need to know whether the user's prompt is hidden because Warp has queued it.PendingCloudLaunchshould carry both: - the spawn-ready prompt and
AttachmentInputs; and - a restoration draft containing the prompt plus pending image/file attachment display state for retry.
When
complete_local_to_cloud_handoff_opencreates the handoff pane:
- Apply
explicit_environment_idto the pane model immediately when present. - For
Compose, prefill the handoff pane input and install the pending image/file attachment snapshot as today. - For
AutoSubmit, do not hydrate the submitted prompt or pending attachment display into the destination editor before queueing. Instead, seedPendingHandoffwith the spawn payload and restoration draft, and move the pane into the queued/starting visual state immediately. If preparation later fails before the server accepts the run, restore that saved draft into the destination editor for manual retry. - Source input was already cleared eagerly at dispatch time, so no claim step is needed.
- Start touched-workspace derivation and snapshot upload as today.
When touched-workspace derivation finishes, keep the current overlap selection behavior only if there was no explicit environment id. This enforces the product priority: explicit
&selection, then touched-repo overlap, then default. Add a model method such asqueue_auto_submit_handoff(ctx)and call it immediately after seedingPendingHandofffor anAutoSubmitlaunch. It should:
- set the submission phase to optimistically queued;
- put the cloud pane in the same queued/starting visual family used before the first cloud response, so users do not see an editable prompt sitting in an unqueued pane;
- retain the restoration draft internally until the server has accepted the run;
- not call the server yet if touched-workspace derivation or snapshot upload is still pending.
Move the readiness check into the model by adding or updating
maybe_auto_submit_handoff(ctx). Call it after eachPendingHandoffmutation that can make readiness true. It should: - require an optimistically queued auto-submit payload;
- require
is_handoff_ready_to_submit(); - take the spawn payload exactly once;
- transition from queued/preparing to active dispatch;
- call
submit_handoff(prompt, attachments.spawn_inputs, ctx). If any step after the destination pane owns the launch but before the server accepts the run fails, restore the restoration draft into the handoff pane input, restore the pending image/file attachment display state, reset the submission phase to manual compose/retry, and show the existing failure toast. This includes snapshot upload failure and immediate spawn request failure. A subsequent manual Enter should retry snapshot upload for the same touched workspace before callingsubmit_handoff; otherwise the product's retryable failure state is not real. Once the server accepts the cloud run, discard the restoration draft. Later cloud-run failures should follow normal cloud-agent failure behavior rather than repopulating the input.
7. Slash command clearing semantics
/handoff query builds a PendingCloudLaunch and dispatches the workspace action. The normal slash-command buffer clear proceeds — no deferred-clear flag is needed since workspace restores the source input on failure via restore_cloud_handoff_draft. /handoff with no query activates & compose mode and lets the normal slash-command buffer clear proceed (the buffer is cleared to make room for the compose draft).
8. End-to-end flow
sequenceDiagram
participant U as User
participant I as Source Input
participant W as Workspace
participant HP as Handoff Pane
participant M as AmbientAgentViewModel
participant API as Server API
U->>I: Type "& fix tests"
I->>I: Strip &, enter HandoffComposeState
U->>I: Optionally pick environment
U->>I: Enter
I->>I: Eagerly exit compose mode, clear buffer/attachments
I->>W: OpenLocalToCloudHandoffPane { launch, explicit_environment_id }
W->>W: Classify source conversation
alt empty source
W->>HP: Open normal cloud pane
W->>M: Apply explicit env, spawn_agent
else eligible non-empty source
W->>API: fork_conversation(source token)
API-->>W: forked_conversation_id
W->>HP: Push handoff pane, restore local fork
W->>M: set PendingHandoff(auto_submit + restore draft)
Note over HP: Auto-submit draft is not hydrated into editable input
W->>M: queue auto-submit, show starting
par background prep
W->>M: set touched workspace
W->>M: set snapshot upload result
end
M->>M: maybe_auto_submit_handoff when ready
M->>API: POST /agent/runs with conversation_id + snapshot token
alt prep or immediate spawn failure before server accepts run
M->>HP: Restore prompt/attachments for manual retry
end
else blocked or ineligible
W->>I: restore_cloud_handoff_draft(launch, env)
W-->>U: Toast
end
Risks and mitigations
- Input clears before workspace opens the target pane. Source input eagerly clears on dispatch; workspace calls
restore_cloud_handoff_drafton every synchronous failure path to repopulate the prompt, display attachments, and environment selection. The async fork path restores in the error callback. Tests should cover both&and/handoff queryfailure restoration. - Environment overlap overwrites explicit user choice. Store explicit environment state on
PendingHandoffand skippick_handoff_overlap_envwhen it is present. - Auto-submit queueing hides the user's prompt before dispatch. Carry both spawn-ready
AttachmentInputs and a restoration snapshot. Keep the restoration draft until the server accepts the run, and restore it on any pre-acceptance failure. - Snapshot failure remains unretryable. Current handoff readiness blocks submit after
SnapshotUploadStatus::Failed; this feature should add a manual retry branch so the failure pane can recover. - Selected text/block/document context mismatch. Current cloud-mode submit does not serialize this context. This spec deliberately avoids inventing a one-off path for
&; general cloud selected-context support should be added once and reused by normal cloud mode,&, and/handoff query.
Testing and validation
Unit tests
app/src/terminal/input_test.rs: add&prefix tests parallel torun_input_mode_prefix_testfor typed-only activation, paste/system insert non-activation, first-character-only activation, stripping, indicator state, Backspace empty-state exit (two-step: editing to empty stays in mode, backspace on empty exits), Escape preserving prompt text, and input-mode toggle being disabled while in handoff compose. Cover local fullscreen AgentView only, and assert terminal mode, cloud-mode panes, and CLI-agent rich input do not activate.app/src/terminal/input_test.rs: verify&activation locks the input as AI even when autodetection is enabled, typed!remains prompt text while&mode is active, and exiting&restores normal AI/autodetection behavior for future prompts.app/src/terminal/input_test.rsor view-rendering coverage formaybe_render_ai_input_indicators: verify the&indicator uses Agent/AI magenta and does not use shell-mode blue.app/src/terminal/input_test.rs: verify Enter in handoff compose builds aPendingCloudLaunch, eagerly clears the source buffer and compose state, and dispatchesOpenLocalToCloudHandoffPane. Verifyrestore_cloud_handoff_draftre-activates compose mode, repopulates the buffer, and restores display attachments and environment.app/src/ai/blocklist/agent_view/agent_message_bar*: verifyExitCloudHandoffModeMessageProducerrenders Backspace + "to exit cloud mode", uses Agent/AI magenta when the buffer is empty, and follows the shell-mode disabled/muted behavior when the buffer is non-empty.app/src/ai/blocklist/agent_view/agent_input_footer/*_test.rsor existing footer tests inapp/src/terminal/input_test.rs: verify the transient environment selector renders only whileHandoffComposeState.active, selection updates the handoff state, and selection persists toCloudAgentSettings::last_selected_environment_id.app/src/terminal/input/slash_command_model_tests.rs: keep optional argument parsing coverage for/handoff query; add coverage for argument text containing spaces.app/src/terminal/input/slash_commands/*: test that/handoff queryclears the buffer normally and dispatchesOpenLocalToCloudHandoffPane;/handoffwithout query activates&compose mode.app/src/terminal/view/ambient_agent/model.rstests: coverPendingHandoffauto-submit entering the optimistically queued phase immediately, firing exactly once when touched workspace and snapshot upload both settle, explicit env preventing overlap replacement, snapshot failure restoring the draft for manual retry, and immediate spawn request failure restoring the draft before server acceptance.
Integration / manual
- Product behaviors 1-18: type
&, observe stripped magenta visible indicator, locked AI mode, empty-state cloud hint, magenta Backspace message, transient selector, Backspace-on-empty/Escape exits, and Enter requiring a non-empty prompt. - Product behaviors 20 and 30-31: from an empty local AgentView conversation,
& queryand/handoff queryopen normal cloud mode and auto-start a fresh run with the prompt and file/image attachments. - Product behaviors 21 and 30-32: from an eligible non-empty local conversation, both auto-run entrypoints open the handoff pane, immediately show queued/starting without an editable prompt gap, and dispatch the cloud run once fork, overlap/default env selection, and snapshot upload are ready.
- Product behaviors 22-27 and 33-34: running/blocked, missing-token, fork-failure, snapshot-failure, and immediate spawn-failure cases preserve or restore the prompt and image/file attachments according to whether workspace claimed the launch.
- Product behavior 49:
&and!are mutually exclusive in both directions. Verify&cannot activate from visible shell mode, typed!inside&mode remains prompt text, explicit terminal-mode actions exit&, and the input never renders both indicators. - Product behaviors 39-41 and 50: explicit
&environment wins over touched-repo overlap and persists as the saved cloud environment; slash-command auto-run still allows overlap/default selection.
Validation commands
- Compile the touched Rust targets after implementation. Prefer the repo's normal Rust check command if available in existing docs/scripts; otherwise use the narrowest
cargo check/Bazel equivalent that coversapp/src/terminal/input.rs,app/src/workspace/view.rs, andapp/src/terminal/view/ambient_agent/model.rs. - Run the focused unit tests added above.
Parallelization
The implementation touches tightly coupled input, footer, workspace, and ambient-model state. One agent should implement it sequentially to avoid conflicting edits around Input and AgentInputFooter; validation can be split afterward if compile/test time becomes the bottleneck.
Follow-ups
- Add general cloud-mode serialization for selected text/block/document context and plumb it through
SpawnAgentRequest.referenced_attachmentsor the appropriate server contract. Once that exists,PendingCloudLaunchshould carry the same selected-context payload for normal cloud mode,&, and/handoff query. - Consider dedicated telemetry for
&activation, auto-submit success, and blocked/failure reasons if existing slash/cloud telemetry is insufficient for rollout analysis.