Files
galaxy/specs/REMOTE-1558/TECH.md
T

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:495 defines the existing ! shell-mode prefix. The typed-only prefix stripping and lock behavior live in app/src/terminal/input.rs (9087-9200), empty-state escape/backspace handling lives in app/src/terminal/input.rs (9686-9824), and the visible ! indicator is rendered by maybe_render_ai_input_indicators in app/src/terminal/input.rs (14701-14758).
  • InputType is the semantic/classification mode for a buffer: Shell or AI. It is imported from input_classifier and maps directly into the session-sharing protocol's Shell / AI input mode in app/src/ai/blocklist/input_model.rs (114-121). Do not add a CloudHandoff variant to InputType; & 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 by BlocklistAIInputModel being locked to InputType::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 into AttachmentInput, clears the editor and pending attachments, then calls either AmbientAgentViewModel::spawn_agent or submit_handoff. It does not serialize selected text/block/document context; spawn_agent and submit_handoff both set referenced_attachments: vec![] in app/src/terminal/view/ambient_agent/model.rs (707-786) and app/src/terminal/view/ambient_agent/model.rs (1203-1258).
  • /handoff is registered with an optional argument in app/src/search/slash_command_menu/static_commands/commands.rs:174. Slash parsing preserves the text after the first space in app/src/terminal/input/slash_command_model.rs:416, and the handler dispatches WorkspaceAction::OpenLocalToCloudHandoffPane for /handoff query and activates & handoff-compose mode for /handoff with no query.
  • WorkspaceAction::OpenLocalToCloudHandoffPane is defined in app/src/workspace/action.rs:489 and handled in app/src/workspace/view.rs:20345.
  • Workspace::start_local_to_cloud_handoff in app/src/workspace/view.rs (12894-12965) currently requires an active non-empty conversation with a server_conversation_token; otherwise it toasts and opens no pane. complete_local_to_cloud_handoff_open in app/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, seeds PendingHandoff, and starts touched-workspace derivation plus snapshot upload.
  • PendingHandoff and handoff readiness live on AmbientAgentViewModel in app/src/terminal/view/ambient_agent/model.rs (78-138) and app/src/terminal/view/ambient_agent/model.rs (397-509). submit_handoff builds a normal SpawnAgentRequest with conversation_id set to the forked server conversation id and initial_snapshot_token set from the prepared upload.
  • EnvironmentSelector in app/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs (129-480) is currently hard-bound to ModelHandle<AmbientAgentViewModel>. It persists explicit selections to CloudAgentSettings::last_selected_environment_id and only enables while the ambient model is composing. The footer renders it only for ambient cloud panes in app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs (2014-2039).
  • AgentMessageBar in app/src/ai/blocklist/agent_view/agent_message_bar.rs already owns the shell-mode "backspace to exit shell mode" affordance through ExitBashModeMessageProducer; 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_color in app/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_composing already excludes local-to-cloud handoff panes in app/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-read Vec<AttachmentInput> for SpawnAgentRequest, plus a Vec<PendingAttachment> display/restoration snapshot so failure paths can repopulate the source input.
  • PendingCloudLaunchprompt: String and attachments: HandoffLaunchAttachments. Reused by the action variant and by PendingHandoff.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:
  1. In handle_editor_event, recognize & only on EditOrigin::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.
  2. If the visible ! shell-mode indicator is active, typed & is normal shell text; the user must exit shell mode before entering handoff compose mode.
  3. Strip the literal & from the editor buffer, set HandoffComposeState.active = true, set the input config directly to InputConfig { input_type: InputType::AI, is_locked: true }, keep focus in the editor, and notify the footer. Do not call unlocked_if_autodetection_enabled for this transition; cloud-prefix mode is intentionally locked AI until exit.
  4. While HandoffComposeState.active is true, skip the existing TERMINAL_INPUT_PREFIX activation 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.
  5. Any other path that locks the input to shell mode while & is active, such as explicit terminal-mode actions or DeleteAllLeft, should call exit_handoff_compose_mode before applying the shell lock. Conversely, & activation should no-op if the shell lock is already active. This makes HandoffComposeState.active and locked-shell input a defended invariant rather than a rendering convention.
  6. Add a helper like current_prefix_mode(ctx) -> InputPrefixMode and route activation, rendering, hint text, Escape/Backspace, Enter behavior, and tests through it. This helper should derive Shell from locked InputType::Shell, derive CloudHandoff from HandoffComposeState.active, and never let both be true from a caller's perspective.
  7. Render & through maybe_render_ai_input_indicators as a sibling of the existing ! indicator path. Defensively prefer & only if HandoffComposeState.active; otherwise render ! from locked-shell state. The & indicator should use Agent/AI magenta (ai_brand_color or the equivalent theme magenta), not ansi_fg_blue. Tests should assert both indicators cannot render together.
  8. In set_zero_state_hint_text, use the handoff hint only when HandoffComposeState.active and the editor buffer is empty. Keep this hint focused on what the prompt will do; the Backspace exit copy belongs to the message bar.
  9. Thread the HandoffComposeState handle into the Agent View message bar path by passing it through BlocklistAIStatusBar::new and AgentMessageBar::new, or by passing a small derived prefix-mode provider if that keeps constructor churn lower. Add an ExitCloudHandoffModeMessageProducer parallel to ExitBashModeMessageProducer that 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.
  10. On BackspaceOnEmptyBuffer / BackspaceAtBeginningOfBuffer or Escape, call a single exit_handoff_compose_mode helper 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 a PendingCloudLaunch from the current buffer, pre-read cloud-mode-supported image/file attachments using the same logic as app/src/terminal/input.rs (11926-12131), read the explicit environment id from HandoffComposeState, then eagerly exit compose mode and clear the source buffer and pending attachments. Dispatch OpenLocalToCloudHandoffPane { launch, explicit_environment_id }. If workspace fails to open the target pane, it calls Input::restore_cloud_handoff_draft to 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) — the is_explicit flag 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 the CloudPane target 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 AmbientAgentViewModel and for HandoffComposeState. Preserve existing selector behavior: selecting an environment from either target persists to CloudAgentSettings::last_selected_environment_id, opens the environment-management pane through the existing footer event, and uses the same label/menu/disabled states. Update AgentInputFooter so 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.active is 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:

  1. No active conversation or an empty active conversation: fresh cloud run/compose.
  2. Non-empty conversation whose status is running or blocked: restore source input, toast.
  3. Non-empty idle conversation without server_conversation_token: restore source input, toast.
  4. 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 calls Input::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 calls restore_cloud_handoff_draft before showing the toast. The success branch proceeds to complete_local_to_cloud_handoff_open as 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 on AmbientAgentViewModel, or an equivalent representation, but callers need to know whether the user's prompt is hidden because Warp has queued it. PendingCloudLaunch should 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_open creates the handoff pane:
  1. Apply explicit_environment_id to the pane model immediately when present.
  2. For Compose, prefill the handoff pane input and install the pending image/file attachment snapshot as today.
  3. For AutoSubmit, do not hydrate the submitted prompt or pending attachment display into the destination editor before queueing. Instead, seed PendingHandoff with 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.
  4. Source input was already cleared eagerly at dispatch time, so no claim step is needed.
  5. 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 as queue_auto_submit_handoff(ctx) and call it immediately after seeding PendingHandoff for an AutoSubmit launch. 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 each PendingHandoff mutation 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 calling submit_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_draft on 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 query failure restoration.
  • Environment overlap overwrites explicit user choice. Store explicit environment state on PendingHandoff and skip pick_handoff_overlap_env when 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 to run_input_mode_prefix_test for 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.rs or view-rendering coverage for maybe_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 a PendingCloudLaunch, eagerly clears the source buffer and compose state, and dispatches OpenLocalToCloudHandoffPane. Verify restore_cloud_handoff_draft re-activates compose mode, repopulates the buffer, and restores display attachments and environment.
  • app/src/ai/blocklist/agent_view/agent_message_bar*: verify ExitCloudHandoffModeMessageProducer renders 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.rs or existing footer tests in app/src/terminal/input_test.rs: verify the transient environment selector renders only while HandoffComposeState.active, selection updates the handoff state, and selection persists to CloudAgentSettings::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 query clears the buffer normally and dispatches OpenLocalToCloudHandoffPane; /handoff without query activates & compose mode.
  • app/src/terminal/view/ambient_agent/model.rs tests: cover PendingHandoff auto-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, & query and /handoff query open 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 covers app/src/terminal/input.rs, app/src/workspace/view.rs, and app/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_attachments or the appropriate server contract. Once that exists, PendingCloudLaunch should 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.