Skip to content

Commit 74bdbd1

Browse files
authored
implement basic local cloud handoff UI (#9455)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> This PR implements the UI for the local -> cloud handoff flow in the client. This is done levaraging the workspace-discovery and snapshotting functionality added in the PRs below this one. On chip-click, we snapshot the conversation's touched files and repos, and create a new cloud mode pane to kick off a run. When the user submits a query, we kick that run off. Don't over-index on the UI too much — in a follow-up PR I'll add the conversation into the cloud mode pane and also re-use the full cloud mode setup v2 UI. The associated server PR for this client PR is here: warpdotdev/warp-server#10777 ## Testing <!-- How did you test this change? What automated tests did you add? If you didn't add any new tests, what's your justification for not adding any? If you're not sure whether you should add a test, check our testing policy: https://www.notion.so/warpdev/How-We-Code-at-Warp-257fe43d556e4b3c8dfd42f70004cc72#1f97825450504baa9c5fd87a737daa09 --> demo: https://www.loom.com/share/a6caa2c974e34b49b2b038a8019c062c ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent ce3296a commit 74bdbd1

23 files changed

Lines changed: 977 additions & 37 deletions

File tree

app/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ vertical_tabs_summary_mode = []
915915
tab_configs = []
916916
agent_harness = []
917917
oz_handoff = []
918+
handoff_local_cloud = []
918919
hoa_notifications = []
919920
open_code_notifications = []
920921
transfer_control_tool = []

app/src/ai/agent_sdk/driver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ mod snapshot;
102102
pub(crate) mod terminal;
103103

104104
use environment::PrepareEnvironmentError;
105+
pub(crate) use snapshot::upload_snapshot_for_handoff;
105106
use terminal::TerminalDriverEvent;
106107

107108
const MCP_SERVER_STARTUP_TIMEOUT: Duration = Duration::from_secs(60);

app/src/ai/agent_sdk/driver/snapshot.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -741,8 +741,6 @@ async fn upload_snapshot_from_declarations_file(
741741
/// it at an incomplete prefix. Manifest-upload failures are also routed through
742742
/// `report_error!` so on-call alerting catches the silent regression.
743743
/// - `Err(_)` only for hard failures of `upload_local_handoff_snapshot` itself (auth, etc.).
744-
// TODO(REMOTE-1486): drop once the handoff UI in the parent stack branch wires this up.
745-
#[allow(dead_code)]
746744
pub(crate) async fn upload_snapshot_for_handoff(
747745
repo_paths: Vec<PathBuf>,
748746
orphan_file_paths: Vec<PathBuf>,

app/src/ai/blocklist/agent_view/agent_input_footer/editor.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ fn open_toolbar_items_from_settings<V: View>(
9393
)
9494
}
9595
};
96+
97+
// Drop saved items that are no longer available (e.g. their feature flag was disabled).
98+
// Without this, the editor renders chips like `HandoffToCloud` from a prior `Custom`
99+
// selection even when the gating flag is off.
100+
let filter_unavailable = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
101+
items
102+
.into_iter()
103+
.filter(|item| available.contains(item))
104+
.collect()
105+
};
106+
let current_left = filter_unavailable(current_left);
107+
let current_right = filter_unavailable(current_right);
108+
96109
chip_configurator.open_left_right_zones_with_items(
97110
current_left,
98111
current_right,

app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,11 @@ pub struct AgentInputFooter {
228228
// Fast-forward (auto-approve) toggle button shown in the agent view footer.
229229
fast_forward_button: ViewHandle<ActionButton>,
230230

231+
// "Hand off to cloud" chip. Visibility is gated on native/local handoff
232+
// availability. Per-conversation eligibility is enforced by
233+
// `Workspace::start_local_to_cloud_handoff`.
234+
handoff_to_cloud_button: ViewHandle<ActionButton>,
235+
231236
// CLI agent voice input state (self-contained, bypasses editor voice flow).
232237
#[cfg(feature = "voice_input")]
233238
cli_voice_input_state: CLIVoiceInputState,
@@ -349,6 +354,20 @@ impl AgentInputFooter {
349354
})
350355
});
351356

