Skip to content
Merged
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
17 changes: 17 additions & 0 deletions app/src/editor/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3235,6 +3235,23 @@ impl EditorView {
ctx.emit(Event::BufferReinitialized);
}

/// Exits the ephemeral loading state created by `set_buffer_text_ignoring_undo`
/// without touching the CRDT buffer or emitting any `UpdatePeers` operations.
/// The editor switches back to displaying the regular collaborative buffer.
pub fn exit_ephemeral_loading_state(&mut self, ctx: &mut ViewContext<Self>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, we don't really have an 'loading' state anymore right (or at least a visual loading state)? I guess the ephemeral buffer is the loading state?

@seemeroland seemeroland Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea we do still have the UI of making the input gray and adding a spinner between viewer submitting prompt and receiving the ack - that's the ephemeral buffer for loading state

self.editor_model.update(ctx, |model, ctx| {
model.exit_ephemeral_loading_state(ctx);
});
}

/// Shows an empty display-only ephemeral overlay for immediate visual feedback.
/// See [`EditorModel::show_display_only_empty_buffer`] for the full contract.
pub fn show_display_only_empty_buffer(&mut self, ctx: &mut ViewContext<Self>) {
self.editor_model.update(ctx, |model, ctx| {
model.show_display_only_empty_buffer(ctx);
});
}

