Files
galaxy/app/src/ai/ai_document_view.rs
T

1283 lines
49 KiB
Rust

#[cfg(feature = "local_fs")]
use std::path::PathBuf;
use std::sync::Arc;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::document::ai_document_model::{AIDocumentSaveStatus, AIDocumentUserEditStatus};
use crate::appearance::Appearance;
use crate::drive::{items::WarpDriveItemId, sharing::ShareableObject, CloudObjectTypeAndId};
use crate::notebooks::editor::view::RichTextEditorConfig;
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view::header::components::{
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
CenteredHeaderEdgeWidth,
};
use crate::pane_group::pane::view::header::toolbelt_button_position_id;
use crate::pane_group::pane::view::header::PaneHeaderAction;
use crate::send_telemetry_from_ctx;
use crate::settings::FontSettings;
use crate::terminal::input::MenuPositioning;
use crate::terminal::view::TerminalView;
use crate::util::bindings::keybinding_name_to_keystroke;
use crate::view_components::action_button::{
ButtonSize, NakedTheme, SecondaryTheme, TooltipAlignment,
};
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;
use crate::BlocklistAIHistoryModel;
use crate::{
ai::document::ai_document_model::{
AIDocumentId, AIDocumentInstance, AIDocumentModel, AIDocumentModelEvent,
AIDocumentUpdateSource, AIDocumentVersion,
},
editor::InteractionState,
menu::{Menu, MenuItem, MenuItemFields},
notebooks::{
editor::{
model::NotebooksEditorModel,
rich_text_styles,
view::{EditorViewEvent, RichTextEditorView},
},
link::{NotebookLinks, SessionSource},
},
pane_group::{pane::view, BackingView, PaneConfiguration, PaneEvent},
server::telemetry::TelemetryEvent,
ui_components::buttons::icon_button,
ui_components::icons::Icon,
view_components::action_button::{ActionButton, PrimaryTheme},
};
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::icons;
use galaxy_core::ui::icons::ICON_DIMENSIONS;
use galaxy_core::ui::theme::Fill as ThemeFill;
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::CrossAxisAlignment;
use galaxyui::elements::MainAxisAlignment;
use galaxyui::elements::MainAxisSize;
use galaxyui::elements::{ChildAnchor, PositionedElementAnchor, PositionedElementOffsetBounds};
use galaxyui::keymap::EditableBinding;
use galaxyui::keymap::FixedBinding;
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::button::ButtonTooltipPosition;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{
elements::{
ChildView, ConstrainedBox, Container, Flex, Hoverable, MouseStateHandle, OffsetPositioning,
ParentElement, SavePosition, Stack,
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use galaxyui::{id, EntityId};
pub fn init(app: &mut AppContext) {
app.register_editable_bindings([EditableBinding::new(
// Reuse the save file keybinding name and description
// so that there's only one entry in settings reused for both cases.
SAVE_FILE_BINDING_NAME,
SAVE_FILE_BINDING_DESCRIPTION,
AIDocumentAction::SendUpdatedPlan,
)
.with_context_predicate(id!("AIDocumentView") & !id!("IMEOpen"))
.with_key_binding("cmdorctrl-s")]);
// Allow closing the AI document pane with the same toggle keybinding used in Terminal
app.register_fixed_bindings([FixedBinding::new(
"cmdorctrl-alt-p",
AIDocumentAction::Close,
id!("AIDocumentView") & !id!("IMEOpen"),
)]);
}
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::settings::EditorLayout;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
#[cfg(feature = "local_fs")]
use galaxy_util::path::LineAndColumnArg;
// Import keybinding constants from code view to ensure consistency
use crate::code::view::{SAVE_FILE_BINDING_DESCRIPTION, SAVE_FILE_BINDING_NAME};
#[derive(Debug, Clone, PartialEq)]
pub enum AIDocumentAction {
Close,
SelectVersion(AIDocumentVersion),
Export,
OpenVersionMenu,
CreateWarpDriveNotebook,
RevertToDocumentVersion,
SendUpdatedPlan,
CopyLink(String),
CopyPlanId,
ShowInWarpDrive,
AttachToActiveSession,
}
#[derive(Debug, Clone)]
pub enum AIDocumentEvent {
Pane(PaneEvent),
CloseRequested,
ViewInWarpDrive(WarpDriveItemId),
#[cfg(feature = "local_fs")]
OpenCodeInWarp {
source: CodeSource,
layout: EditorLayout,
line_col: Option<LineAndColumnArg>,
},
#[cfg(feature = "local_fs")]
OpenFileWithTarget {
path: std::path::PathBuf,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
},
AttachPlanAsContext(AIDocumentId),
}
impl From<PaneEvent> for AIDocumentEvent {
fn from(event: PaneEvent) -> Self {
AIDocumentEvent::Pane(event)
}
}
pub const DEFAULT_PLANNING_DOCUMENT_TITLE: &str = "Planning document";
/// Entry for the version history dropdown menu.
struct VersionMenuEntry {
version: AIDocumentVersion,
created_at: chrono::DateTime<chrono::Local>,
restored_from: Option<AIDocumentVersion>,
}
pub struct AIDocumentView {
document_id: AIDocumentId,
document_version: AIDocumentVersion,
links: ModelHandle<NotebookLinks>,
editor: ViewHandle<RichTextEditorView>,
pane_configuration: ModelHandle<PaneConfiguration>,
focus_handle: Option<PaneFocusHandle>,
original_terminal_view: Option<ViewHandle<TerminalView>>,
// Version menu state
version_menu: ViewHandle<Menu<AIDocumentAction>>,
sync_button_mouse_state: MouseStateHandle,
update_plan_button: ViewHandle<ActionButton>,
restore_button: ViewHandle<ActionButton>,
is_version_menu_open: bool,
version_button_position_id: String,
synced_status_mouse_state: MouseStateHandle,
view_position_id: String,
version_button: ViewHandle<ActionButton>,
}
impl AIDocumentView {
pub fn new(
document_id: AIDocumentId,
document_version: AIDocumentVersion,
ctx: &mut ViewContext<Self>,
) -> Self {
let window_id = ctx.window_id();
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
// Subscribe to document model updates so we can react to agent changes
ctx.subscribe_to_model(
&AIDocumentModel::handle(ctx),
move |me, _, event, ctx| match event {
AIDocumentModelEvent::DocumentUpdated {
document_id,
version,
source,
} => {
// Only handle updates for our document.
// If the agent created a new version, auto update this view to the newest version.
if document_id == &me.document_id {
match source {
AIDocumentUpdateSource::Agent => {
me.document_version = *version;
me.refresh(ctx);
}
// Restoration is used for both persisted restore and
// shared-session viewer mirroring.
AIDocumentUpdateSource::Restoration => {
let is_shared_session_view = AIDocumentModel::as_ref(ctx)
.get_conversation_id_for_document_id(document_id)
.and_then(|conv_id| {
BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conv_id)
.map(|c| c.is_viewing_shared_session())
})
.unwrap_or(false);
if is_shared_session_view {
// For shared-session viewers mirrored updates represent the live truth,
// so we always follow the latest version.
me.document_version = *version;
me.refresh(ctx);
} else if *version == me.document_version {
// For normal persisted restoration, only refresh when restoration
// targets the version this pane was opened for.
me.document_version = *version;
me.refresh(ctx);
}
}
_ => {}
}
}
}
AIDocumentModelEvent::DocumentSaveStatusUpdated(id) => {
if *id != document_id {
return;
}
me.update_header_buttons(ctx);
}
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id: id,
status,
} => {
if *id != document_id {
return;
}
// Auto-set pending document ID when document becomes dirty
if status.is_dirty() {
if let Some(terminal_view) = &me.original_terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.ai_context_model().update(
ctx,
|context_model, ctx| {
context_model.set_pending_document(Some(document_id), ctx);
},
);
});
}
}
me.update_header_buttons(ctx);
}
AIDocumentModelEvent::StreamingDocumentsCleared(_) => {
me.refresh(ctx);
}
AIDocumentModelEvent::DocumentVisibilityChanged(_) => {}
},
);
// Subscribe to conversation status changes to update buttons
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
move |me, _, event, ctx| {
use crate::ai::blocklist::BlocklistAIHistoryEvent;
match event {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
terminal_view_id, ..
} => {
// Check if this is our terminal view
if let Some(tv) = &me.original_terminal_view {
if tv.id() == *terminal_view_id {
me.update_header_buttons(ctx);
}
}
}
BlocklistAIHistoryEvent::RestoredConversations {
terminal_view_id,
conversation_ids,
} => {
// Try to populate terminal view if conversations were restored
me.maybe_populate_terminal_view(*terminal_view_id, conversation_ids, ctx);
}
_ => {}
}
},
);
let view_position_id = format!("ai_document_view_{}", ctx.view_id());
// Get the initial editor model, or create an empty one if the document isn't found
let initial_editor_model = AIDocumentModel::as_ref(ctx)
.get_document(&document_id, document_version)
.map(|doc| doc.get_editor())
.unwrap_or_else(|| {
log::warn!("AI document {document_id} version {document_version} not found in model, using empty editor");
ctx.add_model(|ctx| {
let appearance = Appearance::as_ref(ctx);
let font_settings = FontSettings::as_ref(ctx);
let styles = rich_text_styles(appearance, font_settings);
NotebooksEditorModel::new_unbound(styles, ctx)
})
});
let editor = ctx.add_typed_action_view(|ctx| {
RichTextEditorView::new(
view_position_id.clone(),
initial_editor_model.clone(),
links.clone(),
RichTextEditorConfig::default(),
ctx,
)
});
editor.update(ctx, |editor_view, ctx| {
editor_view
.model()
.update(ctx, |model, ctx| model.set_window_id(window_id, ctx));
});
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
let document_title = AIDocumentModel::as_ref(ctx)
.get_document(&document_id, document_version)
.map(|doc| doc.get_title())
.unwrap_or_else(|| DEFAULT_PLANNING_DOCUMENT_TITLE.to_string());
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new(document_title));
// Create version menu view and subscribe to close events to hide overlay
let version_menu = ctx.add_typed_action_view(|_| Menu::new().with_width(220.));
ctx.subscribe_to_view(&version_menu, |me, _, event, ctx| {
if let crate::menu::Event::Close { .. } = event {
me.is_version_menu_open = false;
me.version_menu
.update(ctx, |menu, ctx| menu.reset_selection(ctx));
ctx.notify();
}
});
// Anchor overlay to the toolbelt "Show version history" button
let version_button = ctx.add_typed_action_view(|_| {
ActionButton::new("", NakedTheme)
.with_icon(icons::Icon::History)
.with_size(ButtonSize::Small)
.with_tooltip("Show version history")
.on_click(|ctx| {
ctx.dispatch_typed_action(
PaneHeaderAction::<AIDocumentAction, AIDocumentAction>::CustomAction(
AIDocumentAction::OpenVersionMenu,
),
);
})
});
let version_button_position_id =
toolbelt_button_position_id(&pane_configuration, version_button.id());
pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.refresh_pane_header_overflow_menu_items(ctx)
});
// Create sync button mouse state (for Warp Drive syncing)
let sync_button_mouse_state = MouseStateHandle::default();
// Create Update Agent button
// Read the actual configured keybinding for the save action
let save_action = keybinding_name_to_keystroke(SAVE_FILE_BINDING_NAME, ctx)
.map(|k| k.displayed())
.unwrap_or("Click".to_string());
let tooltip_text = format!("This plan has changes the agent isn't aware of. {save_action} to stop the agent's current task and send the updated plan");
let update_plan_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Update Agent", PrimaryTheme)
.with_size(ButtonSize::Small)
.with_tooltip(tooltip_text)
.with_tooltip_alignment(TooltipAlignment::Right)
.with_tooltip_positioning_provider(Arc::new(MenuPositioning::BelowInputBox))
.on_click(|ctx| {
ctx.dispatch_typed_action(
PaneHeaderAction::<AIDocumentAction, AIDocumentAction>::CustomAction(
AIDocumentAction::SendUpdatedPlan,
),
);
})
});
// Create restore button
let restore_button = ctx.add_typed_action_view(|_ctx| {
ActionButton::new("Restore", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(
PaneHeaderAction::<AIDocumentAction, AIDocumentAction>::CustomAction(
AIDocumentAction::RevertToDocumentVersion,
),
);
})
});
let mut me = Self {
document_id,
document_version,
links,
editor,
pane_configuration,
focus_handle: None,
original_terminal_view: None,
version_menu,
sync_button_mouse_state,
update_plan_button,
restore_button,
is_version_menu_open: false,
version_button_position_id,
synced_status_mouse_state: MouseStateHandle::default(),
view_position_id,
version_button,
};
// Force update the editor view based on the initial document version
me.refresh(ctx);
me
}
pub fn pane_configuration(&self) -> &ModelHandle<PaneConfiguration> {
&self.pane_configuration
}
pub fn document_id(&self) -> &AIDocumentId {
&self.document_id
}
pub fn document_version(&self) -> AIDocumentVersion {
self.document_version
}
pub fn selected_text(&self, ctx: &AppContext) -> Option<String> {
let selected_text = self
.editor
.as_ref(ctx)
.model()
.as_ref(ctx)
.selected_text(ctx);
if selected_text.is_empty() {
return None;
}
Some(selected_text)
}
pub fn set_original_terminal_view(&mut self, terminal_view: Option<ViewHandle<TerminalView>>) {
self.original_terminal_view = terminal_view;
}
/// Get the terminal view for this document. Returns None if no terminal is associated.
pub fn terminal_view(&self) -> Option<ViewHandle<TerminalView>> {
self.original_terminal_view.clone()
}
/// Attempts to populate the terminal view reference if not already set.
/// This is called when conversations are restored, allowing us to find the
/// terminal view associated with this document's conversation.
fn maybe_populate_terminal_view(
&mut self,
terminal_view_id: EntityId,
conversation_ids: &[AIConversationId],
ctx: &mut ViewContext<Self>,
) {
// If we already have a terminal view, no need to update
if self.original_terminal_view.is_some() {
return;
}
// Get conversation ID from document
let Some(document_conversation_id) =
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&self.document_id)
else {
return;
};
// Check if our document's conversation is in the restored conversations
if !conversation_ids.contains(&document_conversation_id) {
return;
}
// Search for the terminal view by ID
let window_id = ctx.window_id();
if let Some(terminal_views) = ctx.views_of_type::<TerminalView>(window_id) {
if let Some(terminal_view) = terminal_views
.into_iter()
.find(|tv| tv.id() == terminal_view_id)
{
self.original_terminal_view = Some(terminal_view);
ctx.notify();
}
}
}
/// Returns true if the conversation associated with this document is actively streaming.
fn is_conversation_streaming(&self, ctx: &AppContext) -> bool {
let document_model = AIDocumentModel::handle(ctx);
let Some(conversation_id) = document_model
.as_ref(ctx)
.get_conversation_id_for_document_id(&self.document_id)
else {
return false;
};
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(&conversation_id) else {
return false;
};
conversation.status().is_in_progress()
}
/// Refresh the view to match the current state of the document model
fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
let Some(document) =
AIDocumentModel::as_ref(ctx).get_document(&self.document_id, self.document_version)
else {
return;
};
// Document is read-only if it's an earlier version OR if we're still streaming the plan.
// Once streaming completes, the current version becomes editable.
let is_earlier_version = matches!(document, AIDocumentInstance::Earlier(_));
let is_document_creation_streaming =
AIDocumentModel::as_ref(ctx).is_document_creation_streaming(&self.document_id);
let is_read_only = is_earlier_version || is_document_creation_streaming;
self.set_editor_model(document.get_editor(), is_read_only, ctx);
self.update_header_buttons(ctx);
// Update pane title to the document title
let title = document.get_title();
self.pane_configuration.update(ctx, |pc, ctx| {
pc.set_title(title, ctx);
});
}
fn update_header_buttons(&mut self, ctx: &mut ViewContext<Self>) {
let server_id = AIDocumentModel::as_ref(ctx)
.get_current_document(&self.document_id)
.and_then(|doc| doc.sync_id)
.and_then(|sync_id| sync_id.into_server());
self.pane_configuration.update(ctx, |pc, ctx| {
pc.set_shareable_object(server_id.map(ShareableObject::WarpDriveObject), ctx);
pc.refresh_pane_header_overflow_menu_items(ctx);
});
ctx.notify();
}
fn render_header_buttons(&self, app: &AppContext) -> Option<Box<dyn Element>> {
let document = AIDocumentModel::as_ref(app).get_current_document(&self.document_id);
let is_current_version = document
.as_ref()
.map(|doc| doc.version == self.document_version)
.unwrap_or(true);
// If viewing an older version, show restore button
if !is_current_version {
let restore_button = self.restore_button.clone();
return Some(
Container::new(ChildView::new(&restore_button).finish())
.with_margin_right(8.)
.finish(),
);
}
let user_edit_status = document
.as_ref()
.map(|doc| doc.user_edit_status)
.unwrap_or(AIDocumentUserEditStatus::UpToDate);
let save_status = AIDocumentModel::as_ref(app).get_document_save_status(&self.document_id);
let is_streaming = self.is_conversation_streaming(app);
let sync_element = self.render_sync_element(save_status, app);
if is_streaming && user_edit_status.is_dirty() {
let update_plan_button = self.update_plan_button.clone();
Some(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(ChildView::new(&update_plan_button).finish())
.with_child(Container::new(sync_element).with_margin_left(4.).finish())
.finish(),
)
} else {
Some(sync_element)
}
}
/// Renders the sync/save status element based on save status.
fn render_sync_element(
&self,
save_status: AIDocumentSaveStatus,
app: &AppContext,
) -> Box<dyn Element> {
match save_status {
AIDocumentSaveStatus::NotSaved => {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder().clone();
let tooltip = ui_builder
.tool_tip("Save and auto-sync this plan to your Warp Drive".to_string())
.build()
.finish();
let sync_button_mouse_state = self.sync_button_mouse_state.clone();
icon_button(
appearance,
Icon::RefreshCw04,
false,
sync_button_mouse_state,
)
.with_tooltip(move || tooltip)
.with_tooltip_position(ButtonTooltipPosition::BelowRight)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
PaneHeaderAction::<AIDocumentAction, AIDocumentAction>::CustomAction(
AIDocumentAction::CreateWarpDriveNotebook,
),
)
})
.finish()
}
AIDocumentSaveStatus::Saving => {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let color = theme.nonactive_ui_detail().into_solid();
Container::new(
ConstrainedBox::new(
Container::new(
ConstrainedBox::new(
Icon::RefreshCw04
.to_galaxyui_icon(ThemeFill::Solid(color))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(4.)
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish(),
)
.finish()
}
AIDocumentSaveStatus::Saved => {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let color = theme.nonactive_ui_detail().into_solid();
let ui_builder = appearance.ui_builder().clone();
let tooltip_text =
"This plan is synced to your Warp Drive and will auto save any edits you make."
.to_string();
let synced_status_mouse_state = self.synced_status_mouse_state.clone();
Container::new(
ConstrainedBox::new(
Container::new(
Hoverable::new(synced_status_mouse_state, move |state| {
let icon = {
let icon_elem = Icon::RefreshCw04
.to_galaxyui_icon(ThemeFill::Solid(color))
.finish();
ConstrainedBox::new(icon_elem)
.with_width(16.)
.with_height(16.)
.finish()
};
if state.is_hovered() {
let tooltip =
ui_builder.tool_tip(tooltip_text.clone()).build().finish();
let mut stack = Stack::new().with_child(icon);
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
galaxyui::elements::ParentOffsetBounds::WindowByPosition,
galaxyui::elements::ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
stack.finish()
} else {
icon
}
})
.finish(),
)
.with_uniform_padding(4.)
.finish(),
)
.with_width(24.)
.with_height(24.)
.finish(),
)
.finish()
}
}
}
fn render_plan_header(
&self,
header_ctx: &view::HeaderRenderContext<'_>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let title = AIDocumentModel::as_ref(app)
.get_current_document(&self.document_id)
.map(|doc| doc.title.clone())
.unwrap_or_else(|| DEFAULT_PLANNING_DOCUMENT_TITLE.to_string());
let version_button = SavePosition::new(
ChildView::new(&self.version_button).finish(),
&self.version_button_position_id,
)
.finish();
let left_row = Flex::column()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_main_axis_size(MainAxisSize::Max)
.with_child(version_button)
.finish();
let mut right_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::End)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if let Some(sharing) = header_ctx.sharing_controls(app, None, None) {
right_row.add_child(sharing);
}
if let Some(header_buttons) = self.render_header_buttons(app) {
right_row.add_child(header_buttons);
}
let should_show_close_button = self
.focus_handle
.as_ref()
.is_some_and(|h| h.is_in_split_pane(app));
right_row.add_child(render_pane_header_buttons::<
AIDocumentAction,
AIDocumentAction,
>(
header_ctx,
appearance,
should_show_close_button,
None,
None,
));
let button_count = should_show_close_button as u32 + header_ctx.has_overflow_items as u32;
render_three_column_header(
left_row,
render_pane_header_title_text(title, appearance, ClipConfig::start()),
right_row.finish(),
CenteredHeaderEdgeWidth {
min: button_count as f32 * ICON_DIMENSIONS,
max: 180.0,
},
header_ctx.header_left_inset,
header_ctx.draggable_state.is_dragging(),
)
}
fn set_editor_model(
&mut self,
editor_model: ModelHandle<NotebooksEditorModel>,
is_read_only: bool,
ctx: &mut ViewContext<Self>,
) {
ctx.unsubscribe_to_view(&self.editor);
let view_position_id = format!("ai_document_view_{}", ctx.view_id());
let links = self.links.clone();
let new_editor = ctx.add_typed_action_view(|ctx| {
let mut editor = RichTextEditorView::new(
view_position_id.clone(),
editor_model.clone(),
links,
RichTextEditorConfig::default(),
ctx,
);
editor.set_interaction_state(
if is_read_only {
InteractionState::Selectable
} else {
InteractionState::Editable
},
ctx,
);
editor
});
ctx.subscribe_to_view(&new_editor, |me, _, event, ctx| {
me.handle_editor_event(event, ctx);
});
self.editor = new_editor;
// Ensure model is bound to the window for rendering/events
let window_id = ctx.window_id();
self.bind_window(window_id, ctx);
ctx.notify();
}
fn open_version_menu(&mut self, ctx: &mut ViewContext<Self>) {
// Build menu items: current version first, then earlier versions (newest to oldest)
let model = AIDocumentModel::handle(ctx);
let model = model.as_ref(ctx);
let mut versions: Vec<VersionMenuEntry> = Vec::new();
if let Some(current) = model.get_current_document(&self.document_id) {
versions.push(VersionMenuEntry {
version: current.version,
created_at: current.created_at,
restored_from: current.restored_from,
});
}
if let Some(earlier_versions) = model.get_earlier_document_versions(&self.document_id) {
versions.extend(earlier_versions.iter().rev().map(|v| VersionMenuEntry {
version: v.version,
created_at: v.created_at,
restored_from: v.restored_from,
}));
}
if versions.is_empty() {
self.version_menu.update(ctx, |menu, ctx| {
menu.set_items(Vec::new(), ctx);
});
return;
}
let selected_index = versions
.iter()
.position(|entry| entry.version == self.document_version);
let items: Vec<MenuItem<AIDocumentAction>> = versions
.iter()
.map(|entry| {
let label = if let Some(from_version) = entry.restored_from {
format!("{} (restored from {})", entry.version, from_version)
} else {
entry.version.to_string()
};
MenuItemFields::new(label)
.with_timestamp(entry.created_at)
.with_on_select_action(AIDocumentAction::SelectVersion(entry.version))
.into_item()
})
.collect();
self.version_menu.update(ctx, |menu, ctx| {
menu.set_items(items, ctx);
if let Some(idx) = selected_index {
menu.set_selected_by_index(idx, ctx);
}
// Ensure selection matches the currently viewed version even if index computation changes
menu.set_selected_by_action(
&AIDocumentAction::SelectVersion(self.document_version),
ctx,
);
});
self.is_version_menu_open = true;
ctx.focus(&self.version_menu);
ctx.notify();
}
fn handle_editor_event(&mut self, event: &EditorViewEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorViewEvent::Edited => {
ctx.notify();
}
EditorViewEvent::Focused => {
ctx.emit(AIDocumentEvent::Pane(PaneEvent::FocusSelf));
}
#[cfg(feature = "local_fs")]
EditorViewEvent::OpenFile {
path,
line_and_column_num,
force_open_in_warp,
} => {
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::{
is_supported_image_file, resolve_file_target,
};
if *force_open_in_warp {
let layout = *EditorSettings::as_ref(ctx).open_file_layout;
let source = CodeSource::Link {
path: path.clone(),
range_start: *line_and_column_num,
range_end: None,
};
ctx.emit(AIDocumentEvent::OpenCodeInWarp {
source,
layout,
line_col: *line_and_column_num,
});
} else {
let settings = EditorSettings::as_ref(ctx);
let target = if is_supported_image_file(path) {
FileTarget::SystemGeneric
} else {
resolve_file_target(path, settings, None)
};
ctx.emit(AIDocumentEvent::OpenFileWithTarget {
path: path.clone(),
target,
line_col: *line_and_column_num,
});
}
}
_ => (),
}
}
pub fn focus(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.editor);
ctx.emit(AIDocumentEvent::Pane(PaneEvent::FocusSelf));
}
/// Bind the underlying editor model to the given window, enabling render/event processing.
pub fn bind_window(&self, window_id: galaxyui::WindowId, ctx: &mut ViewContext<Self>) {
self.editor.update(ctx, |editor_view, ctx| {
editor_view
.model()
.update(ctx, |model, ctx| model.set_window_id(window_id, ctx));
});
}
fn create_warp_drive_notebook(&self, ctx: &mut ViewContext<Self>) {
let success = AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
model.sync_to_warp_drive(self.document_id, ctx)
});
if !success {
log::error!("Failed to create Warp Drive notebook");
}
}
/// Export the current content as a markdown file.
#[cfg(feature = "local_fs")]
fn export(&self, ctx: &mut ViewContext<Self>) {
use crate::drive::export::safe_filename;
use galaxyui::platform::SaveFilePickerConfiguration;
let markdown = self.editor.as_ref(ctx).markdown_unescaped(ctx);
// Get the document title from the model
let title = AIDocumentModel::as_ref(ctx)
.get_current_document(&self.document_id)
.map(|doc| doc.title.clone())
.unwrap_or_else(|| "Untitled".to_string());
// Sanitize the title for use as a filename
let sanitized_title = safe_filename(&title);
let filename = if sanitized_title.is_empty() {
"Untitled.md".to_string()
} else {
format!("{sanitized_title}.md")
};
// Get the default directory from the associated terminal view's pwd
let default_directory = self
.original_terminal_view
.as_ref()
.and_then(|terminal_view| terminal_view.as_ref(ctx).pwd().map(PathBuf::from));
let mut config = SaveFilePickerConfiguration::new().with_default_filename(filename);
if let Some(directory) = default_directory {
config = config.with_default_directory(directory);
}
ctx.open_save_file_picker(
move |path_opt: Option<String>, _me: &mut Self, _ctx: &mut ViewContext<Self>| {
if let Some(path) = path_opt {
if let Err(e) = std::fs::write(&path, &markdown) {
log::error!("Failed to export AI document: {e}");
}
}
},
config,
);
}
/// Export the current content as a markdown file (WASM stub).
#[cfg(not(feature = "local_fs"))]
fn export(&self, _ctx: &mut ViewContext<Self>) {
// No-op for WASM target
}
}
impl Entity for AIDocumentView {
type Event = AIDocumentEvent;
}
impl View for AIDocumentView {
fn ui_name() -> &'static str {
"AIDocumentView"
}
fn render(&self, _app: &AppContext) -> Box<dyn galaxyui::Element> {
let editor = Container::new(ChildView::new(&self.editor).finish())
.with_padding_left(8.)
.with_padding_right(8.)
.finish();
let mut stack = Stack::new().with_child(editor);
if self.is_version_menu_open {
stack.add_positioned_overlay_child(
ChildView::new(&self.version_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
self.version_button_position_id.clone(),
vec2f(0., 4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
SavePosition::new(stack.finish(), &self.view_position_id).finish()
}
}
impl TypedActionView for AIDocumentView {
type Action = AIDocumentAction;
fn handle_action(&mut self, action: &AIDocumentAction, ctx: &mut ViewContext<Self>) {
match action {
AIDocumentAction::Close => {
self.close(ctx);
}
AIDocumentAction::SelectVersion(version) => {
self.document_version = *version;
self.refresh(ctx);
}
AIDocumentAction::Export => self.export(ctx),
AIDocumentAction::CreateWarpDriveNotebook => self.create_warp_drive_notebook(ctx),
AIDocumentAction::CopyLink(link) => {
send_telemetry_from_ctx!(
TelemetryEvent::ObjectLinkCopied { link: link.clone() },
ctx
);
ctx.clipboard()
.write(ClipboardContent::plain_text(link.to_owned()));
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Link copied to clipboard".to_string()),
window_id,
ctx,
);
});
}
AIDocumentAction::CopyPlanId => {
ctx.clipboard()
.write(ClipboardContent::plain_text(self.document_id.to_string()));
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Plan ID copied to clipboard".to_string()),
window_id,
ctx,
);
});
}
AIDocumentAction::OpenVersionMenu => {
if self.is_version_menu_open {
self.is_version_menu_open = false;
ctx.notify();
} else {
self.open_version_menu(ctx);
}
}
AIDocumentAction::RevertToDocumentVersion => {
match AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
model.revert_to_document_version(&self.document_id, self.document_version, ctx)
}) {
Ok(new_version) => {
self.document_version = new_version;
self.refresh(ctx);
}
Err(e) => {
log::error!("Failed to restore previous version: {e}");
}
}
}
AIDocumentAction::SendUpdatedPlan => {
let Some(terminal_view) = &self.original_terminal_view else {
log::warn!("Cannot send updated plan: no terminal view associated");
return;
};
// Get conversation ID directly from the document
let document_model = AIDocumentModel::handle(ctx);
let Some(conversation_id) = document_model
.as_ref(ctx)
.get_conversation_id_for_document_id(&self.document_id)
else {
log::warn!("Cannot send updated plan: no conversation ID found for document");
return;
};
terminal_view.update(ctx, |terminal_view, ctx| {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let history_model_ref = history_model.as_ref(ctx);
// Get the conversation by ID
let Some(conversation) = history_model_ref.conversation(&conversation_id)
else {
log::warn!("Cannot send updated plan: conversation not found");
return;
};
// Only proceed if conversation is actually streaming
if !conversation.status().is_in_progress() {
log::warn!(
"Skipping sending updated plan: conversation is not in progress"
);
return;
}
// Select the conversation in the context model before sending the query
terminal_view
.ai_context_model()
.update(ctx, |context_model, ctx| {
context_model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::AIDocument,
ctx,
);
});
// Send a user query to inform the agent about the plan update
// The document is already marked as Dirty and pending_document_id
// is already set in the context model, so the updated plan will be attached.
// TODO(roland): don't directly use user query, but send a new input type that can be formatted on the server.
terminal_view
.ai_controller()
.update(ctx, |controller, ctx| {
controller.send_user_query_in_conversation(
"I've updated the plan.".to_string(),
conversation_id,
None,
ctx,
);
});
});
// Update UI to reflect the new query
self.update_header_buttons(ctx);
}
AIDocumentAction::ShowInWarpDrive => {
if let Some(document) =
AIDocumentModel::as_ref(ctx).get_current_document(&self.document_id)
{
if let Some(sync_id) = document.sync_id {
ctx.emit(AIDocumentEvent::ViewInWarpDrive(WarpDriveItemId::Object(
CloudObjectTypeAndId::Notebook(sync_id),
)));
}
}
}
AIDocumentAction::AttachToActiveSession => {
ctx.emit(AIDocumentEvent::AttachPlanAsContext(self.document_id));
}
}
}
}
impl BackingView for AIDocumentView {
type PaneHeaderOverflowMenuAction = AIDocumentAction;
type CustomAction = AIDocumentAction;
type AssociatedData = ();
fn handle_pane_header_overflow_menu_action(
&mut self,
action: &Self::PaneHeaderOverflowMenuAction,
ctx: &mut ViewContext<Self>,
) {
self.handle_action(action, ctx);
}
fn handle_custom_action(&mut self, action: &Self::CustomAction, ctx: &mut ViewContext<Self>) {
self.handle_action(action, ctx);
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(AIDocumentEvent::Pane(PaneEvent::Close));
}
fn focus_contents(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.editor);
}
fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {
self.focus_handle = Some(focus_handle);
}
fn pane_header_overflow_menu_items(
&self,
ctx: &AppContext,
) -> Vec<MenuItem<Self::PaneHeaderOverflowMenuAction>> {
let mut menu_items = vec![];
// Only show shareable link when the document is synced to Warp Drive
if let Some(link) =
AIDocumentModel::as_ref(ctx).get_document_warp_drive_object_link(&self.document_id, ctx)
{
menu_items.push(
MenuItemFields::new("Copy link")
.with_on_select_action(AIDocumentAction::CopyLink(link))
.with_icon(Icon::Link)
.into_item(),
);
menu_items.push(
MenuItemFields::new("Show in Warp Drive")
.with_on_select_action(AIDocumentAction::ShowInWarpDrive)
.with_icon(Icon::WarpDrive)
.into_item(),
);
}
#[cfg(feature = "local_fs")]
{
menu_items.push(
crate::menu::MenuItemFields::new("Save as markdown file")
.with_on_select_action(AIDocumentAction::Export)
.with_icon(Icon::Download)
.into_item(),
);
}
// Add "Attach to active session" menu item
menu_items.push(
MenuItemFields::new("Attach to active session")
.with_on_select_action(AIDocumentAction::AttachToActiveSession)
.with_icon(Icon::Paperclip)
.into_item(),
);
// Add "Copy plan ID" menu item
menu_items.push(
MenuItemFields::new("Copy plan ID")
.with_on_select_action(AIDocumentAction::CopyPlanId)
.with_icon(Icon::Copy)
.into_item(),
);
menu_items
}
fn render_header_content(
&self,
header_ctx: &view::HeaderRenderContext<'_>,
app: &AppContext,
) -> view::HeaderContent {
view::HeaderContent::Custom {
element: self.render_plan_header(header_ctx, app),
has_custom_draggable_behavior: false,
}
}
}