357+
// "Hand off to cloud" chip. On click dispatches the workspace action that
358+
// splits a new cloud-mode pane next to the local pane; that pane handles
359+
// the rest of the handoff flow when native/local handoff is available.
360+
let handoff_to_cloud_button = ctx.add_typed_action_view(|_ctx| {
361+
ActionButton::new("", AgentInputButtonTheme)
362+
.with_icon(Icon::UploadCloud)
363+
.with_tooltip("Hand off to cloud")
364+
.with_size(button_size)
365+
.with_tooltip_alignment(TooltipAlignment::Left)
366+
.on_click(|ctx| {
367+
ctx.dispatch_typed_action(AgentInputFooterAction::OpenHandoffPane);
368+
})
369+
});
370+
352371
// CLI agent-specific buttons (only rendered when a CLI agent session is active).
353372
let cli_button_size = ButtonSize::AgentInputButton;
354373
let file_explorer_button = ctx.add_typed_action_view(|ctx| {
@@ -770,6 +789,7 @@ impl AgentInputFooter {
770789
cli_display_chips: vec![],
771790
display_chip_config,
772791
fast_forward_button,
792+
handoff_to_cloud_button,
773793
#[cfg(feature = "voice_input")]
774794
cli_voice_input_state: CLIVoiceInputState::default(),
775795
#[cfg(feature = "voice_input")]
@@ -1365,7 +1385,8 @@ impl AgentInputFooter {
13651385
AgentToolbarItemKind::ModelSelector
13661386
| AgentToolbarItemKind::NLDToggle
13671387
| AgentToolbarItemKind::ContextWindowUsage
1368-
| AgentToolbarItemKind::FastForwardToggle => None,
1388+
| AgentToolbarItemKind::FastForwardToggle
1389+
| AgentToolbarItemKind::HandoffToCloud => None,
13691390
}
13701391
}
13711392

@@ -1950,6 +1971,17 @@ impl AgentInputFooter {
19501971
AgentToolbarItemKind::FastForwardToggle => FeatureFlag::FastForwardAutoexecuteButton
19511972
.is_enabled()
19521973
.then(|| ChildView::new(&self.fast_forward_button).finish()),
1974+
AgentToolbarItemKind::HandoffToCloud => {
1975+
if !AgentToolbarItemKind::handoff_to_cloud_available() {
1976+
return None;
1977+
}
1978+
// Render the chip when the native/local handoff surface is available.
1979+
// Per-conversation eligibility (synced server token, non-empty
1980+
// history) is enforced by `Workspace::start_local_to_cloud_handoff`,
1981+
// which falls through to splitting a fresh cloud-mode pane when
1982+
// the active conversation isn't handoff-able.
1983+
Some(ChildView::new(&self.handoff_to_cloud_button).finish())
1984+
}
19531985
// Handled by the available_in() guard above; included for exhaustiveness.
19541986
AgentToolbarItemKind::FileExplorer
19551987
| AgentToolbarItemKind::RichInput
@@ -2213,6 +2245,9 @@ pub enum AgentInputFooterAction {
22132245
StartRemoteControl,
22142246
StopRemoteControl,
22152247
OpenCodingAgentSettings,
2248+
/// Open the local-to-cloud handoff pane. Dispatched by the
2249+
/// "Hand off to cloud" footer chip.
2250+
OpenHandoffPane,
22162251
ShowContextMenu {
22172252
position: Vector2F,
22182253
},
@@ -2409,6 +2444,13 @@ impl TypedActionView for AgentInputFooter {
24092444
widget_id: crate::settings_view::cli_agent_settings_widget_id(),
24102445
});
24112446
}
2447+
AgentInputFooterAction::OpenHandoffPane => {
2448+
if AgentToolbarItemKind::handoff_to_cloud_available() {
2449+
ctx.emit(AgentInputFooterEvent::OpenHandoffPane {
2450+
initial_prompt: None,
2451+
});
2452+
}
2453+
}
24122454
AgentInputFooterAction::ShowContextMenu { position } => {
24132455
ctx.emit(AgentInputFooterEvent::ShowContextMenu {
24142456
position: *position,
@@ -2455,6 +2497,11 @@ pub enum AgentInputFooterEvent {
24552497
PluginInstalled(CLIAgent),
24562498
#[cfg(not(target_family = "wasm"))]
24572499
OpenPluginInstructionsPane(CLIAgent, PluginModalKind),
2500+
/// Local-to-cloud handoff chip clicked. The terminal `Input` subscriber
2501+
/// forwards this to `WorkspaceAction::OpenLocalToCloudHandoffPane`.
2502+
OpenHandoffPane {
2503+
initial_prompt: Option<String>,
2504+
},
24582505
}
24592506

24602507
impl Entity for AgentInputFooter {

app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,17 @@ pub enum AgentToolbarItemKind {
6868

6969
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
7070
FastForwardToggle,
71+
72+
// Agent view only – "Hand off to cloud" chip.
73+
HandoffToCloud,
7174
}
7275

7376
impl AgentToolbarItemKind {
77+
pub fn handoff_to_cloud_available() -> bool {
78+
FeatureFlag::OzHandoff.is_enabled()
79+
&& FeatureFlag::HandoffLocalCloud.is_enabled()
80+
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
81+
}
7482
pub fn available_in(&self) -> ToolbarAvailability {
7583
match self {
7684
Self::ContextChip(_) | Self::VoiceInput | Self::FileAttach | Self::ShareSession => {
@@ -79,7 +87,8 @@ impl AgentToolbarItemKind {
7987
Self::ModelSelector
8088
| Self::NLDToggle
8189
| Self::ContextWindowUsage
82-
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
90+
| Self::FastForwardToggle
91+
| Self::HandoffToCloud => ToolbarAvailability::AgentViewOnly,
8392
Self::FileExplorer | Self::RichInput | Self::Settings => {
8493
ToolbarAvailability::CLIAgentOnly
8594
}
@@ -98,6 +107,8 @@ impl AgentToolbarItemKind {
98107
Self::Settings | Self::ShareSession | Self::FileExplorer => !status.is_viewer(),
99108
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
100109
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
110+
// Handoff is host-initiated; viewers cannot hand off another user's conversation.
111+
Self::HandoffToCloud => !status.is_viewer(),
101112
Self::ContextChip(_)
102113
| Self::ModelSelector
103114
| Self::NLDToggle
@@ -120,6 +131,7 @@ impl AgentToolbarItemKind {
120131
Self::ShareSession => "/remote-control",
121132
Self::Settings => "Settings",
122133
Self::FastForwardToggle => "Fast Forward",
134+
Self::HandoffToCloud => "Hand off to cloud",
123135
}
124136
}
125137

@@ -136,6 +148,9 @@ impl AgentToolbarItemKind {
136148
Self::ShareSession => Some(Icon::Phone01),
137149
Self::Settings => Some(Icon::Settings),
138150
Self::FastForwardToggle => Some(Icon::FastForward),
151+
// The bundled `upload-cloud-01.svg` (cloud-with-upward-arrow) is the
152+
// closest fit among the existing icons for V0; design may swap it later.
153+
Self::HandoffToCloud => Some(Icon::UploadCloud),
139154
}
140155
}
141156

@@ -177,6 +192,9 @@ impl AgentToolbarItemKind {
177192
{
178193
items.push(Self::ShareSession);
179194
}
195+
if Self::handoff_to_cloud_available() {
196+
items.push(Self::HandoffToCloud);
197+
}
180198
items.push(Self::VoiceInput);
181199
items.push(Self::FileAttach);
182200
items
@@ -203,6 +221,9 @@ impl AgentToolbarItemKind {
203221
{
204222
items.push(Self::ShareSession);
205223
}
224+
if Self::handoff_to_cloud_available() {
225+
items.push(Self::HandoffToCloud);
226+
}
206227
items
207228
}
208229

app/src/ai/blocklist/agent_view/zero_state_block.rs

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -177,22 +177,31 @@ impl AgentViewZeroStateBlock {
177177
if let Some(cloud_agent_view_model) = cloud_agent_view_model {
178178
let model_events_clone = model_events_dispatcher.clone();
179179
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
180-
if FeatureFlag::CloudModeSetupV2.is_enabled() {
181-
match event {
180+
if me.should_hide {
181+
return;
182+
}
183+
184+
// Hide the zero state when this pane becomes a local-to-cloud handoff
185+
// pane (REMOTE-1486). The fresh cloud-mode banner is suppressed because
186+
// the pane is actually pre-loaded with a forked source conversation, not
187+
// a brand-new one.
188+
if matches!(event, AmbientAgentViewModelEvent::PendingHandoffChanged)
189+
&& model.as_ref(ctx).is_local_to_cloud_handoff()
190+
{
191+
me.should_hide = true;
192+
} else if FeatureFlag::CloudModeSetupV2.is_enabled() {
193+
if matches!(
194+
event,
182195
AmbientAgentViewModelEvent::DispatchedAgent
183-
| AmbientAgentViewModelEvent::Cancelled
184-
if !me.should_hide =>
185-
{
186-
me.should_hide = true;
187-
ctx.unsubscribe_to_model(&model);
188-
ctx.unsubscribe_to_model(&model_events_clone);
189-
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
190-
ctx.notify();
191-
}
192-
_ => (),
196+
| AmbientAgentViewModelEvent::Cancelled
197+
) {
198+
me.should_hide = true;
193199
}
194200
} else if model.as_ref(ctx).should_show_status_footer() {
195201
me.should_hide = true;
202+
}
203+
204+
if me.should_hide {
196205
ctx.unsubscribe_to_model(&model);
197206
ctx.unsubscribe_to_model(&model_events_clone);
198207
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
@@ -203,6 +212,8 @@ impl AgentViewZeroStateBlock {
203212

204213
let has_parent_terminal =
205214
cloud_agent_view_model.is_none_or(|model| !model.as_ref(ctx).is_ambient_agent());
215+
let is_local_to_cloud_handoff = cloud_agent_view_model
216+
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff());
206217
let changelog_model = ChangelogModel::handle(ctx);
207218
ctx.subscribe_to_model(&changelog_model, |me, changelog_model, event, ctx| {
208219
if let changelog_model::Event::ChangelogRequestComplete { .. } = event {
@@ -259,7 +270,8 @@ impl AgentViewZeroStateBlock {
259270
terminal_model,
260271
current_working_directory,
261272
cached_recent_conversations,
262-
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff),
273+
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff)
274+
|| is_local_to_cloud_handoff,
263275
should_show_init_callout,
264276
has_parent_terminal,
265277
state_handles,

app/src/ai/blocklist/handoff/touched_repos.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@
1717
//! (one `git remote get-url origin` per unique repo). Callers run them in sequence
1818
//! off the main thread; see `app/src/workspace/view.rs::start_local_to_cloud_handoff`.
1919
20-
// TODO(REMOTE-1486): drop once the handoff UI in the parent stack branch wires this up.
21-
#![allow(dead_code)]
22-
2320
use std::collections::HashSet;
2421
use std::path::{Path, PathBuf};
2522
use std::time::Duration;

app/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2849,6 +2849,8 @@ pub fn enabled_features() -> HashSet<FeatureFlag> {
28492849
FeatureFlag::AgentHarness,
28502850
#[cfg(feature = "oz_handoff")]
28512851
FeatureFlag::OzHandoff,
2852+
#[cfg(feature = "handoff_local_cloud")]
2853+
FeatureFlag::HandoffLocalCloud,
28522854
#[cfg(feature = "hoa_notifications")]
28532855
FeatureFlag::HOANotifications,
28542856
#[cfg(feature = "open_code_notifications")]

app/src/search/slash_command_menu/static_commands/commands.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,21 @@ pub static FORK: LazyLock<StaticCommand> = LazyLock::new(|| {
171171
}
172172
});
173173

174+
pub static MOVE_TO_CLOUD: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
175+
name: "/move-to-cloud",
176+
description: "Hand off this conversation to a cloud agent",
177+
icon_path: "bundled/svg/upload-cloud-01.svg",
178+
availability: Availability::AGENT_VIEW
179+
| Availability::ACTIVE_CONVERSATION
180+
| Availability::AI_ENABLED,
181+
auto_enter_ai_mode: false,
182+
argument: Some(
183+
Argument::optional()
184+
.with_hint_text("<optional follow-up prompt>")
185+
.with_execute_on_selection(),
186+
),
187+
});
188+
174189
pub const OPEN_CODE_REVIEW: StaticCommand = StaticCommand {
175190
name: "/open-code-review",
176191
description: "Open code review",
@@ -686,6 +701,13 @@ fn all_commands() -> Vec<StaticCommand> {
686701
commands.push(CLOUD_AGENT.clone());
687702
}
688703

704+
if FeatureFlag::OzHandoff.is_enabled()
705+
&& FeatureFlag::HandoffLocalCloud.is_enabled()
706+
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
707+
{
708+
commands.push(MOVE_TO_CLOUD.clone());
709+
}
710+
689711
if FeatureFlag::InlineProfileSelector.is_enabled() {
690712
commands.push(PROFILE.clone());
691713
}

0 commit comments

Comments
 (0)