Skip to content

Commit 4e600af

Browse files
authored
open local->cloud mode conversation in the same pane (#9988)
## Description <!-- Please remember to add your design buddy onto the PR for review, if it contains any UI changes! --> After posting a demo of local -> cloud handoff, I get some feedback (from peter, varoon, and ZL) that it would be better to just open the cloud mode session in the same pane as the local conversation. This makes sense to me, and we're still forking the conversation and opening up a new cloud mode pane so it's not like this is a destructive action (someone can just close the cloud mode view and re-open the local conversation view). Implements REMOTE-1557 ## Screenshots / Videos <!-- Attach screenshots or a short video demonstrating the change, where appropriate. Remove this section if it is not relevant to your PR. --> demo: https://www.loom.com/share/d2f67ee4e15245959210ef41d1dfe7c6 ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
1 parent e6df31b commit 4e600af

4 files changed

Lines changed: 121 additions & 69 deletions

File tree

app/src/terminal/view.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3173,12 +3173,16 @@ impl TerminalView {
31733173
original_exchange_count,
31743174
final_exchange_count,
31753175
was_ambient_agent,
3176+
is_exit_before_new_entrance,
31763177
..
31773178
} => {
31783179
// Prompt suggestions should not follow the user back to terminal view.
31793180
me.clear_prompt_suggestions(ctx);
31803181
// For ambient agent sessions, pop the pane stack to return to the parent terminal.
3181-
if *was_ambient_agent {
3182+
// Skip the pop when this exit is immediately followed by re-entering agent view
3183+
// for a different conversation (e.g. a restored conversation taking over the
3184+
// pane).
3185+
if *was_ambient_agent && !*is_exit_before_new_entrance {
31823186
if let Some(pane_stack) =
31833187
me.pane_stack.as_ref().and_then(|h| h.upgrade(ctx))
31843188
{

app/src/terminal/view/ambient_agent/view_impl.rs

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ use crate::ai::AIRequestUsageModel;
1111
use warp_core::features::FeatureFlag;
1212
use warp_core::send_telemetry_from_ctx;
1313
use warpui::prelude::{Empty, Vector2F};
14+
use warpui::{ModelHandle, ViewHandle};
1415

1516
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
1617
use crate::ai::blocklist::{agent_view::AgentViewEntryOrigin, BlocklistAIHistoryModel};
1718
use crate::ai::conversation_details_panel::ConversationDetailsData;
1819
use crate::pane_group::TerminalViewResources;
19-
use crate::server::server_api::ai::SpawnAgentRequest;
2020
use crate::terminal::view::rich_content::{RichContentInsertionPosition, RichContentMetadata};
2121
use crate::terminal::view::TerminalView;
2222
use crate::terminal::CLIAgent;
@@ -30,8 +30,9 @@ use super::loading_screen::{
3030
render_cloud_mode_cancelled_screen, render_cloud_mode_error_screen,
3131
render_cloud_mode_github_auth_required_screen, render_cloud_mode_loading_screen,
3232
};
33-
use super::{AmbientAgentEntryBlock, AmbientAgentViewModelEvent};
33+
use super::{AmbientAgentEntryBlock, AmbientAgentViewModel, AmbientAgentViewModelEvent};
3434
use crate::terminal::view::Event as TerminalViewEvent;
35+
3536
const CHILD_AGENT_GITHUB_AUTH_REQUIRED_BLOCKED_ACTION: &str =
3637
"GitHub authentication required before starting the child agent.";
3738

@@ -619,7 +620,7 @@ impl TerminalView {
619620
let prompt = initial_prompt.clone();
620621
self.set_pending_cloud_mode_start_callback(
621622
Box::new(move |view, ctx| {
622-
view.start_cloud_mode(None, prompt, ctx);
623+
view.start_cloud_mode(prompt, ctx);
623624
}),
624625
ctx,
625626
);
@@ -633,19 +634,36 @@ impl TerminalView {
633634
return;
634635
}
635636

636-
self.start_cloud_mode(None, initial_prompt, ctx);
637+
self.start_cloud_mode(initial_prompt, ctx);
637638
}
638639

639-
/// Start a cloud mode session nested under this one.
640+
/// Push a fresh cloud-mode pane onto this view's pane_stack for a local-to-cloud handoff.
641+
/// Returns the pushed view and its `AmbientAgentViewModel` so the caller can restore the
642+
/// forked conversation, bind it to the cloud-side conversation id, and seed `PendingHandoff`.
640643
///
641-
/// If `spawn_request` is `Some`, the agent is immediately started. Otherwise, it can
642-
/// further configured in the cloud mode session.
644+
/// Uses the same setup-mode initialization path as fresh cloud-mode runs (which calls
645+
/// `enter_ambient_agent_setup` → `enter_agent_view_for_new_conversation` and focuses the
646+
/// input) so the new pane's cloud-mode input is editable and focused immediately. The
647+
/// caller then layers the forked conversation's blocks on top via
648+
/// `restore_conversation_after_view_creation` and seeds `PendingHandoff`; submission uses
649+
/// the cached `forked_conversation_id` from `PendingHandoff`, not the new pane's local
650+
/// agent-view conversation id.
651+
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
652+
pub(crate) fn start_local_to_cloud_handoff_pane(
653+
&mut self,
654+
ctx: &mut ViewContext<Self>,
655+
) -> Option<(ViewHandle<TerminalView>, ModelHandle<AmbientAgentViewModel>)> {
656+
self.start_cloud_mode(None, ctx)
657+
}
658+
659+
/// Start a cloud mode session nested under this one, pushing a new pane onto this view's
660+
/// pane_stack and returning the pushed view + model handle. The new pane enters setup mode
661+
/// with `initial_prompt` (if any) pre-filled in the input.
643662
fn start_cloud_mode(
644663
&mut self,
645-
spawn_request: Option<SpawnAgentRequest>,
646664
initial_prompt: Option<String>,
647665
ctx: &mut ViewContext<Self>,
648-
) {
666+
) -> Option<(ViewHandle<TerminalView>, ModelHandle<AmbientAgentViewModel>)> {
649667
let resources = TerminalViewResources {
650668
tips_completed: self.tips_completed.clone(),
651669
server_api: self.server_api.clone(),
@@ -665,7 +683,7 @@ impl TerminalView {
665683
.cloned()
666684
else {
667685
log::warn!("Cloud mode view was created without an ambient agent view model");
668-
return;
686+
return None;
669687
};
670688
let terminal_view_weak = terminal_view.downgrade();
671689
let terminal_manager_weak = terminal_manager.downgrade();
@@ -719,44 +737,32 @@ impl TerminalView {
719737
});
720738

721739
let pane_config = self.pane_configuration.clone();
722-
let ambient_agent_view_model_for_update = ambient_agent_view_model.clone();
723740
terminal_view.update(ctx, |view, ctx| {
724741
view.set_pane_configuration(pane_config);
725-
726-
if let Some(request) = spawn_request {
727-
// Spawn the agent immediately with the provided request.
728-
view.enter_agent_view_for_new_conversation(
729-
None,
730-
AgentViewEntryOrigin::CloudAgent,
731-
ctx,
732-
);
733-
ambient_agent_view_model_for_update.update(ctx, |model, ctx| {
734-
model.spawn_agent_with_request(request, ctx);
735-
});
736-
} else {
737-
// Enter setup mode for composing a prompt
738-
view.enter_ambient_agent_setup(initial_prompt, ctx);
739-
}
742+
view.enter_ambient_agent_setup(initial_prompt, ctx);
740743
});
741744

742-
if let Some(pane_stack) = self.pane_stack.clone() {
743-
if let Some(stack) = pane_stack.upgrade(ctx) {
744-
stack.update(ctx, |stack, ctx| {
745-
stack.push(terminal_manager, terminal_view, ctx);
746-
});
747-
} else {
748-
log::warn!("Pane stack deallocated, cannot enter cloud mode");
749-
}
750-
} else {
745+
let Some(pane_stack) = self.pane_stack.clone() else {
751746
log::warn!("Pane stack not available, cannot enter cloud mode");
752-
}
747+
return None;
748+
};
749+
let Some(stack) = pane_stack.upgrade(ctx) else {
750+
log::warn!("Pane stack deallocated, cannot enter cloud mode");
751+
return None;
752+
};
753+
let pushed_view = terminal_view.clone();
754+
stack.update(ctx, |stack, ctx| {
755+
stack.push(terminal_manager, pushed_view, ctx);
756+
});
753757

754758
send_telemetry_from_ctx!(
755759
CloudAgentTelemetryEvent::EnteredCloudMode {
756760
entry_point: CloudModeEntryPoint::LocalSession
757761
},
758762
ctx
759763
);
764+
765+
Some((terminal_view, ambient_agent_view_model))
760766
}
761767

762768
/// Renders the ambient agent progress view based on agent progress.

app/src/workspace/view.rs

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12877,14 +12877,16 @@ impl Workspace {
1287712877
});
1287812878
}
1287912879

12880-
/// Open a local-to-cloud handoff pane next to the active local pane. Triggered
12881-
/// by the `/move-to-cloud` slash command and the "Hand off to cloud" footer
12882-
/// chip.
12880+
/// Open a local-to-cloud handoff pane in place over the active local pane.
12881+
/// Triggered by the `/move-to-cloud` slash command and the "Hand off to
12882+
/// cloud" footer chip.
1288312883
///
1288412884
/// When the active conversation is non-empty and has a server token, mints a
12885-
/// server-side fork via `POST /agent/conversations/{conversation_id}/fork`, then
12886-
/// splits a fresh cloud-mode pane next to the local pane and pre-populates it
12887-
/// with the forked conversation.
12885+
/// server-side fork via `POST /agent/conversations/{conversation_id}/fork`,
12886+
/// then pushes a fresh cloud-mode view onto the active pane's navigation stack
12887+
/// and pre-populates it with the forked conversation. Pressing Escape in the
12888+
/// cloud-mode view pops back to the original local pane (the same pattern used
12889+
/// by Cmd-Alt-Enter / Ctrl-Alt-Enter to enter cloud mode from a local session).
1288812890
///
1288912891
/// All failure modes — ineligibility, fork RPC failure, and local fork
1289012892
/// materialization failure — surface an error toast and **do not open** any
@@ -12914,11 +12916,11 @@ impl Workspace {
1291412916
conversation
1291512917
.server_conversation_token()
1291612918
.cloned()
12917-
.map(|token| (conversation.clone(), token))
12919+
.map(|token| (view.clone(), conversation.clone(), token))
1291812920
})
1291912921
});
1292012922

12921-
let Some((source_conversation, source_token)) = source else {
12923+
let Some((source_view, source_conversation, source_token)) = source else {
1292212924
// Ineligible: don't open a fresh unrelated pane — the chip is a
1292312925
// hand-off-this-conversation action.
1292412926
let window_id = ctx.window_id();
@@ -12938,6 +12940,7 @@ impl Workspace {
1293812940
move |me, result, ctx| match result {
1293912941
Ok(response) => {
1294012942
me.complete_local_to_cloud_handoff_open(
12943+
source_view,
1294112944
source_conversation,
1294212945
response.forked_conversation_id,
1294312946
initial_prompt,
@@ -12960,11 +12963,23 @@ impl Workspace {
1296012963

1296112964
/// Finishes the local-to-cloud handoff open after the fork RPC returns.
1296212965
/// Materializes a local fork bound to the server's forked conversation id,
12963-
/// splits a fresh cloud-mode pane, restores the forked conversation into it,
12964-
/// seeds `PendingHandoff`, and kicks off async derivation + snapshot upload.
12966+
/// pushes a fresh cloud-mode view onto `source_view`'s navigation stack,
12967+
/// restores the forked conversation into it, exits the source pane's agent
12968+
/// view (so Esc returns to a clean terminal), seeds `PendingHandoff`, and
12969+
/// kicks off async derivation + snapshot upload.
12970+
///
12971+
/// The source's agent-view exit happens AFTER the new pane is pushed, so the
12972+
/// source pane is hidden behind the cloud-mode view when its chrome flips
12973+
/// from agent mode to terminal mode — the user never sees the transition.
12974+
/// The pane-stack pop logic in `terminal/view.rs` skips popping when the
12975+
/// `ExitedAgentView` event has `is_exit_before_new_entrance: true`, which
12976+
/// prevents the bookkeeping exit triggered by `restore_conversation_after_view_creation`
12977+
/// (re-entering agent view for the forked conversation) from tearing down
12978+
/// the just-pushed pane.
1296512979
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
1296612980
fn complete_local_to_cloud_handoff_open(
1296712981
&mut self,
12982+
source_view: ViewHandle<TerminalView>,
1296812983
source_conversation: AIConversation,
1296912984
forked_conversation_id: String,
1297012985
initial_prompt: Option<String>,
@@ -12997,25 +13012,18 @@ impl Workspace {
1299713012
};
1299813013
let local_fork_id = local_fork.id();
1299913014

13000-
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
13001-
pane_group.add_ambient_agent_pane(ctx);
13002-
});
13003-
let Some(new_pane_view) = self
13004-
.active_tab_pane_group()
13005-
.as_ref(ctx)
13006-
.active_session_view(ctx)
13007-
else {
13008-
log::warn!(
13009-
"start_local_to_cloud_handoff: no active session view after add_ambient_agent_pane"
13010-
);
13011-
return;
13012-
};
13013-
let Some(model_handle) = new_pane_view
13014-
.as_ref(ctx)
13015-
.ambient_agent_view_model()
13016-
.cloned()
13017-
else {
13018-
log::warn!("start_local_to_cloud_handoff: new ambient agent pane has no view model");
13015+
// Push the cloud-mode pane onto the source's nav stack.
13016+
let Some((new_pane_view, model_handle)) = source_view.update(ctx, |view, view_ctx| {
13017+
view.start_local_to_cloud_handoff_pane(view_ctx)
13018+
}) else {
13019+
log::warn!("start_local_to_cloud_handoff: failed to push cloud-mode pane");
13020+
let window_id = ctx.window_id();
13021+
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
13022+
let toast = DismissibleToast::error(
13023+
"Failed to prepare handoff. Please try again.".to_owned(),
13024+
);
13025+
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
13026+
});
1301913027
return;
1302013028
};
1302113029

@@ -13028,16 +13036,33 @@ impl Workspace {
1302813036
}
1302913037

1303013038
// Restore the forked conversation into the new pane so its AI exchanges are
13031-
// visible immediately. Mirrors the `/fork` in-current-pane flow.
13032-
let local_fork_for_restore = local_fork.clone();
13039+
// visible immediately. This re-enters agent view for the forked conversation,
13040+
// which emits an internal `ExitedAgentView { is_exit_before_new_entrance: true }`
13041+
// for the cloud-mode placeholder; the pane-stack pop is skipped for that flag.
1303313042
new_pane_view.update(ctx, |terminal_view, view_ctx| {
1303413043
terminal_view.restore_conversation_after_view_creation(
13035-
RestoredAIConversation::new(local_fork_for_restore),
13044+
RestoredAIConversation::new(local_fork.clone()),
1303613045
/* use_live_appearance */ true,
1303713046
view_ctx,
1303813047
);
1303913048
});
1304013049

13050+
// Enter fullscreen agent view for the restored fork in the new pane. Without this the
13051+
// pane sits in shared-session-viewer-pending mode and the input renders read-only;
13052+
// entering agent view activates the editable cloud-mode input flow. Use
13053+
// `RestoreExistingConversation` to indicate the conversation is restored (not new) —
13054+
// the `is_local_to_cloud_handoff` flag set via `set_pending_handoff` below is the
13055+
// authoritative "this is a cloud agent pane" signal for downstream gating
13056+
// (`is_cloud_agent_pre_first_exchange`, etc.).
13057+
new_pane_view.update(ctx, |terminal_view, view_ctx| {
13058+
terminal_view.enter_agent_view_for_conversation(
13059+
None,
13060+
AgentViewEntryOrigin::RestoreExistingConversation,
13061+
local_fork_id,
13062+
view_ctx,
13063+
);
13064+
});
13065+
1304113066
// Bind the local fork to the cloud-side conversation id and mark it as a shared conversation.
1304213067
history_model.update(ctx, |history_model, _| {
1304313068
history_model.set_server_conversation_token_for_conversation(
@@ -13047,6 +13072,22 @@ impl Workspace {
1304713072
history_model.set_viewing_shared_session_for_conversation(local_fork_id, true);
1304813073
});
1304913074

13075+
// Exit the source pane's fullscreen agent view (if active) so pressing Esc in the
13076+
// cloud-mode pane returns to a terminal-mode source. This runs AFTER the push, so
13077+
// the source pane is hidden behind the cloud-mode view and the transition is
13078+
// invisible to the user. `was_ambient_agent` is false for a local source, so this
13079+
// exit doesn't pop the pane stack.
13080+
let source_agent_view_controller = source_view.as_ref(ctx).agent_view_controller().clone();
13081+
if source_agent_view_controller
13082+
.as_ref(ctx)
13083+
.agent_view_state()
13084+
.is_fullscreen()
13085+
{
13086+
source_agent_view_controller.update(ctx, |controller, ctx| {
13087+
controller.exit_agent_view_without_confirmation(ctx);
13088+
});
13089+
}
13090+
1305013091
let pending = PendingHandoff {
1305113092
forked_conversation_id: forked_conversation_id.clone(),
1305213093
touched_workspace: None,

crates/warp_features/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[
940940
#[cfg(not(windows))]
941941
FeatureFlag::SshRemoteServer,
942942
FeatureFlag::CloudModeInputV2,
943+
FeatureFlag::HandoffLocalCloud,
943944
FeatureFlag::DragTabsToWindows,
944945
];
945946

0 commit comments

Comments
 (0)