Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/ai/agent_conversations_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ fn test_display_status_uses_active_execution_over_previous_conversation_status()
parent_conversation_id: None,
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
},
);

Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/agent_sdk/ambient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ impl AmbientAgentRunner {
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
fork_from_conversation_id: None,
conversation_id: None,
handoff_prep_token: None,
};

Expand Down
2 changes: 1 addition & 1 deletion app/src/ai/agent_sdk/mcp_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ fn serializes_mcp_servers_as_object_not_string() {
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
fork_from_conversation_id: None,
conversation_id: None,
handoff_prep_token: None,
};

Expand Down
4 changes: 2 additions & 2 deletions app/src/ai/ambient_agents/spawn_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ async fn poll_stops_on_terminal_failure_like_state() {
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
fork_from_conversation_id: None,
conversation_id: None,
handoff_prep_token: None,
};

Expand Down Expand Up @@ -481,7 +481,7 @@ async fn poll_for_session_join_info_waits_until_link_is_available() {
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
fork_from_conversation_id: None,
conversation_id: None,
handoff_prep_token: None,
};

Expand Down
8 changes: 4 additions & 4 deletions app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ pub struct AgentInputFooter {
// "Hand off to cloud" chip. Visibility is gated only on the
// `OzHandoff && LocalToCloudHandoff` feature flags. Per-conversation
// eligibility is enforced by `Workspace::start_local_to_cloud_handoff`,
// which falls through to splitting a fresh cloud-mode pane when the
// which surfaces an error toast and does not open a pane when the
// active conversation isn't handoff-able.
handoff_to_cloud_button: ViewHandle<ActionButton>,

Expand Down Expand Up @@ -359,8 +359,8 @@ impl AgentInputFooter {
// "Hand off to cloud" chip. On click dispatches the workspace action that
// splits a new cloud-mode pane next to the local pane; that pane handles
// the rest of the handoff flow. The chip is always visible when the feature
// flags are on; per-conversation eligibility falls through to splitting a
// fresh cloud-mode pane in `Workspace::start_local_to_cloud_handoff`.
// flags are on; per-conversation eligibility surfaces an error toast and
// does not open a pane in `Workspace::start_local_to_cloud_handoff`.
let handoff_to_cloud_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("", AgentInputButtonTheme)
.with_icon(Icon::UploadCloud)
Expand Down Expand Up @@ -1984,7 +1984,7 @@ impl AgentInputFooter {
// Always render the chip when the feature flags are on.
// Per-conversation eligibility (synced server token, non-empty
// history) is enforced by `Workspace::start_local_to_cloud_handoff`,
// which falls through to splitting a fresh cloud-mode pane when
// which surfaces an error toast and does not open a pane when
// the active conversation isn't handoff-able.
Some(ChildView::new(&self.handoff_to_cloud_button).finish())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ pub enum AgentToolbarItemKind {
/// that splits a fresh cloud-mode pane next to the active local pane.
/// Visibility is gated only on the `OzHandoff && LocalToCloudHandoff` feature
/// flags so the chip is always available; the click handler in
/// `Workspace::start_local_to_cloud_handoff` falls through to opening a
/// fresh cloud-mode pane when the active conversation isn't handoff-able
/// `Workspace::start_local_to_cloud_handoff` surfaces an error toast and
/// does not open a pane when the active conversation isn't handoff-able
/// (no synced server token, empty, or no active conversation at all).
HandoffToCloud,
}
Expand Down
8 changes: 8 additions & 0 deletions app/src/ai/blocklist/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,14 @@ impl AIBlock {
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
}

// Re-render when the cloud agent transitions through setup phases so the response
// footer (thumbs up/down, fork, credits) toggles correctly with `is_cloud_agent_pre_first_exchange`.
// Without this, the prior exchange's footer remains visible during a follow-up's
// "Step n/3" loading until something else triggers a redraw.
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
ctx.subscribe_to_model(ambient_agent_view_model, |_, _, _, ctx| ctx.notify());
}

ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
if let BlocklistAIContextEvent::UpdatedPendingContext { .. } = event {
ctx.notify();
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/blocklist/block/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,7 @@ impl View for BlocklistAIStatusBar {
is_cloud_agent_pre_first_exchange(
Some(ambient_agent_view_model),
&self.agent_view_controller,
&self.terminal_model,
app,
)
})
Expand Down
70 changes: 29 additions & 41 deletions app/src/ai/blocklist/block/view_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,62 +159,52 @@ fn add_slash_command_highlight(
/// query blocks during live startup/streaming.
///
/// To avoid duplicate UI, we suppress the AI block header/query only while the viewer is live
/// (not replaying historical conversation events).
///
/// The prompts are rendered in the ambient-agent query block UI, so this helper only gates
/// duplicate rendering in the AI block path when that optimistic block was actually inserted.
/// (not replaying historical conversation events) AND the AI block's display query matches an
/// optimistically rendered user query. The per-query check is important for forked
/// conversations (e.g. local-to-cloud handoff) where the conversation's first exchange comes
/// from the source conversation and must remain visible — only the dispatched prompt has a
/// matching optimistic block to defer to.
fn should_hide_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block: bool,
has_optimistic_user_query: bool,
is_shared_ambient_agent_session: bool,
is_first_exchange: bool,
is_receiving_agent_conversation_replay: bool,
) -> bool {
FeatureFlag::CloudModeSetupV2.is_enabled()
&& is_shared_ambient_agent_session
&& !is_receiving_agent_conversation_replay
&& ((has_inserted_cloud_mode_user_query_block && is_first_exchange)
|| has_optimistic_user_query)
&& has_optimistic_user_query
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_should_hide_ai_block_query_and_header_for_initial_cloud_prompt() {
fn test_should_hide_ai_block_query_and_header_for_optimistic_prompt() {
let _flag = FeatureFlag::CloudModeSetupV2.override_enabled(true);

assert!(should_hide_ai_block_query_and_header(
true, false, true, true, false
));
assert!(should_hide_ai_block_query_and_header(true, true, false));
}

#[test]
fn test_should_hide_ai_block_query_and_header_for_optimistic_followup_prompt() {
fn test_should_not_hide_ai_block_query_and_header_during_replay() {
let _flag = FeatureFlag::CloudModeSetupV2.override_enabled(true);

assert!(should_hide_ai_block_query_and_header(
false, true, true, false, false
));
assert!(!should_hide_ai_block_query_and_header(true, true, true));
}

#[test]
fn test_should_not_hide_ai_block_query_and_header_during_replay() {
fn test_should_not_hide_ai_block_query_and_header_for_untracked_prompt() {
let _flag = FeatureFlag::CloudModeSetupV2.override_enabled(true);

assert!(!should_hide_ai_block_query_and_header(
true, true, true, true, true
));
assert!(!should_hide_ai_block_query_and_header(false, true, false));
}

#[test]
fn test_should_not_hide_ai_block_query_and_header_for_untracked_prompt() {
fn test_should_not_hide_ai_block_query_and_header_outside_shared_session() {
let _flag = FeatureFlag::CloudModeSetupV2.override_enabled(true);

assert!(!should_hide_ai_block_query_and_header(
false, false, true, false, false
));
assert!(!should_hide_ai_block_query_and_header(true, false, false));
}
}

Expand Down Expand Up @@ -895,10 +885,6 @@ impl View for AIBlock {
terminal_model.is_receiving_agent_conversation_replay(),
)
};
let is_first_exchange = conversation
.first_exchange()
.is_some_and(|exchange| exchange.id == self.client_ids.client_exchange_id);

let input_props = input::Props {
comments: &self.comment_states,
addressed_comment_ids: &addressed_comment_ids,
Expand Down Expand Up @@ -929,22 +915,15 @@ impl View for AIBlock {
query_and_index
.as_ref()
.is_some_and(|(query_for_display, ..)| {
let (has_inserted_cloud_mode_user_query_block, has_optimistic_user_query) =
self.ambient_agent_view_model
.as_ref()
.map(|model| {
let model = model.as_ref(app);
(
model.has_inserted_cloud_mode_user_query_block(),
model.has_optimistic_user_query(query_for_display),
)
})
.unwrap_or((false, false));
let has_optimistic_user_query = self
.ambient_agent_view_model
.as_ref()
.is_some_and(|model| {
model.as_ref(app).has_optimistic_user_query(query_for_display)
});
should_hide_ai_block_query_and_header(
has_inserted_cloud_mode_user_query_block,
has_optimistic_user_query,
is_shared_ambient_agent_session,
is_first_exchange,
is_receiving_agent_conversation_replay,
)
});
Expand Down Expand Up @@ -1093,6 +1072,14 @@ impl View for AIBlock {
let is_conversation_transcript_viewer = terminal_model.is_conversation_transcript_viewer();
drop(terminal_model);

let is_cloud_agent_pre_first_exchange =
crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange(
self.ambient_agent_view_model.as_ref(),
&self.agent_view_controller,
&self.terminal_model,
app,
);

contents.add_child(output::render(
output::Props {
model: self.model.as_ref(),
Expand Down Expand Up @@ -1159,6 +1146,7 @@ impl View for AIBlock {
.is_latest_non_passive_exchange_in_root_task(app)
&& self.has_imported_comments_in_current_thread(app),
ask_user_question_view: self.ask_user_question_view.as_ref(),
is_cloud_agent_pre_first_exchange,
},
app,
));
Expand Down
7 changes: 7 additions & 0 deletions app/src/ai/blocklist/block/view_impl/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ pub(crate) struct Props<'a> {
pub(super) thinking_display_mode: crate::settings::ThinkingDisplayMode,
pub(super) conversation_has_imported_comments: bool,
pub(super) ask_user_question_view: Option<&'a ViewHandle<AskUserQuestionView>>,
/// `true` when this block belongs to a cloud agent pane that is still in its setup
/// phase (running environment startup commands before the first agent turn). Used to
/// hide the response footer (thumbs up/down, credit usage, fork) until the agent has
/// produced real output — otherwise the footer renders awkwardly above the still-
/// pending optimistic user prompt.
pub(super) is_cloud_agent_pre_first_exchange: bool,
}

pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
Expand Down Expand Up @@ -245,6 +251,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
&& !is_output_for_static_prompt_suggestions
&& !is_conversation_in_progress
&& request_type.is_active()
&& !props.is_cloud_agent_pre_first_exchange
&& !status
.error()
.map(|e| e.is_invalid_api_key())
Expand Down
55 changes: 47 additions & 8 deletions app/src/ai/blocklist/controller/shared_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ impl BlocklistAIController {
let existing_conversation_id =
self.find_existing_conversation_by_server_token(&init_event.conversation_id, ctx);
let conversation_id = existing_conversation_id
.inspect(|conversation_id| {
// The local conversation is bound to a cloud-side session, so the cloud agent
// is the source of truth for user inputs going forward. Mark it as a shared-
// session view so `apply_client_actions` reconstructs UserQuery / ActionResult
// inputs from the cloud agent's response messages — without this, the local
// exchange's inputs stay empty and the AI block has no user query to render.
// Idempotent for conversations that already have the flag set (e.g. regular
// cloud mode, where `start_new_conversation` set it at creation time);
// important for REMOTE-1519 local-to-cloud handoff, where the local fork
// started as a non-shared-session conversation.
history.update(ctx, |history, _| {
history.set_viewing_shared_session_for_conversation(*conversation_id, true);
});
})
.or_else(|| {
let selected_conversation_id = self
.context_model
Expand Down Expand Up @@ -150,9 +164,11 @@ impl BlocklistAIController {
h.start_new_conversation(terminal_view_id, false, true, ctx)
})
});
if self
.should_skip_replayed_response_for_existing_conversation(existing_conversation_id, ctx)
{
if self.should_skip_replayed_response_for_existing_conversation(
existing_conversation_id,
&init_event.request_id,
ctx,
) {
self.shared_session_state.current_response_id = Some(stream_id);
self.shared_session_state
.should_skip_current_replayed_response = true;
Expand Down Expand Up @@ -220,22 +236,45 @@ impl BlocklistAIController {
fn should_skip_replayed_response_for_existing_conversation(
&self,
existing_conversation_id: Option<AIConversationId>,
init_request_id: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(conversation_id) = existing_conversation_id else {
return false;
};
let model = self.terminal_model.lock();
if !model.is_receiving_agent_conversation_replay()
|| !model.should_suppress_existing_agent_conversation_replay()
{
let is_receiving_replay = model.is_receiving_agent_conversation_replay();
let should_suppress = model.should_suppress_existing_agent_conversation_replay();
if !is_receiving_replay || !should_suppress {
return false;
}
drop(model);

BlocklistAIHistoryModel::as_ref(ctx)
// Only skip the replayed response stream when we already have a local
// exchange whose `server_output_id` matches its `request_id`. New
// exchanges that the cloud agent appended after the local fork (e.g.
// the user's first submitted prompt for a REMOTE-1519 local-to-cloud
// handoff pane) carry request_ids we have never seen and must flow
// through normally so the viewer's blocklist picks them up.
let history = BlocklistAIHistoryModel::as_ref(ctx);
let known_server_output_ids: Vec<String> = history
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.exchange_count() > 0)
.map(|conversation| {
conversation
.all_exchanges()
.into_iter()
.filter_map(|exchange| {
exchange
.output_status
.server_output_id()
.map(|sid| sid.to_string())
})
.collect()
})
.unwrap_or_default();
known_server_output_ids
.iter()
.any(|sid| sid == init_request_id)
}

fn on_shared_client_actions(
Expand Down
12 changes: 7 additions & 5 deletions app/src/ai/blocklist/handoff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
//! filesystem path the local agent has touched, groups those paths into git
//! roots and orphan files, and exposes the env-overlap pick used by the
//! handoff pane bootstrap.
//! - `orchestrator`: drives the prep + upload phases of the handoff off the main
//! thread. The actual cloud-agent spawn happens inside the handoff pane's
//! `AmbientAgentViewModel::submit_handoff` so the regular streaming spawn flow
//! (loading screen, shared-session join) is reused unchanged.
//!
//! The chip-click open path lives in `Workspace::start_local_to_cloud_handoff`
//! and drives the prep-fork RPC + the async snapshot upload directly via
//! `AIClient::prepare_handoff_fork` and `agent_sdk::driver::upload_snapshot_for_handoff`.
//! The actual cloud-agent spawn happens inside the handoff pane's
//! `AmbientAgentViewModel::submit_handoff`, which reads the cached
//! `forked_conversation_id` and `snapshot_prep_token` off `PendingHandoff`.

pub(crate) mod orchestrator;
pub(crate) mod touched_repos;
Loading