pub fn register_remote_peer(
&mut self,
replica_id: ReplicaId,
Expand Down
78 changes: 71 additions & 7 deletions app/src/editor/view/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ struct BufferAndDisplayMaps {
/// A buffer and display map dedicated for ephemeral edits (see [`UpdateBufferOption::IsEphemeral`]).
/// If [`Some`], then the ephemeral buffer is active.
ephemeral: Option<(ModelHandle<Buffer>, ModelHandle<DisplayMap>)>,

/// When `true`, the active ephemeral buffer is "display-only": it exists purely for
/// visual feedback and its content must NOT be applied to the regular buffer when the
/// ephemeral is exited (materialized). On materialization the ephemeral is simply
/// discarded and the edit proceeds directly on the regular buffer without any
/// content-restoration step. This avoids generating spurious CRDT delete operations
/// that would corrupt the shared collaborative state.
ephemeral_is_display_only: bool,
}

impl BufferAndDisplayMaps {
Expand All @@ -371,9 +379,11 @@ impl BufferAndDisplayMaps {
/// Deactivates any ephemeral state.
fn deactivate_ephemeral_state(&mut self) {
self.ephemeral.take();
self.ephemeral_is_display_only = false;
}

/// Activates a new ephemeral state.
/// Activates a new regular ephemeral state whose content will be applied
/// to the regular buffer when the ephemeral is exited (materialized).
fn activate_new_ephemeral_state(&mut self, ctx: &mut ModelContext<EditorModel>) {
let tab_size = self.regular.1.as_ref(ctx).tab_size();
let ephemeral_buffer = ctx.add_model(|_| Buffer::new(""));
Expand All @@ -388,6 +398,15 @@ impl BufferAndDisplayMaps {
EditorModel::handle_display_map_event,
);
self.ephemeral = Some((ephemeral_buffer, ephemeral_display_map));
self.ephemeral_is_display_only = false;
}

/// Activates a display-only ephemeral state. When this ephemeral is materialized
/// (exited by a non-ephemeral edit), its content is discarded rather than applied
/// to the regular buffer, preventing spurious CRDT operations.
fn activate_display_only_ephemeral_state(&mut self, ctx: &mut ModelContext<EditorModel>) {
self.activate_new_ephemeral_state(ctx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if a viewer submits a prompt while in the ephemeral state?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried and wasn't able to

self.ephemeral_is_display_only = true;
}
}

Expand Down Expand Up @@ -538,6 +557,7 @@ impl EditorModel {
buffer_and_display_map: BufferAndDisplayMaps {
regular,
ephemeral: None,
ephemeral_is_display_only: false,
},
vim_visual_tails: vec![],
consecutive_autocomplete_insertion_edits_counter: 0,
Expand Down Expand Up @@ -603,6 +623,31 @@ impl EditorModel {
self.buffer_and_display_map.deactivate_ephemeral_state();
}

/// Exits an ephemeral loading state (created by `set_buffer_text_ignoring_undo`)
/// without touching the CRDT buffer or generating any `UpdatePeers` operations.
/// After this call the editor displays the regular collaborative buffer, allowing
/// any pending remote delete operations to become visible.
pub fn exit_ephemeral_loading_state(&mut self, ctx: &mut ModelContext<Self>) {
if self.is_ephemeral() {
self.buffer_and_display_map.deactivate_ephemeral_state();
ctx.notify();
}
}

/// Shows an empty buffer as a display-only ephemeral overlay for immediate visual
/// feedback, without touching the regular CRDT buffer or emitting `UpdatePeers` ops.
///
/// When the viewer next makes an edit (materializing the ephemeral), the empty content
/// is **discarded** rather than applied to the regular buffer — so no spurious CRDT
/// delete ops are generated for whatever is currently in the regular buffer (e.g.
/// another viewer's concurrent edits). The edit instead proceeds directly on the
/// regular buffer as-is.
pub fn show_display_only_empty_buffer(&mut self, ctx: &mut ModelContext<Self>) {
self.buffer_and_display_map
.activate_display_only_ephemeral_state(ctx);
ctx.notify();
}

fn refresh_batch_version(&mut self, ctx: &mut ModelContext<Self>) {
self.buffer_handle().update(ctx, |buffer, _| {
buffer.refresh_version_on_edits_and_selection_changes_batch()
Expand Down Expand Up @@ -682,11 +727,20 @@ impl EditorModel {
.activate_new_ephemeral_state(ctx);
Some(snapshot)
} else if can_edit && self.is_ephemeral() && edit.update_buffer.is_some() {
// We're materializing an ephemeral edit, so snapshot the ephemeral buffer
// so that we can apply it to the regular buffer.
let snapshot = self.as_snapshot(ctx);
self.buffer_and_display_map.deactivate_ephemeral_state();
Some(snapshot)
if self.buffer_and_display_map.ephemeral_is_display_only {
// Display-only ephemeral: discard the ephemeral content entirely and
// proceed directly on the regular buffer. Do NOT snapshot-and-restore,
// which would generate spurious CRDT delete ops for whatever the regular
// buffer currently contains (e.g. another viewer's concurrent edits).
self.buffer_and_display_map.deactivate_ephemeral_state();
None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Discarding the display-only ephemeral here lets the first edit before the sharer's CRDT clear arrives run against the regular buffer that still contains the submitted prompt; backspace/delete or selection edits can emit CRDT ops against hidden prompt text, so keep the input non-editable until the clear arrives or materialize against an already-cleared regular buffer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested viewer typing immediately after sending a query. It's fine - there's a brief moment the inputs are appended to the prompt, but the correct prompt is sent, and the inputs end up in the new buffer (and are consistent across partaicipants)

} else {
// Regular ephemeral (history picker, model selector, etc.): snapshot the
// ephemeral buffer so its content can be applied to the regular buffer.
let snapshot = self.as_snapshot(ctx);
self.buffer_and_display_map.deactivate_ephemeral_state();
Some(snapshot)
}
} else {
None
};
Expand Down Expand Up @@ -771,7 +825,17 @@ impl EditorModel {
if let Err(e) = buffer.apply_ops(operations, ctx) {
log::warn!("Failed to apply remote edits to buffer: {e}");
}
})
});

// If a display-only empty ephemeral is showing (optimistic clear after sending
// an agent prompt), exit it now that a real CRDT update has arrived. This makes
// the actual collaborative buffer state immediately visible to the viewer, whether
// that's an empty buffer from the sharer's delete ops or another participant's
// concurrent edits.
if self.buffer_and_display_map.ephemeral_is_display_only {
self.buffer_and_display_map.deactivate_ephemeral_state();
ctx.notify();
}
}

pub fn interaction_state(&self) -> InteractionState {
Expand Down
44 changes: 37 additions & 7 deletions app/src/terminal/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7367,22 +7367,52 @@ impl Input {
shared_session_input_state.pending_command_execution_request = None;
}

/// This clears the loading state and input buffer for both the sharer and viewer
/// once an agent request is in flight or cancelled.
pub fn unfreeze_and_clear_agent_input(&mut self, ctx: &mut ViewContext<Self>) {
/// Restores the frozen/loading visual state of the agent input for both the sharer
/// and viewer without touching the CRDT buffer contents.
///
/// Does NOT clear or reinitialize the buffer. Buffer clearing for agent prompts is
/// handled by the sharer emitting CRDT delete operations via `system_clear_buffer`
/// (triggered when `BlocklistAIControllerEvent::SentRequest` fires). Viewers receive
/// those delete ops through `InputUpdated` and apply them via the normal CRDT path.
///
/// For viewers, this exits the ephemeral loading state created by
/// `freeze_input_in_loading_state`. When `is_shared_session_viewer_prompt_inflight` is true,
/// we optimistically clear the buffer using a display-only empty ephemeral
/// so the viewer sees an empty buffer immediately before crdt operations for actually clearing
/// the real buffer are received from the sharer.
///
/// The display-only ephemeral is safe for CRDT: when the viewer next makes an edit
/// (materializing the ephemeral), its empty content is **discarded** — no delete ops
/// are generated for the regular buffer's contents. The edit proceeds directly on
/// the regular buffer (which the sharer's delete ops will have cleared by then).
pub fn unfreeze_agent_input(
&mut self,
is_shared_session_viewer_prompt_inflight: bool,
ctx: &mut ViewContext<Self>,
) {
if matches!(
self.model.lock().shared_session_status(),
SharedSessionStatus::ActiveViewer { .. } | SharedSessionStatus::ActiveSharer
) {
self.editor.update(ctx, |editor, ctx| {
// Reinitialize the buffer to properly clear it
editor.reinitialize_buffer(None, ctx);

if let SharedSessionStatus::ActiveViewer { role } =
self.model.lock().shared_session_status()
{
// reinstate role for viewers
editor.set_interaction_state(role.into(), ctx);
// Exit the ephemeral loading state so the regular CRDT buffer is
// accessible. The sharer's delete ops (arriving via InputUpdated)
// will clear the regular buffer.
editor.exit_ephemeral_loading_state(ctx);
if is_shared_session_viewer_prompt_inflight {
// Create a display-only empty ephemeral for immediate visual
// feedback. This is an optimistic clear for UI purposes, without
// affecting the real buffer synced by crdt operations.
// Unlike a regular ephemeral, materializing this one
// discards its content instead of restoring it to the regular
// buffer, so no spurious CRDT delete ops are generated.
editor.show_display_only_empty_buffer(ctx);
}
}

let appearance: &Appearance = Appearance::as_ref(ctx);
Expand Down Expand Up @@ -14433,7 +14463,7 @@ impl Input {
// Prepare request failed (e.g. attachment limit exceeded).
// Keep pending attachments so the user can retry, unfreeze input,
// and show an error toast.
input.unfreeze_and_clear_agent_input(ctx);
input.unfreeze_agent_input(false, ctx);
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
Expand Down
74 changes: 74 additions & 0 deletions app/src/terminal/input_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8683,6 +8683,80 @@ fn ctrl_enter_inserts_newline_when_submit_on_ctrl_enter_is_false() {
});
}

/// `unfreeze_agent_input` must NOT clear the buffer. The buffer is cleared via CRDT
/// delete ops emitted by `system_clear_buffer` when `SentRequest` fires, which flow to
/// both the server (for new viewers) and existing viewers (via `InputUpdated`).
/// Clearing the buffer here would cause CRDT inconsistencies (see the function doc).
#[test]
fn unfreeze_agent_input_does_not_clear_buffer() {
App::test((), |mut app| async move {
initialize_app(&mut app);

let tips_model = app.add_model(|_| TipsCompleted::default());

// Test for ActiveSharer
let (_, sharer_terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
TerminalView::new_for_test(tips_model, None, ctx)
});
sharer_terminal.update(&mut app, |view, _| {
let mut model = view.model.lock();
model.block_list_mut().set_bootstrapped();
model.set_shared_session_status(SharedSessionStatus::ActiveSharer);
});
let sharer_input = sharer_terminal.read(&app, |view, _| view.input().clone());

sharer_input.update(&mut app, |input, ctx| {
input.replace_buffer_content("help me write a test", ctx);
});
assert_eq!(
sharer_input.read(&app, |i, ctx| i.buffer_text(ctx)),
"help me write a test"
);

sharer_input.update(&mut app, |input, ctx| {
input.unfreeze_agent_input(false, ctx);
});

// Buffer must be unchanged — clearing is the responsibility of system_clear_buffer
// via the SentRequest event, not of this unfreeze function.
assert_eq!(
sharer_input.read(&app, |i, ctx| i.buffer_text(ctx)),
"help me write a test",
"unfreeze_agent_input must not clear the sharer's buffer"
);

// Same for ActiveViewer
let tips_model2 = app.add_model(|_| TipsCompleted::default());
let (_, viewer_terminal) = app.add_window(WindowStyle::NotStealFocus, move |ctx| {
TerminalView::new_for_test(tips_model2, None, ctx)
});
viewer_terminal.update(&mut app, |view, _| {
let mut model = view.model.lock();
model.block_list_mut().set_bootstrapped();
model.set_shared_session_status(SharedSessionStatus::executor());
});
let viewer_input = viewer_terminal.read(&app, |view, _| view.input().clone());

viewer_input.update(&mut app, |input, ctx| {
input.replace_buffer_content("follow-up question", ctx);
});
assert_eq!(
viewer_input.read(&app, |i, ctx| i.buffer_text(ctx)),
"follow-up question"
);

viewer_input.update(&mut app, |input, ctx| {
input.unfreeze_agent_input(false, ctx);
});

assert_eq!(
viewer_input.read(&app, |i, ctx| i.buffer_text(ctx)),
"follow-up question",
"unfreeze_agent_input must not clear the viewer's buffer"
);
});
}

#[test]
fn ctrl_enter_inserts_newline_in_normal_input_after_rich_input_closes() {
use crate::editor::EnterAction;
Expand Down
5 changes: 3 additions & 2 deletions app/src/terminal/local_tty/terminal_view_adaptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,9 +1373,10 @@ impl TerminalManager<TerminalView> {

// Execute the agent prompt in the Oz-harness case
terminal_view.update(ctx, |view, ctx| {
// Clear the sharer's input (as the prompt in the input is now being executed)
// Restore the sharer's frozen visual state. The buffer is cleared by
// system_clear_buffer when SentRequest fires from execute_agent_prompt_for_shared_session.
view.input().update(ctx, |input, ctx| {
input.unfreeze_and_clear_agent_input(ctx);
input.unfreeze_agent_input(false, ctx);
});

view.ai_controller().update(ctx, |ai_controller, ctx| {
Expand Down
12 changes: 11 additions & 1 deletion app/src/terminal/shared_session/viewer/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,17 @@ impl EventLoop {
return;
}
view.input().update(ctx, |input, ctx| {
input.unfreeze_and_clear_agent_input(ctx);
// Restore frozen visual state. Then also reinitialize the
// buffer here: for shell commands the block transition will
// reset the CRDT with a new block ID shortly after, so the
// brief CRDT inconsistency is harmless. This pre-emptive
// clear gives the viewer an empty buffer while the command
// runs rather than showing the command text.
input.unfreeze_agent_input(false, ctx);
let editor = input.editor().clone();
editor.update(ctx, |editor, ctx| {
editor.reinitialize_buffer(None, ctx);
});
});
});
}
Expand Down
12 changes: 10 additions & 2 deletions app/src/terminal/shared_session/viewer/terminal_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,8 +1205,13 @@ impl TerminalManager {
return;
};
view.update(ctx, |terminal_view, ctx| {
// Restore frozen visual state. optimistically_show_empty=true creates
// a display-only empty ephemeral for immediate UX feedback. Unlike a
// regular ephemeral, this one discards its content on materialization
// instead of restoring it to the regular buffer, so no spurious CRDT
// delete ops are generated for concurrent edits by other viewers.
terminal_view.input().update(ctx, |input, ctx| {
input.unfreeze_and_clear_agent_input(ctx);
input.unfreeze_agent_input(true, ctx);
});
});
}
Expand All @@ -1218,8 +1223,11 @@ impl TerminalManager {
let reason_string = agent_prompt_failure_reason_string(reason);
terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx);

// Restore frozen visual state without clearing the buffer — the prompt
// failed so no CRDT delete ops were sent, and the user should be able
// to retry with their original text.
terminal_view.input().update(ctx, |input, ctx| {
input.unfreeze_and_clear_agent_input(ctx);
input.unfreeze_agent_input(false, ctx);
});
});
}
Expand Down
Loading